This document outlines the security architecture, cryptographic controls, authentication lifecycle, threat models, and deployment requirements for the DeutschFlow learning system.
DeutschFlow uses a signed, stateless session token architecture stored in an HttpOnly, SameSite=Lax cookie (df_access).
The token format is <expiresAt>.<hmacSignature>, where:
expiresAt: Millisecond epoch timestamp when the session expires.hmacSignature: Base64URL-encoded HMAC-SHA256 signature generated overexpiresAt.
To prevent hardcoded or static key material, the active signing key is derived dynamically:
signingKey = HMAC-SHA256(SESSION_SECRET, "${SESSION_SECRET}:${SITE_PASSWORD}:${SESSION_EPOCH}")
This multi-factor derivation guarantees:
- Password Invalidation: Changing
SITE_PASSWORDimmediately invalidates all existing sessions across all browsers. - Key Rotation: Rotating
SESSION_SECRETimmediately invalidates all outstanding sessions. - Instant Global Revocation: Incrementing
SESSION_EPOCH(e.g., from"1"to"2") immediately invalidates all active sessions without requiring password changes.
- Default Duration: 30 days (
2,592,000seconds). - Configuration: Overridden via the
SESSION_MAX_AGE_SECONDSenvironment variable. - Safety Bounds: Enforced between 1 hour (
3,600seconds) and 90 days (7,776,000seconds). Out-of-bound values safely default to 30 days.
- The logout endpoint (
POST /api/logout) strictly requires a POST method and rejects a request whoseOriginheader names another origin (isAllowedOrigininsrc/app/api/logout/route.ts). A request that carries noOriginheader is allowed: the endpoint only clears this browser's own cookie, so the worst case is a self-inflicted logout, and a non-browser client that omitsOriginhas nothing to forge. SameSite=Laxondf_accessis the second line of defence: a cross-site form POST does not carry the cookie at all, so a third-party page cannot even reach a state change here.GET /api/logoutis strictly disabled: it returns HTTP 405 Method Not Allowed (Allow: POST) and executes no state changes, preventing prefetch logouts and unauthorized GET CSRF.- Upon logout, the
df_accesscookie is cleared withmaxAge: 0andexpires: Epoch 0, alongside cache-invalidation headers (Cache-Control: no-store).
To mitigate brute-force password guessing against the gate:
- Free Attempts: Up to 3 attempts allowed without delay.
- Exponential Backoff: Doubling cooldown starting at 1s for the 4th attempt (
1s, 2s, 4s, 8s, 16s...) capped at30s. - Sliding Window: 15 minutes (
WINDOW_MS = 900,000ms).
- Deployment Strategy: When deployed on serverless edge environments (e.g., Vercel), in-memory state is not shared across isolated containers.
- Centralized Integration: Setting
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENroutes throttle state to a shared atomic Redis store via HTTPS REST. - Atomic Lua Script & Multi-Exec:
- Pipeline Non-Atomicity: Note that per official Upstash documentation,
/pipelinebatches commands over a single HTTP connection but does not execute them atomically or isolate them from concurrent operations. - Atomic State Decisions: To eliminate read-modify-write race conditions when evaluating lockout thresholds,
recordFailureexecutes an atomic Lua script overPOST /(["EVAL", script, <key count>, countKey, lastKey, ...]). The script atomically inspects the current attempt count, increments it, applies lockout cooldowns, and updates the sliding expiration in a single isolated step. - Transactional Batching: For operations without conditional dependencies (e.g. setting records, checking counters, and clearing keys), the engine uses
/multi-execto guarantee atomic multi-command execution.
- Pipeline Non-Atomicity: Note that per official Upstash documentation,
- Configurable Failure Policy (
THROTTLE_FAILURE_POLICY):fail-secure-fallback(default): If Redis times out or is unreachable, the system falls back to an in-memory LRU rate limiter. This provides local burst defense per node while keeping the application accessible.fail-closed: If Redis fails or is unreachable, the endpoint strictly rejects authentication requests with HTTP 503 Service Unavailable (ThrottleStoreError), ensuring no attacker can bypass distributed lockouts during a store outage.
- Distributed Protection vs Local Fallback Distinction: Local LRU fallback protects individual instances from rapid flooding, but cannot synchronize lockouts globally across ephemeral serverless instances. For full production-grade distributed enforcement, a highly available Upstash Redis cluster with valid credentials and network egress is required.
- Forwarded client-IP headers (
x-forwarded-for,x-real-ip) are only trusted when deployed behind a validated edge infrastructure boundary (process.env.VERCEL === "1"with edge request provenance). - Standards-Compliant IP Validation: Client IP format is validated using Node.js's complete
node:net.isIPstandard validator rather than permissive regular expressions. This strictly rejects malformed addresses, trailing characters, invalid separators, and IPv4 octets above 255, while correctly accepting valid compressed IPv6 (e.g.::1,::) and IPv4-mapped IPv6 addresses (::ffff:192.0.2.128). - Edge Header Precedence & Anti-Spoofing Chain Resolution:
x-real-ipis prioritized as Vercel's edge-verified single client socket address.- When inspecting
x-forwarded-for, incoming reverse proxies append the connecting IP to the end of the chain. To prevent callers from evading rate limits by prepending spoofed IPs to their own request headers, the validator selects the edge-appended rightmost hop.
- Clarification on
x-vercel-id:x-vercel-idis an internal routing trace header generated by Vercel for diagnostic and deployment identification, not an authenticated cryptographic edge signature. - Non-Vercel / Multi-Proxy Limitations: If deployed outside Vercel (or behind arbitrary untrusted reverse proxies),
x-forwarded-forcan easily be forged by clients if the ingress proxy does not strip or overwrite incoming forwarding headers. In unverified or untrusted environments, caller identities default to a safe shared fallback bucket (unknown) to prevent attackers from arbitrarily evading rate limits by spoofing random IP headers.- Consequence of the shared bucket: one attacker's failed guesses put every user into the cooldown, and any user's successful login resets the bucket, giving the attacker fresh free attempts. The reset can't simply be skipped: admission counts successful logins too, so without it any three correct logins within the window would lock everyone out. A strong
SITE_PASSWORDis the real defence here, or setTRUSTED_PROXY_HEADER. - Opt-in for self-hosting behind your own proxy: set
TRUSTED_PROXY_HEADERto the header your ingress proxy sets or appends (for exampleX-Forwarded-Forfor nginxproxy_add_x_forwarded_for, orX-Real-IP). The rightmost valid address in it becomes the caller key. Only set it if that proxy is the only way to reach the app and it overwrites or appends the header; otherwise clients can pick their own throttle identity.
- Consequence of the shared bucket: one attacker's failed guesses put every user into the cooldown, and any user's successful login resets the bucket, giving the attacker fresh free attempts. The reset can't simply be skipped: admission counts successful logins too, so without it any three correct logins within the window would lock everyone out. A strong
DeutschFlow enforces strict security headers:
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://api.groq.com; worker-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'
Strict-Transport-Security: max-age=31536000
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: microphone=(self), camera=(), geolocation=()- DeutschFlow is a 100% client-rendered, local-first PWA that statically pre-renders all application routes and boots completely offline from a service worker precache.
- Next.js static HTML generation injects inline hydration bootstrap scripts that cannot use dynamic per-request nonces without disabling static rendering and breaking offline boot.
- Crucial Boundary: Because
script-srcincludes'unsafe-inline', the CSP header alone does not protect against inline script execution if untrusted HTML reaches the DOM. Comprehensive XSS resistance is instead established through deep defense-in-depth:- React/JSX automatic output encoding for all rendered strings.
- Prohibited Sinks Policy: The codebase strictly prohibits
dangerouslySetInnerHTML,innerHTML,outerHTML,eval(),new Function(), anddocument.write(). - Automated Regression Testing:
web/tests/security-sinks.test.tsstatically audits all TypeScript/TSX source files on every test run. - Input validation and byte length bounding on all backup imports.
- Groq API keys on Android are encrypted using the hardware-backed Android Keystore (
KeyGenParameterSpecwithAES/GCM/NoPadding). - Encryption keys never leave the secure hardware element.
- In the Web PWA, the API key is encrypted using WebCrypto
AES-GCM-256before writing to IndexedDB (settingsstore). - The AES key is generated as a non-extractable
CryptoKeyand stored in a separate IndexedDB database (deutschflow-vault), not in the settings row. It is not derived from any user- or origin-supplied seed: extraction is refused by the key itself, so a copied profile directory is not enough to read the ciphertext./api/logout-time erasure and Log out and erase this device both destroy it (src/lib/db/vault.ts,src/lib/db/erase.ts). - The plaintext key is reconstructed in memory before each AI request, so script running on this origin — or a malicious extension — can read it. That boundary is stated at the top of
src/lib/db/vault.ts; it is the reason this is not the analogue of a hardware keystore. - All AI error messages, URLs, and logs are scrubbed with
redactSensitiveKeyto prevent credential leakage.
- The service worker (
sw.js) caches the static build output and the app-shell HTML of the protected routes it precaches, which is what makes offline boot work. Cached pages are only served for a navigation that cannot reach the network, and any navigation answered with a redirect to/loginpurges them. - Application data (transcripts, vocabulary, review events, roleplay conversations) resides strictly within IndexedDB, never in the HTTP Cache Storage.
- Offline access outlives a server-side session expiry. The session is an HttpOnly cookie the worker cannot read, and its logged-out flag is only set by an explicit logout; so a device that goes offline keeps opening the cached shell, and therefore the local library, until the app is used online again (which purges the cache if the cookie has expired) or the device is erased. On a shared device, Log out and erase this device is the control that closes this window.
- Logout does not erase this data. The access gate is one shared site password, not a per-user account, so on a shared device the next person to log in sees the previous learner's library. Settings → Log out and erase this device ends the session and then deletes every IndexedDB table (including the encrypted Groq key), the key vault, the app's
localStorageentries and its caches (src/lib/db/erase.ts). It keeps only thedeutschflow-sessionlogged-out flag, which the logout itself depends on. - Logging out clears authentication markers and invalidates any protected cache entries.
| Variable | Required | Default / Fallback | Consequence if Missing or Misconfigured |
|---|---|---|---|
SITE_PASSWORD |
YES | None | Critical: The site access gate is rendered inaccessible or fails to unlock. Authentication cannot succeed. Under 16 characters, the server logs a warning at startup (src/instrumentation.ts): the throttle still allows about 2,880 guesses a day per caller. |
SESSION_SECRET |
YES | None — there is no fallback | Critical: Session tokens cannot be signed, so /api/login returns 503 and the gate redirects every request to /login. Only SESSION_SECRET is read (an AUTH_SECRET variable is not), and a value under 32 bytes is treated as unset. |
UPSTASH_REDIS_REST_URL |
RECOMMENDED | undefined (Local in-memory Map) |
Medium (Security/Availability): Without Upstash Redis, rate-limiting runs in local in-memory fallback mode. Each serverless instance tracks attempts independently, allowing distributed attackers to rotate across instances. No shared state exists between containers. |
UPSTASH_REDIS_REST_TOKEN |
RECOMMENDED | undefined |
Same as above. If URL is present without valid token, requests to Redis return HTTP 401/403. Under fail-secure-fallback mode, it falls back to in-memory limiting; under fail-closed mode, it blocks all logins with HTTP 503. |
TRUSTED_PROXY_HEADER |
NO (self-hosted only) | undefined (one shared throttle bucket) |
Ignored on Vercel. Self-hosted, it names the header your reverse proxy sets with the client address, giving each client its own throttle bucket. Set it only behind a proxy that overwrites or appends that header, or clients can choose their own identity. |
THROTTLE_FAILURE_POLICY |
NO | "fail-secure-fallback" |
If set to "fail-closed" and Redis is unreachable, all login attempts return HTTP 503 Service Unavailable. If set to "fail-secure-fallback", logins remain available protected by local process-level rate limiting. |
SESSION_MAX_AGE_SECONDS |
NO | 2592000 (30 days) |
Defaults to 30 days. Clamped to [3600, 7776000] (1 hour to 90 days) to prevent unsafe infinite or microscopic session windows. |
SESSION_EPOCH |
NO | "1" |
Defaults to version "1". Incrementing this value invalidates all active sessions globally without changing passwords. |
GROQ_API_KEY |
NO | None — not read by the app | There is no server-side AI key: every AI request is made from the browser with the user's own key from the in-app Key Vault, which the client encrypts at rest. Setting this variable has no effect. |
NEXT_PUBLIC_GROQ_MODEL |
NO | openai/gpt-oss-120b |
Inlined into the client bundle at build time, so a change needs a rebuild. A value outside SUPPORTED_GROQ_MODELS is ignored and the default is used. |
- Master
SITE_PASSWORDset with high entropy (>= 16 characters). - Random
SESSION_SECRETgenerated with at least 32 bytes of secure random entropy (openssl rand -base64 32). - Upstash Redis provisioned with
UPSTASH_REDIS_REST_URLandUPSTASH_REDIS_REST_TOKENto enable atomic distributed rate limiting across serverless edge containers. - Ingress proxy boundaries verified:
process.env.VERCEL === "1"ensuresx-forwarded-foris injected by the trusted edge. If deployed behind custom reverse proxies, ensure the ingress proxy overwrites or sanitizes untrusted incomingX-Forwarded-Forheaders. - Automated regression test suites passing across Web (
npm test) and Android (./gradlew.bat testDebugUnitTest).