Skip to content

Sign-in shows raw {"error":"CSRF validation failed"} when epds_csrf cookie is absent at POST #179

Description

@aspiers

Symptom

A user visits https://auth.certified.one/account/login, enters their email, and on submit lands on /account/send-otp showing a raw JSON error page:

{"error": "CSRF validation failed"}

No styled page, no recovery path — a dead end.

Root cause

The auth-service is behaving correctly server-side. csrfProtection (packages/auth-service/src/middleware/csrf.ts) returns 403 {"error":"CSRF validation failed"} on a POST when either:

  • the epds_csrf cookie is not sent by the browser, or
  • the csrf body field is absent.

Reproduced live on prod (auth.certified.one, version 0.6.3):

# GET sets the cookie correctly:
set-cookie: epds_csrf=…; Max-Age=1800; Path=/; HttpOnly; Secure; SameSite=Lax

# POST WITHOUT the cookie → 403:
$ curl -d 'csrf=<token>&email=test@example.com' .../account/send-otp
403  {"error":"CSRF validation failed"}

# POST WITH the cookie → 200 (renders OTP form):
200  <!DOCTYPE html> …

So this is a client-side cookie-delivery / expiry condition, not a backend defect. Most likely triggers:

  • Cookie expiredepds_csrf has Max-Age=1800 (30 min). Load the login page, wait >30 min, then submit → cookie gone, but the page still shows a form with a now-orphaned token → 403.
  • Browser privacy settings / extensions blocking the Secure; HttpOnly; SameSite=Lax cookie.
  • A stale or duplicate epds_csrf cookie from an earlier scope (note the historic magic_csrfepds_csrf rename).

Two problems to fix

  1. UX: a CSRF failure should not surface raw JSON. It should render a styled "Your session timed out — reload and try again" page (consistent with the existing PAR-expiry / session-expired error pages), ideally auto-reloading /account/login to mint a fresh token. Related to the existing "session expired dead-end" work (Re-submitting email after PAR expiry produces a "Session expired" dead-end #150, Browser back after expiry sends OTP via the default email template (client branding lost) #152).
  2. Robustness: consider whether the 30-minute epds_csrf lifetime is too short for the email-entry step, and/or refresh the token on render so a stale form can't outlive its cookie.

Test gap

This class of failure is not covered:

  • packages/auth-service/src/__tests__/csrf.test.ts mocks req.cookies directly — never exercises a real GET→POST cookie roundtrip, so it can't catch cookie-not-delivered.
  • The CSRF e2e scenarios in features/security.feature ("Forms include CSRF protection", "POST without CSRF token is rejected") are tagged @pending and have no step definitions — e2e/cucumber.mjs excludes not @pending, so they never run.

Implementing those two scenarios (real cookie roundtrip + missing-cookie → 403 + friendly page) would close the gap. (PR #100 already touches security.feature for CSP scenarios — may be the right home.)

Affected code

  • packages/auth-service/src/middleware/csrf.ts
  • packages/auth-service/src/routes/account-login.ts
  • features/security.feature (@pending CSRF scenarios)

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions