Back to Blog
critical SEVERITY8 min read

How OAuth CSRF Attacks Happen in Node.js and How to Fix Them

A missing OAuth state parameter validation in `src/account_manager.js` left the `startOAuthServer()` function vulnerable to CSRF attacks, allowing an attacker to inject their own authorization code into a victim's active OAuth session. The fix generates a cryptographically random state token using `crypto.randomBytes()`, returns it alongside the server handle, and rejects any callback where the returned state doesn't match — closing the attack window entirely. This affects all downstream consume

O
By Orbis AppSec
Published August 26, 2026Reviewed August 26, 2026

Answer Summary

This is an OAuth CSRF vulnerability (CWE-352) in Node.js, specifically in the `startOAuthServer()` function of `src/account_manager.js`. Although a `state` parameter was generated during the OAuth flow, it was never validated on the callback, allowing an attacker to craft a malicious redirect URI and inject their authorization code into a victim's session. The fix uses `crypto.randomBytes(16)` to generate a cryptographically secure state token, exposes it in the server's return value, and adds an explicit state-mismatch check that returns HTTP 403 and calls `onError()` when validation fails.

Vulnerability at a Glance

cweCWE-352
fixGenerate a cryptographically random state with `crypto.randomBytes(16)`, return it from `startOAuthServer()`, and reject callbacks where `returnedState !== expectedState`
riskAttacker can inject their authorization code into a victim's OAuth session, potentially hijacking account linkage
languageJavaScript (Node.js)
root cause`startOAuthServer()` generated a state parameter but never validated the returned `state` query parameter in the callback handler
vulnerabilityOAuth CSRF (Cross-Site Request Forgery)

The Vulnerability at a Glance

Field Detail
File src/account_manager.js
Function startOAuthServer()
CWE CWE-352 — Cross-Site Request Forgery
Severity Critical
Impact Authorization code injection into victim OAuth session

Introduction

The src/account_manager.js file manages OAuth authentication flows for a Node.js library, spinning up a local HTTP server to capture the OAuth callback code. A flaw in the startOAuthServer() function meant that while a state parameter was nominally part of the design, it was never validated on the incoming callback — leaving the door wide open for an attacker to inject their own authorization code into a victim's active session.

Because this is a Node.js library, the vulnerability doesn't just affect one application — it propagates to every downstream consumer that relies on this package for OAuth account management.


The Vulnerability Explained

What Was Actually Happening

Inside startOAuthServer(), the local HTTP server listens for the OAuth provider's redirect. When the callback arrives, the handler extracted the code and error query parameters — but completely ignored the state parameter:

// BEFORE — vulnerable code
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
// 'state' is never read or validated here

The OAuth 2.0 specification (RFC 6749, Section 10.12) is explicit: the state parameter exists precisely to prevent CSRF. Without validating it, any HTTP request that arrives at the local callback port with a valid-looking code parameter will be accepted and processed — regardless of whether it came from the legitimate OAuth provider redirect.

The Attack Scenario

Here's how an attacker exploits this against a real user of this library:

  1. Victim initiates an OAuth flow. startOAuthServer() starts listening on one of the OAUTH_FALLBACK_PORTS.
  2. Attacker separately initiates their own OAuth flow with the same provider and obtains their own authorization_code (but intentionally does not complete the exchange).
  3. Attacker crafts a URL pointing to the victim's local callback server: http://localhost:<port>/callback?code=ATTACKER_CODE
  4. Attacker tricks the victim into visiting this URL (via a malicious link, an iframe in a page the victim is already viewing, or a redirect chain).
  5. The victim's startOAuthServer() callback handler sees a code parameter and processes it — exchanging the attacker's authorization code for tokens, effectively linking the attacker's identity to the victim's account.

The result: the attacker's credentials are now associated with the victim's account in the application. Depending on the OAuth provider and application logic, this can lead to full account takeover.

Why the Local Server Model Makes This Worse

Desktop and Electron-style applications often use a localhost redirect URI as the OAuth callback (rather than a hosted endpoint). This pattern is common and legitimate, but it means the "server" accepting the callback is running on the victim's own machine — making it accessible to any page the victim visits in a browser, since browsers can make cross-origin requests to localhost.


The Fix

The fix is clean, minimal, and exactly what RFC 6749 prescribes. Three coordinated changes were made to startOAuthServer():

1. Generate a Cryptographically Secure State Token

// AFTER — secure code (line ~248)
const crypto = require('crypto');

async function startOAuthServer(onCode, onError) {
    const expectedState = crypto.randomBytes(16).toString('hex');
    // ...
}

