Skip to content

OAuth CSRF — State Nonce Never Validated Server-Side #223

@Ridanshi

Description

@Ridanshi

Summary

The GitHub OAuth connection flow generates a state nonce but never validates it server-side during the callback flow.

The callback handler currently trusts the decoded userId from the client-controlled state payload without verifying whether the nonce was ever issued by the server.

This allows forged OAuth state payloads and enables forced account-linking attacks.


Affected File

apps/backend/src/routes/connect.ts


Root Cause

The /api/connect/github route generates:

{
  userId,
  nonce
}

and base64-encodes it into the OAuth state parameter.

However, during callback handling:

/api/connect/github/callback

the backend only decodes and structurally validates the payload:

if (
  typeof decoded.userId !== 'string' ||
  typeof decoded.nonce !== 'string'
) {
  return null;
}

The nonce itself is never:

  • stored server-side,
  • cross-checked,
  • expired,
  • or invalidated.

A code comment already acknowledges the missing validation:

// In a real app, store this in Redis to cross-check in callback

Security Impact

This creates a CSRF-style OAuth account-linking vulnerability.

An attacker can:

  1. obtain a victim's user ID,
  2. forge a valid-looking OAuth state,
  3. complete GitHub OAuth using their own GitHub account,
  4. inject the forged state into the callback request.

The backend will then:

  • trust the forged userId,
  • exchange the OAuth code,
  • and store the attacker's GitHub token under the victim's account.

This can result in:

  • forced GitHub account linking,
  • unauthorized follow actions,
  • incorrect OAuth identity association,
  • and compromised social integration integrity.

Example Attack Flow

const forgedState = Buffer.from(
  JSON.stringify({
    userId: "<victim-user-id>",
    nonce: "fake"
  })
).toString("base64");

The attacker then:

  • authenticates normally with GitHub,
  • intercepts the callback request,
  • replaces the state parameter with the forged value.

The backend accepts the forged callback and associates the attacker's GitHub token with the victim account.


Proposed Fix

Implement proper server-side nonce validation.

Suggested approach:

During OAuth initiation

  • generate a cryptographically secure nonce,
  • store it in Redis:
oauth_nonce:{nonce} -> userId

with a short expiration (e.g. 5–10 minutes).

During callback

  • look up the nonce,
  • verify it matches the expected user,
  • reject unknown/expired nonces,
  • delete the nonce immediately after successful validation.

Additional recommendations:

  • reject replayed nonces,
  • fail closed on Redis lookup failure,
  • add structured logging for invalid state attempts.

Acceptance Criteria

  • OAuth nonce is persisted server-side before redirect.
  • Callback validates nonce existence before trusting userId.
  • Nonce is deleted after successful use.
  • Expired or forged states are rejected.
  • Replay attacks are prevented.
  • Existing OAuth flows continue working normally.

Security Severity

High

This is a realistic OAuth state-validation vulnerability affecting account-linking integrity.

Metadata

Metadata

Assignees

Labels

No labels
No 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