For product/data context, see docs/PRODUCT_DIRECTION.md and docs/DATA_MODEL.md. Security rules apply to Server Mode; Local Mode stays on-device unless a future explicit migration/import flow is designed.
Last reviewed 2026-10-21. Status means: In place — enforced in code and covered by a guardrail or test; Partial — enforced with a documented limitation; To be added — not implemented yet.
Evidence for every row lives in Security Posture and the control-by-control inventory with file:line citations in SECURITY_CONTROLS.md. The "To be added" table is the reference checklist's items this project does not implement yet, plus the verified gaps recorded in that inventory.
| Measure | Status |
|---|---|
Authentication — JWE session in an HttpOnly cookie (__Secure- prefix in production); Bearer accepted for API clients |
In place |
Authorization — single withAuth gate, server-derived identity, admin role plus approval-status checks |
In place |
Account approval — new accounts start PENDING until an admin approves them |
In place |
| Password handling — PBKDF2-HMAC-SHA256 at 310,000 iterations, optional RSA/AES credential envelope | In place |
Session lifecycle — absolute lifetime cap, sliding renewal, tokenVersion revocation on password change or sign-out |
In place |
| Login abuse controls — per-IP, per-device and per-username throttling with exponential lockout, state in Postgres | In place |
Rate limiting — global /api budget plus route-specific buckets returning 429 with Retry-After |
In place |
Input validation — shared KMP validators and typed @Serializable DTOs on the request path |
In place |
| SQL injection protection — Exposed parameterized queries, ownership predicates compiled into every query | In place |
| XSS protection — React output escaping plus an enforcing Content-Security-Policy | In place |
CSRF protection — SameSite=Lax session cookie and a deny-by-default CORS allowlist |
Partial — no CSRF token validation |
Security headers — CSP, Permissions-Policy, nosniff, X-Frame-Options: DENY, referrer policy, HSTS in production |
In place |
| CORS — deny by default, explicit origin allowlist, no wildcard with credentials | In place |
| Multi-tenant isolation — row ownership scoping, share ACL (OWNER/EDITOR/VIEWER), API-key scopes | In place |
| Realtime authorization — authenticated WebSocket handshake, fanout derived from share membership | In place |
Secrets management — required-at-boot secrets, _FILE indirection for mounted secrets, no dev bypass |
In place |
| Data at rest — AES-256-GCM field encryption with key rotation; mobile caches in SQLCipher and Keychain-protected stores | In place |
| Security event log — structured codes with HMAC-hashed identifiers, lockout/IP-concentration/anomaly alerts | In place |
| Dependency vulnerabilities — Dependabot alerts, CodeQL analysis, guardrail tests, SHA-pinned Actions | In place |
| Shell injection protection — no request path executes a shell or spawns a process | In place |
| Supply-chain integrity — release APK signature verified against a pinned certificate digest | In place |
Backups — verified pg_dump, optional encryption, documented restore procedure |
In place |
| Mobile at-rest protection — Android SQLCipher cache with a Keystore-wrapped passphrase; iOS Keychain plus Data Protection | In place |
Screenshot/recents protection — Android FLAG_SECURE on task-text activities and an optional biometric app lock |
In place |
| Certificate pinning — user-configured public-key pins for self-hosted servers; iOS fails closed and asks before accepting a new key | In place |
| Local Mode isolation — local-only data is never uploaded; the web vault is PBKDF2 + AES-GCM and relocks on reload | In place |
| Telemetry privacy — consent-gated Sentry on every client, allow-listed fields, scrubbed identifiers | In place |
HTTPS enforcement — host bound to 127.0.0.1 by default; TLS provided by the operator's tunnel or reverse proxy |
In place |
| Measure | Status |
|---|---|
CSRF token validation (defense in depth beyond SameSite) |
To be added |
| Multi-factor authentication | To be added |
| Session/device inventory and selective revocation | To be added |
| Request body-size limits | To be added |
| Origin check on the WebSocket handshake | To be added |
| Admin action audit trail | To be added |
| CAPTCHA on registration and sign-in | To be added |
| Operator IP-ban / fail2ban integration | To be added |
| Read-only root filesystem, tmpfs, and container memory limits | To be added |
| Image vulnerability scanning, SBOM, provenance and image signing | To be added |
| Digest-pinned deploys and automatic rollback | To be added |
| TLS between backend and Postgres, and network egress restriction | To be added |
| Log retention/rotation, off-host copy and tamper evidence | To be added |
| WAF or edge rate-limit rules; Cloudflare Access or mTLS (optional hardening) | To be added |
| HTTP→HTTPS redirect and forwarded-proto awareness in the app | To be added |
| COOP/COEP/CORP isolation headers and a CSP reporting endpoint | To be added |
| Reverse-proxy hardening guidance — version banner suppression and explicit dotfile deny | To be added |
| Password-strength check beyond the three character-class rules | To be added |
| Server version banner suppression | To be added |
| Strict JSON decoding and length bounds on all free-text fields | To be added |
If you discover a security vulnerability, please report it privately. Do not open a public issue.
Contact: Report a vulnerability privately through GitHub's private vulnerability reporting (the repository's "Security" tab), or email the repository owner directly.
Include:
- Description of the vulnerability.
- Steps to reproduce.
- Potential impact.
- Suggested fix (if any).
We aim to acknowledge reports within 48 hours and provide a fix or mitigation plan within 7 days.
- Passwords are hashed with PBKDF2 (configurable iterations via
AUTH_PBKDF2_ITERATIONS, default 310,000). - Passwords are never logged, returned in API responses, or stored in plaintext.
- Client-side credential envelopes optionally encrypt credentials in transit using an RSA key pair (
AUTH_CREDENTIALS_PRIVATE_KEY).
- Sessions use encrypted JWT (JWE) via Nimbus JOSE JWT + BouncyCastle key derivation.
- Session lifetime is configurable (
AUTH_SESSION_MAX_AGE_SEC, default 24h). - Sessions are delivered in HTTP-only authjs cookies:
__Secure-authjs.session-tokenin production andauthjs.session-tokenelsewhere. Bearer tokens are also accepted for API clients. - Sessions persist across browser/app restarts (no session-only cookies).
- Server-enforced token versioning (
tokenVersionon User model) revokes all sessions on password change or sign-out. - The Ktor pipeline intercept validates tokens on every request by checking
tokenVersion, expiry, role, and approval status against the database before route handlers read the session. - Mobile sessions use encrypted cookie storage with session restoration on app launch.
- New accounts default to
PENDINGapproval status. - Only
APPROVEDusers can access protected API routes and app pages. - Admin approval is required before new users gain access.
- Auth endpoints are rate-limited per IP and per email (configurable windows and thresholds).
- App-layer request throttling protects
/api/**,/health,/api/mobile/probe,POST /api/todo/summary,POST /api/user/change-password, and/ws. - Exponential lockout activates after repeated credential failures (
AUTH_LOCKOUT_*settings). - Rate limit and lockout state is stored in the database (
AuthThrottlemodel).
- Admin routes require both
APPROVEDstatus andADMINrole. - Admin checks are centralized in the shared auth context helper used by routes and services.
All API requests pass through Ktor plugins that enforce:
- Authentication:
Security.ktreads a JWE token from theAuthorizationheader or session cookies and attaches validated claims to the call. - Approval gate:
call.withAuth { }rejects unapproved users from private REST routes, and/wsrejects pending users during the handshake. - Token refresh: Claims are verified against the database on every request (role, approval,
tokenVersion). - Request throttling:
RateLimiting.ktapplies route-specific 429 responses before protected handlers run.
Every response includes (via SecurityHeaders.kt):
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload (production only) |
- The Docker host port is bound to
127.0.0.1(localhost only) by default, preventing direct external HTTP access. Traffic reaches the backend through a tunnel or VPN — see Remote Access for all supported methods (Cloudflare Tunnel, Tailscale, WireGuard, ZeroTier, SSH tunnels, ngrok, frp). - Direct external access is opt-in: set
TDAY_HOST_BIND=0.0.0.0in the project-root.envfile. This is discouraged for production. - Production deployments should use a method that provides TLS termination (Cloudflare Tunnel, ngrok, Caddy reverse proxy, or Tailscale Funnel).
- VPN-based methods (Tailscale, WireGuard, ZeroTier) encrypt traffic at the tunnel layer; browser-trusted HTTPS is optional but recommended for production cookie security.
- HSTS headers are applied when
TDAY_ENV=production(NODE_ENV=productionis still accepted as a compatibility fallback).
- Sensitive database fields can be encrypted/decrypted transparently at the service layer.
- Uses AES-256-GCM with configurable key ID and AAD (
DATA_ENCRYPTION_*variables). - Key rotation is supported: keep previous keys in
DATA_ENCRYPTION_KEYSduring rollover.
- Android server URL and credentials are stored in
EncryptedSharedPreferences. - iOS server config, credentials, cookies, and mode state are stored through Keychain-backed
SecureStore. - Session cookies persist in encrypted platform stores.
- Android task data is cached in Room; iOS task data is cached in SwiftData. Both caches are local app data and are cleared through logout/session invalidation flows where appropriate.
- Local Mode does not send task/list/floater data to a server and should not silently migrate local-only data into Server Mode.
- Optional public-key fingerprint pinning for self-hosted servers.
- All local user data (credentials, cache, cookies) is wiped on logout or session invalidation.
- Custom client headers (
X-Tday-Client,X-Tday-App-Version,X-Tday-Device-Id) for audit trails. - OkHttp logging redacts
CookieandSet-Cookieheaders.
- Production secrets should come from a secrets manager or mounted files.
- The Ktor backend (
AppConfig.kt) supports_FILEsuffix for all sensitive variables:AUTH_SECRET,DATABASE_URL,DATA_ENCRYPTION_KEY,DATA_ENCRYPTION_KEYS,DATA_ENCRYPTION_AAD. - Never commit real secrets.
.env.examplecontains placeholder values only;.env.dockeris a local ignored runtime file created from that template. - Rotate secrets on a fixed schedule (recommended: 60-90 days).
- Keep dependencies updated regularly.
- Review
npm auditoutput before merging dependency updates. - Pin major versions in
tday-web/package.jsonto avoid surprise breaking changes. - Android and backend dependencies are version-locked in
build.gradle.kts.
Structured security event codes are emitted to the eventLog database table:
| Code | Meaning |
|---|---|
auth_limit_ip |
IP-based rate limit triggered |
auth_limit_email |
Email-based rate limit triggered |
auth_lockout |
Account locked out after repeated failures |
auth_credential_envelope_invalid |
Encrypted credential envelope could not be decrypted |
auth_alert_ip_concentration |
Suspicious IP concentration detected |
auth_alert_lockout_burst |
Burst of lockouts in a short window |
auth_signal_anomaly |
Behavioral anomaly detected |
auth_session_absolute_expired |
Session exceeded the configured absolute lifetime and was cleared |
auth_session_renewed |
Active session was renewed because it crossed the renewal threshold |
auth_session_token_version_mismatch |
Session was cleared because the user's token version changed |
auth_session_user_missing |
Session referenced a user row that no longer exists |
request_rate_limit_triggered |
App-layer request throttle triggered |
The request-rate event includes a reason detail such as api_rate_limit, infra_rate_limit, summary_rate_limit, change_password_rate_limit, or websocket_rate_limit.
Alert when auth_lockout grows rapidly, auth_limit_ip spikes from a narrow IP range, session-clearing events cluster unexpectedly, or request_rate_limit_triggered starts clustering on one path.