By default, TOTP MFA is opt-in per user (User.mfaEnabled) or per school (School.mfaEnabled) — a compromised admin password alone grants full access unless MFA was actively set up.
Setting REQUIRE_MFA=true closes this gap: on login, an admin with no MFA configured (neither their own nor their school's) receives a restricted session token instead of full access. requireSchoolAuth (backend/src/middleware/auth.js) rejects every request from a restricted token except the MFA enrollment endpoints (POST /api/auth/mfa/user/setup, POST /api/auth/mfa/user/verify) with 403 MFA_SETUP_REQUIRED, so the frontend must complete enrollment before any protected endpoint becomes reachable. Once POST /api/auth/mfa/user/verify succeeds, the restriction is lifted for the current session immediately.
The login response includes mfaSetupRequired: true when this restricted session is issued, which the frontend uses to redirect to the MFA setup flow instead of the dashboard.
REQUIRE_MFA does not apply to the environment-configured super-admin break-glass account, which is not backed by the User/School MFA fields.
JWT secrets, the Stellar signing-key encryption key (SIGNER_MASTER_KEY), and the webhook
secret encryption key (WEBHOOK_SECRET_ENCRYPTION_KEY) have scripted rotation — see
scripts/rotate-jwt-secret.js, scripts/rotate-signer-master-key.js, and
scripts/rotate-webhook-encryption-key.js, and the "Key Rotation" section of
docs/operator-runbooks.md for when and how to run them.
backend/src/services/webhookSecretEncryption.js encrypts every school's webhookSecret at
rest (AES-256-GCM) using a key derived from WEBHOOK_SECRET_ENCRYPTION_KEY. Rotating that key
without re-encrypting the stored secrets would make every existing secret undecryptable with
the new key, breaking outbound webhook signing for every school (#1380). Rotation is scripted
and supports a dual-key grace period so there is no cutover window where deliveries fail.
Procedure:
- Generate a new key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))". - Set
WEBHOOK_SECRET_ENCRYPTION_KEY_PREVIOUSto the current key (the one already protecting stored secrets) andWEBHOOK_SECRET_ENCRYPTION_KEYto the new key, both in the deployment's secret store. - Run the migration script against the target database, first as a dry run:
Review the per-school report, then re-run with
WEBHOOK_SECRET_ENCRYPTION_KEY_PREVIOUS=<current key> WEBHOOK_SECRET_ENCRYPTION_KEY=<new key> \ node scripts/rotate-webhook-encryption-key.js
--applyto persist the re-encrypted values:WEBHOOK_SECRET_ENCRYPTION_KEY_PREVIOUS=<current key> WEBHOOK_SECRET_ENCRYPTION_KEY=<new key> \ node scripts/rotate-webhook-encryption-key.js --apply
- Redeploy the API and worker instances with both env vars set. While
WEBHOOK_SECRET_ENCRYPTION_KEY_PREVIOUSis present,decryptWebhookSecret()transparently falls back to it whenever the new key fails an auth-tag check — this covers any secret the migration script hasn't reached yet and any instance still finishing a rolling deploy, so webhook deliveries are never interrupted mid-rotation. - Once the rollout is complete and step 3's
--applyrun reports zero failures, dropWEBHOOK_SECRET_ENCRYPTION_KEY_PREVIOUSfrom the deployment and redeploy again to close the grace period. - Verify a live webhook delivery signs correctly, then record the rotation time and operator
in the incident log per the general rotation order in
docs/operator-runbooks.md.
Skipping step 2 (or dropping the previous key before step 3 completes) reproduces the original failure mode: any secret the script hasn't re-encrypted yet becomes unreadable, and signing outbound webhooks for that school silently breaks.
backend/src/utils/signerKeyManager.js encrypts every school's Stellar signing secret key at
rest under a single master key. By default that master key is read from the
SIGNER_MASTER_KEY environment variable, which is the simplest option but has real exposure in
production: env vars are visible in docker inspect output and /proc/<pid>/environ on any node
running the container, in Kubernetes Secret manifests that may end up committed to version
control or stored unencrypted in etcd, and in CI logs that print the environment on failure.
To avoid holding the plaintext key in an env var, set SIGNER_KEY_SOURCE and call
initializeMasterKey() once at startup (already wired into backend/src/app.js's boot sequence)
to resolve it from a real secrets manager instead:
SIGNER_KEY_SOURCE |
Behavior |
|---|---|
env (default) |
getMasterKey() reads SIGNER_MASTER_KEY directly — unchanged from before this feature. |
aws_secrets_manager |
Fetches the secret named by SIGNER_MASTER_KEY_SECRET_ID via @aws-sdk/client-secrets-manager, using AWS_REGION and the SDK's normal credential provider chain (IAM role, instance profile, etc. — no static AWS keys need to live in this app's config). |
http |
Fetches from any HTTP secrets endpoint at SIGNER_MASTER_KEY_HTTP_URL (e.g. a Vault or GCP Secret Manager sidecar/proxy), sending SIGNER_MASTER_KEY_HTTP_TOKEN as a bearer token when set. |
Either provider may return the key as a bare 64-character hex string or as JSON
({"SIGNER_MASTER_KEY": "<hex>"} or {"key": "<hex>"}). The resolved key is cached in memory
only for the life of the process — it is never written back to process.env or to disk — and
getMasterKey() prefers it over SIGNER_MASTER_KEY whenever initializeMasterKey() has
populated it, so a misconfigured or unreachable provider fails loudly at boot (the process exits
non-zero, per backend/src/app.js) instead of silently falling back to an unset env var.
This does not change key rotation mechanics — reEncryptSecretKey() and
scripts/rotate-signer-master-key.js still apply; point SIGNER_MASTER_KEY_OLD /
SIGNER_MASTER_KEY (or the provider-backed equivalents) at the old and new key material as
described above.
A dedicated CI job (scripts/scan-repo-secrets.js, the secret-scan-repo job in
.github/workflows/ci.yml) scans every tracked file in the repository for real Stellar StrKey
secret keys (S + 55 base32 chars) so one can never be committed regardless of which
SIGNER_KEY_SOURCE a deployment uses.
StellarEduPay enforces a Content Security Policy on all HTTP responses to mitigate XSS attacks. The policy is applied at two layers: the Next.js frontend and the Express backend.
Without CSP, a successful XSS injection (e.g. a malicious student name rendered in the dashboard) can execute arbitrary JavaScript in the admin's browser, steal the JWT from localStorage, and exfiltrate school data. CSP prevents this by restricting which scripts, styles, and network destinations the browser will allow.
Applied to every HTML response via the Next.js headers() API:
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
font-src 'self';
connect-src 'self' https://horizon-testnet.stellar.org https://horizon.stellar.org;
object-src 'none';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'
| Directive | Value | Rationale |
|---|---|---|
default-src |
'self' |
Deny all unlisted resource types by default |
script-src |
'self' |
No inline scripts, no eval, no third-party JS |
style-src |
'self' |
No inline styles, no third-party CSS |
img-src |
'self' data: |
Allows inline SVG/base64 images used by the UI |
font-src |
'self' |
Self-hosted fonts only |
connect-src |
'self' https://horizon-testnet.stellar.org https://horizon.stellar.org |
Allows fetch/XHR to the backend API and both Stellar Horizon endpoints |
object-src |
'none' |
Blocks Flash and other plugins |
frame-ancestors |
'none' |
Prevents clickjacking (equivalent to X-Frame-Options: DENY) |
base-uri |
'self' |
Prevents base-tag hijacking |
form-action |
'self' |
Restricts form submissions to the same origin |
Additional security headers set alongside CSP:
| Header | Value |
|---|---|
X-Frame-Options |
DENY |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
The Express backend serves only JSON API responses — directives for scripts, styles, and images are irrelevant. Helmet is configured with a minimal policy appropriate for an API:
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
frameAncestors: ["'none'"],
},
},
})default-src 'none' means the browser should load nothing from this origin as a document resource. frame-ancestors 'none' prevents the API responses from being embedded in frames.
The CSP configuration is covered by tests/csp.test.js, which verifies:
- The frontend
next.config.jsexports aheaders()function returning a catch-all entry with aContent-Security-Policyheader. - The frontend CSP includes
default-src 'self',script-src 'self',frame-ancestors 'none',object-src 'none', and the Stellar Horizonconnect-srcallowlist. - The frontend CSP does not contain
'unsafe-inline'or'unsafe-eval'. - The backend
app.jssetsdefaultSrc: ["'none'"]andframeAncestors: ["'none'"]and does not includescriptSrc,styleSrc,imgSrc,'unsafe-inline', or'unsafe-eval'.
Run the tests with:
npm test -- tests/csp.test.jsThe CSP allow-list is defined in a single source of truth:
frontend/src/config/cspSources.js
Both frontend/next.config.js (runtime policy) and tests/csp.test.js (assertions) import from this module. Adding or removing an origin requires editing exactly one file — the change is automatically reflected in the deployed CSP header and in the test suite.
To add a new external origin:
- Open
frontend/src/config/cspSources.js. - Add the full origin (scheme + host, no trailing slash) to the appropriate array:
CONNECT_SRC_ORIGINS— fetch/XHR targets (APIs, WebSockets)STYLE_SRC_ORIGINS— external stylesheetsFONT_SRC_ORIGINS— external font files
- That's it. Run
npm test -- tests/csp.test.jsto confirm.
Do not add 'unsafe-inline' or 'unsafe-eval' to script-src. If a third-party library requires inline scripts, use a nonce-based approach instead.
All outbound webhook URLs pass through a multi-layer SSRF defence on every delivery attempt.
validateWebhookUrl(url) is called when an endpoint is created or updated:
- Only
https://scheme is accepted. - Well-known internal hostnames (
localhost,*.local,*.internal,*.localhost,*.test,*.invalid) are rejected without a DNS lookup. - Bare IP literals are checked directly against the deny list.
- DNS is resolved and all returned addresses (A + AAAA) must be public.
Immediately before every HTTP delivery the hostname is re-resolved and every IP is re-checked. If the hostname now resolves to a private address (DNS rebinding attack), the delivery is aborted with error SSRF_BLOCKED.
Both IPv4 and IPv6 are covered:
| Range | Reason |
|---|---|
| 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | RFC 1918 private |
| 127.0.0.0/8 | Loopback |
| 169.254.0.0/16 | Link-local / AWS metadata |
| 100.64.0.0/10 | CGNAT (RFC 6598) |
| ::1 | IPv6 loopback |
| fe80::/10 | IPv6 link-local |
| fc00::/7 | IPv6 ULA |
| ::ffff:0:0/96 | IPv4-mapped (delegates to IPv4 check) |
| 64:ff9b::/96 | NAT64 prefix |
The Axios instance used for delivery is configured with maxRedirects: 0. Any 3xx response is treated as a delivery failure with error code SSRF_REDIRECT_BLOCKED and is not followed.
Response bodies are capped at 64 KB (maxContentLength: 65536). Requests that exceed this are aborted.
StellarEduPay implements automatic anonymization of student personally identifiable information (PII) to reduce breach exposure and align with data minimization principles from GDPR and similar privacy frameworks.
Once the retention window expires for a soft-deleted student record, the following PII fields are cleared:
| Field | Cleared to |
|---|---|
name |
"Anonymized" |
dateOfBirth |
null |
gender |
null |
parentName |
null |
contactNumber |
null |
parentPhone |
null |
Non-PII fields retained for audit and reconciliation purposes:
studentId,schoolId,class,academicYear— for payment trackingfeeAmount,totalPaid,remainingBalance,fees— for billing history- Payment and transaction records — for regulatory compliance
Environment variable: STUDENT_PII_RETENTION_DAYS (default: 90)
- Specifies the number of days to retain PII after soft-delete before anonymization
- Default of 90 days (~3 months) balances operational need against breach exposure
- Set to
0to disable anonymization (not recommended for production)
Example:
# Retain PII for 180 days (6 months) before anonymization
STUDENT_PII_RETENTION_DAYS=180The piiAnonymizationScheduler runs once daily on the leader node and:
- Queries for soft-deleted students whose deletion date exceeds
STUDENT_PII_RETENTION_DAYS - Skips records already anonymized (name="Anonymized" and all other PII fields null)
- Bulk-updates matching records to clear sensitive fields
- Logs the count of anonymized records for audit purposes
The scheduler is integrated into the leader-election system, ensuring it runs on exactly one instance in a clustered deployment.
This implementation supports:
- GDPR Article 5 (data minimization) — data kept no longer than necessary
- GDPR Article 17 (right to be forgotten) — PII erasure after the retention window
- Local privacy regulations — configurable retention period per deployment
Schools should configure STUDENT_PII_RETENTION_DAYS to match their legal obligations and operational needs.
To comply with data minimization requirements (GDPR Article 5, storage limitation), the platform automatically redacts personal data from all application logs.
All log entries at info level and above have the following fields masked:
| Field | Redaction Format |
|---|---|
email, parentEmail, contactEmail, loginId |
u***@example.com (first char + domain visible) |
phone, parentPhone |
****7890 (last 4 digits visible) |
name |
J*** S. (first initial + last initial) |
studentId, memo, senderAddress, walletAddress |
[REDACTED] |
- Raw IP addresses are never logged
- Instead, IPs are hashed using HMAC-SHA256 with a per-boot secret
- Same IP produces the same hash within a server session (for correlation/debugging)
- Hashes are not reversible and do not persist across server restarts
- Query strings are stripped from all logged URLs to prevent token/secret leakage
- Example:
/api/payments?token=abc123→/api/payments
All environment variables matching the pattern /(SECRET|PASSWORD|TOKEN|KEY|URI|HASH)$/i are redacted in config dumps, with explicit exceptions for known non-secret keys like SIGNER_KEY_SOURCE, EMAIL_PROVIDER, etc.
Credentials embedded in URIs are also redacted:
mongodb://user:pass@host→mongodb://user:[REDACTED]@hostredis://:pass@host→redis://:[REDACTED]@host
Redaction is applied in three layers:
- Logger format (
backend/src/utils/logger.js): TheformatMessage()function appliesredactPii()to all log arguments at info level and above - Request logger (
backend/src/middleware/requestLogger.js): IP addresses are hashed viahashIp(), URLs are stripped viastripQueryString() - Auth middleware (
backend/src/middleware/auth.js): Failed auth attempts log hashed IPs instead of raw IPs
Run the test suite to verify redaction:
npm test -- backend/tests/piiRedaction.test.jsThe test verifies:
- Email addresses are masked in logs
- Phone numbers are masked in logs
- Names are masked in logs
- IP addresses are hashed (not logged in plaintext)
- Query strings are stripped from URLs
- Config dumps redact all secret-pattern environment variables
Audit logs (audit_log collection in MongoDB) are not redacted because they are the authoritative record for compliance and forensic investigation. However:
- Audit logs are access-controlled (admin-only via authenticated API)
- Audit logs respect the retention period configured in
AUDIT_LOG_RETENTION_DAYS - Audit logs are never written to file logs or stdout
| Log Type | Retention | Location |
|---|---|---|
| File logs (combined/error) | LOG_MAX_FILES (default: 14 days) |
logs/combined-*.log, logs/error-*.log |
| Aggregated logs | Per aggregator config | ELK, Datadog, CloudWatch, etc. |
| Audit logs | AUDIT_LOG_RETENTION_DAYS (default: 365 days) |
MongoDB audit_log collection |
File logs are automatically rotated and deleted by winston-daily-rotate-file.
- Configure log aggregators to respect the platform's retention policy
- Restrict access to log files and aggregation tools — logs still contain operational context that could be sensitive
- Do not disable redaction — all PII redaction is mandatory for production use
- Monitor alerts — admin alerts may contain masked PII; ensure alert channels are access-controlled
- Data Privacy and Retention Policy — full data retention and data subject rights
- Audit Service — audit log implementation
- PII Redaction Utilities — redaction functions
Every authenticated, state-changing request passes through auditContext middleware and calls logAudit in the handler. All entries land in the tamper-evident audit_log MongoDB collection.
An automated regression test (tests/issue-1554-audit-context-coverage.test.js) walks the Express router stack and fails if a non-GET authenticated route is missing auditContext.
| Action | Trigger |
|---|---|
STUDENT_REGISTERED |
POST /api/students |
STUDENT_UPDATED |
PATCH /api/students/:studentId |
STUDENT_DELETED |
DELETE /api/students/:studentId |
STUDENT_RESTORED |
POST /api/students/:studentId/restore |
STUDENT_BULK_IMPORTED |
POST /api/students/bulk |
STUDENT_PAYMENT_RESET |
POST /api/students/:studentId/reset-payment |
STUDENT_RECONCILED |
POST /api/students/:studentId/reconcile |
STUDENT_REMINDER_RESUBSCRIBED |
POST /api/students/:studentId/reminders/resubscribe |
STUDENT_CREDIT_ADJUSTED |
POST /api/students/:studentId/credit-adjustments |
| Action | Trigger |
|---|---|
PAYMENT_SYNC |
POST /api/payments/sync |
PAYMENT_FINALIZED |
POST /api/payments/finalize |
PAYMENT_STATUS_UPDATED |
PATCH /api/payments/:txHash/status |
PAYMENT_BULK_STATUS_UPDATED |
PATCH /api/payments/bulk/status |
PAYMENT_LOCKED |
POST /api/payments/:paymentId/lock |
PAYMENT_UNLOCKED |
POST /api/payments/:paymentId/unlock |
PAYMENT_DLQ_RETRIED |
POST /api/payments/dlq/:id/retry |
PAYMENT_SUSPICION_REVIEWED |
PATCH /api/payments/:txHash/suspicion-review |
PAYMENT_PLACEHOLDER_CORRECTED |
PATCH /api/payments/:txHash/correct-placeholder |
PAYMENT_REFUND_INITIATED |
POST /api/payments/:txHash/refund |
PAYMENT_REFUND_APPROVED |
POST /api/payments/refunds/:refundId/approve |
RECONCILIATION_REPORT_GENERATED |
POST /api/payments/reconciliation/report |
| Action | Trigger |
|---|---|
FEE_ADJUSTMENT_CREATED |
POST /api/fee-adjustments |
FEE_ADJUSTMENT_UPDATED |
PUT /api/fee-adjustments/:id |
FEE_ADJUSTMENT_DELETED |
DELETE /api/fee-adjustments/:id |
FEE_ADJUSTMENT_DRY_RUN |
POST /api/fee-adjustments/dry-run |
FEE_ADJUSTMENT_APPLIED |
POST /api/fee-adjustments/:id/apply |
| Action | Trigger |
|---|---|
RETRY_QUEUE_JOB_RETRIED |
POST /api/retry-queue/jobs/:jobId/retry |
RETRY_QUEUE_JOB_DELETED |
DELETE /api/retry-queue/jobs/:jobId |
RETRY_QUEUE_PAUSED |
POST /api/retry-queue/pause |
RETRY_QUEUE_RESUMED |
POST /api/retry-queue/resume |
RETRY_QUEUE_TRANSACTION_QUEUED |
POST /api/retry-queue/queue |
| Action | Trigger |
|---|---|
EMAIL_SUPPRESSION_ADDED |
POST /api/email/suppressions |
EMAIL_SUPPRESSION_REMOVED |
DELETE /api/email/suppressions/:email |
| Action | Trigger |
|---|---|
PASSWORD_CHANGED |
POST /api/auth/change-password |
SESSION_REVOKED |
DELETE /api/auth/sessions/:sessionId |
MFA_ENABLED |
POST /api/auth/mfa/setup → POST /api/auth/mfa/verify |
MFA_DISABLED |
POST /api/auth/mfa/disable |
MFA_BACKUP_CODES_REGENERATED |
POST /api/auth/mfa/backup-codes/regenerate |
USER_MFA_SETUP_INITIATED |
POST /api/auth/mfa/user/setup |
USER_MFA_ENABLED |
POST /api/auth/mfa/user/verify |
USER_MFA_DISABLED |
POST /api/auth/mfa/user/disable |
These endpoints either have no authenticated actor at call time or use their own documented verification mechanism:
| Endpoint | Reason |
|---|---|
POST /api/auth/login |
No actor yet; auth result logged by controller |
POST /api/auth/refresh |
Token rotation; no actor context |
POST /api/auth/logout |
Session teardown; no mutation |
POST /api/payments/intent |
Unauthenticated caller |
POST /api/payments/submit |
Unauthenticated caller |
POST /api/payments/verify |
Unauthenticated caller |
POST /api/email/webhooks/:provider |
Provider-authenticated (SNS/SendGrid signature) |