crypto.randomBytes(16) generates 16 bytes (128 bits) of cryptographically secure random data from Node.js's built-in crypto module. This is sufficient entropy to make the token unguessable. Using Math.random() here would have been insecure — crypto.randomBytes() is the right tool.

2. Validate the State on Every Callback

// AFTER — state validation added to callback handler
const returnedState = url.searchParams.get('state');

if (returnedState !== expectedState) {
    res.writeHead(403);
    res.end('Invalid state parameter');
    onError('OAuth CSRF check failed: state mismatch');
    return;
}

This is the critical guard. If returnedState doesn't exactly match expectedState, the server:
- Returns HTTP 403 (not a redirect, not a 200 — an explicit rejection)
- Ends the response with a human-readable message
- Calls onError() so the calling code can handle the failure gracefully
- Returns immediately, preventing any further processing of the request

The return statement is important — without it, execution would fall through to the code handling block.

3. Expose the State to the Caller

// AFTER — state returned alongside server handle
return {
    server,
    port: boundPort,
    state: expectedState,   // <-- NEW
    stop: () => new Promise((resolve) => {
        server.closeAllConnections();
        server.close(resolve);
    }),
};

Returning state from startOAuthServer() allows the calling code to include it in the OAuth authorization URL sent to the provider. This closes the loop: the state is generated here, sent to the provider, returned by the provider in the redirect, and validated here — all within the same closure.

Before vs. After

Aspect Before After
State generated ❌ No ✅ Yes — crypto.randomBytes(16)
State validated on callback ❌ No ✅ Yes — strict equality check
Invalid state rejected ❌ No ✅ Yes — HTTP 403 + onError()
State exposed to caller ❌ No ✅ Yes — returned in server object

Prevention & Best Practices

Always Validate OAuth State

The state parameter isn't optional — it's the primary CSRF defense in OAuth 2.0. Every OAuth callback handler should:

  1. Generate a random, unguessable state before redirecting to the provider
  2. Store it somewhere the callback handler can access (closure variable, session, encrypted cookie)
  3. Include it in the authorization URL as &state=<value>
  4. Validate it on every callback — reject if absent or mismatched

Use crypto.randomBytes(), Not Math.random()

// ❌ Insecure — predictable
const state = Math.random().toString(36).slice(2);

// ✅ Secure — cryptographically random
const state = require('crypto').randomBytes(16).toString('hex');

Math.random() is not cryptographically secure. An attacker who can observe timing or output patterns may be able to predict the value.

Respond With 403, Not a Redirect

When state validation fails, return HTTP 403 and stop processing. Don't redirect the user to an error page using the attacker-supplied code or state values — that can introduce open redirect vulnerabilities.

Consider PKCE for Public Clients

