Skip to content

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

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

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status
    In progress

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions