Skip to content

Add state nonce to OAuth flow for login CSRF protection#32

Merged
alexkroman merged 2 commits into
mainfrom
claude/codebase-security-review-20Umd
Jun 7, 2026
Merged

Add state nonce to OAuth flow for login CSRF protection#32
alexkroman merged 2 commits into
mainfrom
claude/codebase-security-review-20Umd

Conversation

@alexkroman

Copy link
Copy Markdown
Collaborator

Summary

Implements login CSRF protection in the OAuth discovery flow by introducing a single-use state nonce that binds the browser hand-off to the loopback callback capture. This prevents a malicious page from completing someone else's login by replaying a discovery token while aai login is waiting.

Key Changes

  • State nonce generation and validation: flow.run_login_flow() now generates a cryptographically random 256-bit nonce (secrets.token_urlsafe(32)) and passes it through the entire OAuth flow:

    • Sent to Stytch via discovery.build_start_url(state)
    • Returned by Stytch in the callback query string
    • Validated by loopback.capture_callback(expected_state) using constant-time comparison
  • Loopback callback validation: Enhanced loopback.capture_callback() to:

    • Accept an expected_state parameter
    • Reject callbacks with missing or mismatched state (400 response)
    • Keep the server waiting for a valid callback (forged attempts don't end the capture)
    • Use secrets.compare_digest() for timing-safe comparison
  • Discovery URL construction: discovery.build_start_url() now embeds the state nonce as a query parameter on the redirect URL itself (?state=...), which Stytch preserves through the OAuth flow

  • Success page security: Added history.replaceState() to the success HTML to scrub the callback URL (containing the token and state) from browser history, per RFC 8252 (OAuth for native apps)

  • File permissions hardening: .env files created by scaffold.scaffold() are now written with 0600 permissions (owner read/write only) to prevent API keys from being world/group-readable on shared hosts. Existing .env files are also tightened when aai init --force overwrites them.

Implementation Details

  • State validation happens before token extraction, so a forged callback never reaches the token capture logic
  • The nonce is carried as a query parameter on the redirect URL (not as a separate OAuth state parameter) because Stytch validates the redirect URL by exact path match and preserves query parameters through the flow
  • All existing tests updated to pass the required state parameter; new tests verify state mismatch rejection, missing state rejection, and that the success page scrubs sensitive data from history

https://claude.ai/code/session_0115sdYguJwA8exNeKU52CEb

claude added 2 commits June 7, 2026 22:07
Security review follow-ups:

- aai login OAuth: bind the browser hand-off to the loopback capture with a
  single-use random `state` nonce. build_start_url carries it as a query param
  on the (still path-exact) discovery_redirect_url; capture_callback only accepts
  a callback whose `state` matches (constant-time), rejecting forged callbacks
  with a 400 and continuing to wait. Stops a malicious local page from completing
  someone else's login (login CSRF / account confusion) by replaying an
  attacker-minted discovery token while `aai login` is waiting.

- aai init: write the scaffolded .env (which holds the real API key) with 0600
  instead of the umask default, so the key isn't world/group-readable on a shared
  host. Created 0600 from the start and re-tightened on --force overwrite.

Tests cover the nonce round-trip, state mismatch/missing rejection, and the
.env file mode (fresh + overwrite).
The loopback callback URL carries the single-use discovery_oauth token and the
state nonce in its query string, so it would otherwise linger in the browser's
history and address bar. The success page now drops the query from the current
history entry via history.replaceState on load — no extra request to race the
server shutdown (unlike a redirect). The token is already spent server-side by
then, but keeping credentials out of browser history is OAuth-for-native-apps
(RFC 8252) hygiene. The page reflects no query data, so there is nothing to inject.
@alexkroman alexkroman merged commit fd6e653 into main Jun 7, 2026
10 checks passed
@alexkroman alexkroman deleted the claude/codebase-security-review-20Umd branch June 7, 2026 22:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants