ADR-003
Security hygiene — hashed tokens, verified email, server-owned sessions, guarded public writes
Context
The auth layer was ported from wedance-v4 for continuity (FirebaseScrypt hashes verify unchanged — docs/data-migration-plan.md §0). The port kept v4's weak spots and added a few. The seven areas below are tracked in the current-state review (findings #1, #2, #6, #8, #9, #23, #26); exploit-level detail is withheld until each fix ships.
- Secrets stored in plaintext. Session and magic-link tokens are stored unhashed; a database leak yields live credentials.
- Magic-link requests can modify existing accounts before ownership is proven.
- Email is trusted but never verified. Registration and ticket-claiming treat email as proof of identity with no confirmation step.
- Login and reset share one token with no purpose field.
- Salts use insufficient entropy (not a CSPRNG).
- Sessions are client-owned. Cookie flags, server-side revocation, and credential-change invalidation are missing.
- Public write endpoints lack validation and rate limiting. Upload, login, feedback, and booking mutations accept unbounded input from unauthenticated callers.
- PII in public responses. Several API endpoints return data beyond what the caller should see, including contact details and privacy-gated fields.
Options
- Patch each hole in place (salt fix, a size check, a
whereclause). Needed anyway, but leaves the model (plaintext secrets, client-owned sessions, unverified email) intact. - Adopt a hosted auth provider (the dormant
dancers.neon_auth_id,schema.ts:15, points at Neon Auth). Solves sessions and verification, but breaks password continuity for 2,339 migrated v4 hashes unless every user resets, and it is a product decision (social login, map:meetup-2.4). - Harden the existing auth model: hashed single-purpose tokens, verified email as a gate, server-owned
HttpOnlysessions with revocation, CSPRNG salts, guarded public writes, explicit output shapes.
Decision
Option 3 now; option 2 stays possible later because the session and token tables below are provider-agnostic.
auth_tokensreplacesdancers.magic_token*:id,dancer_id(nullable for sign-up),email,purpose(login·reset_password·verify_email),token_hash bytea(SHA-256 of 32 random bytes; the raw token only ever appears in the email link),payload jsonb(sign-up fields for a not-yet-existing account),expires_at,used_at. Consuming a token is a singleUPDATE … SET used_at = now() WHERE token_hash = $1 AND used_at IS NULL AND expires_at > now() RETURNING …. Requesting a link never writes todancers; profile fields from the request are applied only when the token is consumed.dancers.email_verified_at timestamptz. Set when aloginorverify_emailtoken is consumed; backfilled for v4 users whose v4 record was verified and for dancers who already signed in via magic link. Password registration sends averify_emailtoken. Verified email is required for webhook auto-linking (otherwise the row stays an unclaimed stub) and for claiming ticket rows.sessions.token_hash byteareplacessessions.token; addlast_seen_at,revoked_at,user_agent. The server sets the session cookie (HttpOnly,Secure,SameSite=Lax) from the auth mutations;auth.signOutrevokes the row; password reset/change revokes all other sessions. TheAuthorization: Bearerheader stays accepted for one release while clients move to the cookie. Existing plaintext sessions are hashed in place (expand: addtoken_hash, backfillsha256(token), read by hash, then droptoken), so nobody is signed out.- Salts from
crypto.randomBytes(16). FirebaseScrypt parameters stay identical so v4 hashes keep verifying. - Upload becomes protected, capped at 5 MB, accepts only
image/jpeg,image/png,image/webp(sniffed, not trusted from the header) and uploads into a per-dancer folder. - Rate limits via one tRPC middleware backed by a Postgres table
rate_limits(key text, window_start timestamptz, hits int, PRIMARY KEY (key, window_start))and a singleINSERT … ON CONFLICT DO UPDATE SET hits = hits + 1 RETURNING hits. No new infrastructure. Keys combine procedure, client IP and, for auth, the email. - Public outputs are explicit and privacy-filtered. No
select()of whole rows in public procedures (ADR-011). One shared predicate decides whether a dancer may appear in any public list (profile_publicand the per-event visibility of ADR-008);getClaimStatusis removed (the claim page asks the signed-in user). - Previews never see production (ADR-001).
Rendering diagram…
Diagram source
erDiagram
dancers ||--o{ sessions : "has (CASCADE)"
dancers |o--o{ auth_tokens : "requests (CASCADE)"
sessions {
uuid id PK
uuid dancer_id FK
bytea token_hash UK
timestamptz expires_at
timestamptz revoked_at
}
auth_tokens {
uuid id PK
text purpose "login, reset_password, verify_email"
bytea token_hash UK
jsonb payload
timestamptz expires_at
timestamptz used_at
}Consequences
- A leaked database no longer yields usable sessions or links.
- Dancers who registered with a password but never verified their email must verify before their purchased tickets attach to the account; the UI explains it.
- One more round trip on magic-link sign-up (account created on consume, not on request).
- Some public pages lose data they showed before (gig contact email moves behind "Apply", booking messages stay with the venue).
Stories unblocked
RAZ-275 and RAZ-288 (safe photo upload), RAZ-241 (reset with a purpose-bound token), map:meetup-2.1-sign-up, RAZ-83 (verified ticket claims), RAZ-155 (sign-out and erasure revoke sessions).