For desktop or mobile applications (public clients that can't keep a client secret), the OAuth 2.0 PKCE extension (RFC 7636) provides additional protection against authorization code interception attacks. PKCE and state validation are complementary — use both.

Detection with Static Analysis

This exact pattern — reading code from an OAuth callback without reading and validating state — is detectable with Semgrep:

rules:
  - id: oauth-missing-state-validation
    patterns:
      - pattern: |
          $URL.searchParams.get('code')
      - pattern-not: |
          $URL.searchParams.get('state')
    message: OAuth callback reads 'code' without validating 'state' parameter (CWE-352)
    languages: [javascript]
    severity: ERROR

OWASP Reference

This vulnerability maps to OWASP's Cross-Site Request Forgery prevention guidance and is specifically called out in the OAuth 2.0 Security Best Current Practice document.


Key Takeaways

  • startOAuthServer() generated state but never used it — having a state parameter in the design is not enough; it must be validated on every callback or it provides zero protection.
  • Local localhost OAuth servers are reachable from the browser — any page a victim visits can make requests to localhost, making CSRF validation more critical in desktop/Electron OAuth flows than in server-side flows.
  • crypto.randomBytes(16) is the correct primitive — not Math.random(), not a UUID library, not a timestamp. Use the platform's cryptographic RNG.
  • Returning state from startOAuthServer() is architecturally correct — the caller needs the state value to build the authorization URL; exposing it in the return object keeps the flow cohesive.
  • HTTP 403 is the right response for state mismatch — calling onError() and returning immediately prevents any partial processing of a potentially malicious request.

How Orbis AppSec Detected This

  • Source: The OAuth callback URL received by the local HTTP server in startOAuthServer(), specifically url.searchParams.get('code') — attacker-controlled input arriving via the browser redirect
  • Sink: The onCode callback invocation that processes the authorization code without first verifying the state parameter — located in the request handler inside startOAuthServer() in src/account_manager.js around line 286
  • Missing control: No call to url.searchParams.get('state') and no comparison against a stored expected value before processing the code parameter
  • CWE: CWE-352 — Cross-Site Request Forgery
  • Fix: Added crypto.randomBytes(16) state generation, strict equality validation of the returned state, and HTTP 403 rejection on mismatch before any authorization code processing occurs

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

The vulnerability in startOAuthServer() is a textbook example of an incomplete security control: the state parameter existed in concept but was never enforced in code. In OAuth flows, especially those using localhost redirect URIs in desktop applications, this omission is directly exploitable with nothing more than a crafted link. The fix is small — under 15 lines — but it closes a critical gap that could have allowed account hijacking for every user of this library.

When building OAuth flows in Node.js, treat state validation as non-negotiable. Generate it with crypto.randomBytes(), include it in your authorization URL, and reject any callback that doesn't return it exactly. The cost is minimal; the protection is complete.


References

Frequently Asked Questions

What is an OAuth CSRF attack?

An OAuth CSRF attack tricks a victim's browser into completing an OAuth callback with an attacker-controlled authorization code, allowing the attacker to link their identity to the victim's account.

How do you prevent OAuth CSRF in Node.js?

Generate a cryptographically random `state` parameter with `crypto.randomBytes()` before starting the OAuth flow, store it server-side, and reject any callback where the returned `state` value doesn't match exactly.

What CWE is OAuth CSRF?

OAuth CSRF is classified under CWE-352 (Cross-Site Request Forgery), which covers cases where a server performs an action based on a request it cannot verify originated from the legitimate user.

Is HTTPS enough to prevent OAuth CSRF?

No. HTTPS protects data in transit but does not prevent an attacker from crafting a valid-looking callback URL. The `state` parameter validation is the specific control required to prevent CSRF in OAuth flows.

Can static analysis detect OAuth CSRF?

Yes. Tools like Semgrep can flag OAuth callback handlers that read a `code` parameter without also validating a `state` parameter, which is the exact pattern that Orbis AppSec's multi-agent AI scanner identified here.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #27

Related Articles

critical

How Missing Authentication Middleware Happens in Node.js APIs and How to Fix It

A critical vulnerability in a Node.js Panel Connector API (CVE-2025-7783) left 14 endpoints—including shell command execution, file deletion, and file writing—completely open to unauthenticated access. The comment in the source code even declared "NO AUTH — Full Open Access," making it a textbook example of a missing authentication control. The fix adds a Bearer token middleware guard on all `/api` routes, blocking unauthorized requests before they reach any sensitive handler.

critical

How Unauthenticated API Endpoint Exposure happens in Node.js and how to fix it

A critical vulnerability in `api/firebase-config.js` exposed all Firebase configuration values — including API keys, app IDs, and project IDs — to any unauthenticated caller. With no access controls, CORS restrictions, or rate limiting in place, attackers could retrieve live credentials and directly access Firebase services. The fix adds shared-secret authentication using timing-safe comparison, origin validation, and method enforcement.

high

How Middleware and Proxy Bypass happens in Next.js App Router and how to fix it

CVE-2026-64642 is a high-severity authentication bypass vulnerability in Next.js that affects App Router applications using Turbopack with a single locale configuration. The flaw allows attackers to circumvent middleware and proxy security controls, potentially gaining unauthorized access to protected routes. Upgrading from Next.js 16.2.7 to 16.2.11 closes the vulnerability entirely.

critical

How OAuth 2.0 CSRF happens in PHP and how to fix it

A critical OAuth 2.0 CSRF vulnerability in `login_weibo.php` allowed attackers to forge Weibo login requests by exploiting the missing `state` parameter validation. Without this check, an attacker could trick a victim's browser into completing an OAuth flow with the attacker's authorization code, potentially hijacking the victim's session. The fix generates a cryptographically random state token, stores it in the session, and validates it on callback.

critical

How Unauthenticated API Endpoints happen in Node.js Express and how to fix it

The `/token` endpoint in `plugin/multiplex/index.js` generated presentation control tokens without verifying the requester's identity, allowing any attacker with network access to seize control of a live reveal.js presentation. The fix restricts token generation to localhost-only requests and replaces a broken cryptographic primitive with a proper SHA-256 hash. Together, these changes eliminate both the access-control gap and a secondary cryptographic weakness in a single targeted patch.

critical

How eval() Code Injection happens in JavaScript and how to fix it

A critical code injection vulnerability was discovered in `js/lib/jsencrypt.js` at line 195, where a direct `eval()` call executed a JavaScript string shim for the `process` object in browser environments. If an attacker could influence the string passed to `eval()`—through a compromised dependency, a man-in-the-middle attack, or supply chain tampering—they could achieve arbitrary JavaScript execution in any user's browser. The fix replaces the `eval()` call with the equivalent inline JavaScript