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
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed 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

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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #27

Related Articles

critical

Music Studio App Express Server: CWE-287 Authentication Bypass via

A critical authentication bypass vulnerability in a music studio application's Express server allowed remote attackers to access API endpoints despite the intended localhost-only restriction. The localRequest middleware validated the Host header but failed to verify the actual remote socket address, enabling trivial bypasses in containerized and multi-user environments.

high

CVE-2026-104850: MCP TypeScript SDK OAuth Credential Leak

The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) contained a flaw in its OAuth client flow where the MCP server being connected to could influence which authorization server the client talked to, meaning client credentials and token-exchange traffic could be directed to an endpoint the attacker controls. This project was pinned to the affected `1.30.0`; the dependency has been moved to `^1.31.0`, which carries the upstream fix for CVE-2026-104850. Any MCP client that performs OAuth against t

high

SQLite Auth Database Plaintext Storage in InitAuthDB()

The `InitAuthDB()` function opened SQLite databases without restricting filesystem permissions, leaving bcrypt password hashes, session tokens, and user settings exposed to any local account with read access. The fix explicitly sets `os.Chmod(filepath, 0600)` immediately after database creation, ensuring only the owner can access authentication secrets.

high

undici 8.10.0 Cache Poisoning: CVE-2026-85152 Auth Bypass

undici, the HTTP client used by Node.js's `fetch()` implementation, shipped a caching layer that did not properly isolate cached responses by origin, letting a response poisoned on one origin be served to requests for another. This created a path to cross-origin authentication bypass, tracked as CVE-2026-85152 and fixed in undici 8.10.2.

critical

Hybridauth Telegram OAuth Timing Attack in `authenticateCheckError()`

The Telegram OAuth provider in Hybridauth relied on `strcmp()` to validate HMAC-SHA256 signatures, exposing a timing side-channel that could allow attackers to forge authentication tokens character by character. The fix replaces this with PHP's timing-safe `hash_equals()` function, eliminating the information leak.

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.