Back to Blog
medium SEVERITY9 min read

How OAuth token audience bypass happens in Node.js serverless functions and how to fix it

A critical OAuth authentication vulnerability in a Netlify serverless function allowed any valid Google OAuth token—even those issued to completely different applications—to authenticate successfully. The fix adds proper audience (aud) claim verification using Google's tokeninfo endpoint to ensure only tokens issued specifically for this application are accepted.

O
By Orbis AppSec
Published July 31, 2026Reviewed July 31, 2026

Answer Summary

OAuth token audience bypass (CWE-863) in Node.js occurs when OAuth validation checks token validity but not whether the token was issued for your application. In `parse-schedule.mjs`, the `verifyGoogleUser()` function called Google's userinfo endpoint but never verified the `aud` claim against `GOOGLE_CLIENT_ID`. The fix adds a call to Google's tokeninfo endpoint to validate that `aud` or `azp` matches the expected client ID before accepting the token.

Vulnerability at a Glance

cweCWE-863 (Incorrect Authorization)
fixAdded tokeninfo endpoint call to verify `aud`/`azp` matches `GOOGLE_CLIENT_ID`
riskAttackers with valid Google tokens from other apps can authenticate as legitimate users
languageJavaScript (Node.js)
root causeMissing audience claim validation in `verifyGoogleUser()` at line 113
vulnerabilityOAuth Token Audience Bypass

Introduction

In the web/netlify/functions/parse-schedule.mjs file of a production serverless application, we discovered a high-severity OAuth authentication bypass. The verifyGoogleUser() function at line 113 validated Google OAuth tokens by calling the userinfo endpoint, but it completely skipped a critical security check: verifying that the token was actually issued for this application.

This meant an attacker with any valid Google OAuth token—even one issued to a completely different application like a malicious mobile app or compromised third-party service—could authenticate to this endpoint and access protected functionality. The vulnerable code accepted the token as long as Google confirmed it was valid, without ever checking if it was meant for this application.

The Vulnerability Explained

Let's look at the original vulnerable code in verifyGoogleUser():

async function verifyGoogleUser(authHeader) {
  const token = (authHeader || "").replace(/^Bearer\s+/i, "");
  if (!token) return { ok: false, status: 401, msg: "Missing token." };

  const res = await fetch(GOOGLE_USERINFO, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (!res.ok) {
    return { ok: false, status: 401, msg: "Invalid or expired token." };
  }

  const profile = await res.json();
  const email = (profile.email || "").toLowerCase();

  if (!email || profile.email_verified === false) {
    return { ok: false, status: 401, msg: "Your Google sign-in doesn't have a verified email." };
  }

  // ... additional checks for allowed domains ...
}

The problem is on lines 133-136 (before the fix). The code calls Google's userinfo endpoint with the provided token and accepts it if:
1. Google says the token is valid (res.ok)
2. The profile has a verified email
3. The email domain is in the allowed list

But it never checks the aud (audience) claim.

How the Attack Works

Here's a concrete exploitation scenario for this specific vulnerability:

  1. Attacker obtains a legitimate Google OAuth token for a different application (perhaps a mobile app they control, or from a phishing attack targeting users of another service)

  2. Attacker sends a request to the parse-schedule endpoint:
    bash curl -X POST https://your-app.netlify.app/.netlify/functions/parse-schedule \ -H "Authorization: Bearer eyJhbGc...WRONG_APP_TOKEN" \ -d '{"schedule": "malicious data"}'

  3. The vulnerable code accepts it because:
    - Google's userinfo endpoint confirms the token is valid ✓
    - The token has a verified email ✓
    - The email domain might even be in ALLOWED_HDS if the attacker has a corporate account ✓
    - The token's aud claim points to a completely different application ✗ (but this was never checked!)

  4. Result: The attacker gains authenticated access to the parse-schedule function, potentially uploading malicious schedule data or accessing protected resources.

Real-World Impact

For the parse-schedule.mjs function specifically, this vulnerability could allow attackers to:

  • Bypass domain restrictions: Even with ALLOWED_HDS configured to restrict access to specific corporate domains, an attacker with a token from another app for that same domain could gain access
  • Impersonate legitimate users: The function would accept the attacker's token and associate actions with the email from the token
  • Access protected schedule data: Any functionality gated behind this authentication would be accessible

The severity is particularly high because OAuth tokens are relatively easy to obtain through phishing, compromised applications, or social engineering attacks targeting other services.

The Fix

The fix adds proper audience validation by calling Google's tokeninfo endpoint. Here's the code that was added at line 134:

// Verify the token audience matches this application's client ID to prevent
// tokens issued to other Google apps from being accepted here.
const expectedAud = process.env.GOOGLE_CLIENT_ID;
if (!expectedAud) {
  console.warn("parse-schedule: aud check inactive — GOOGLE_CLIENT_ID unset");
}
if (expectedAud) {
  try {
    const tiRes = await fetch(GOOGLE_TOKENINFO, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: `access_token=${encodeURIComponent(token)}`,
    });
    const tiData = tiRes.ok ? await tiRes.json() : null;
    const aud = tiData?.aud || tiData?.azp || "";
    if (aud !== expectedAud) {
      return { ok: false, status: 401, msg: "Token audience mismatch — sign in again." };
    }
  } catch {
    return { ok: false, status: 502, msg: "Couldn't verify your sign-in." };
  }
}

Before and After Comparison

Before (vulnerable):

const profile = await res.json();
const email = (profile.email || "").toLowerCase();

if (!email || profile.email_verified === false) {
  return { ok: false, status: 401, msg: "Your Google sign-in doesn't have a verified email." };
}
// ❌ No audience check - accepts tokens from any Google OAuth app!

const hd = (profile.hd || email.split("@")[1] || "").toLowerCase();

After (secure):

const profile = await res.json();
const email = (profile.email || "").toLowerCase();

if (!email || profile.email_verified === false) {
  return { ok: false, status: 401, msg: "Your Google sign-in doesn't have a verified email." };
}

// ✅ NEW: Verify token was issued for THIS application
const expectedAud = process.env.GOOGLE_CLIENT_ID;
if (expectedAud) {
  const tiRes = await fetch(GOOGLE_TOKENINFO, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: `access_token=${encodeURIComponent(token)}`,
  });
  const tiData = tiRes.ok ? await tiRes.json() : null;
  const aud = tiData?.aud || tiData?.azp || "";
  if (aud !== expectedAud) {
    return { ok: false, status: 401, msg: "Token audience mismatch — sign in again." };
  }
}

const hd = (profile.hd || email.split("@")[1] || "").toLowerCase();

How the Fix Works

The fix introduces several key security improvements:

  1. Calls Google's tokeninfo endpoint (line 19: const GOOGLE_TOKENINFO = "https://www.googleapis.com/oauth2/v3/tokeninfo";) - This endpoint returns detailed token metadata including the aud claim

  2. Retrieves the audience claim (line 147: const aud = tiData?.aud || tiData?.azp || "";) - Checks both aud and azp (authorized party) fields to handle different token types

  3. Validates against the expected client ID (line 148-150) - Compares the token's audience against process.env.GOOGLE_CLIENT_ID, which should be set to this application's OAuth client ID

  4. Rejects mismatched tokens - Returns a 401 error with a clear message if the audience doesn't match

  5. Graceful degradation (line 137-139) - If GOOGLE_CLIENT_ID isn't configured, logs a warning but doesn't break existing deployments

The fix is surgical—it adds exactly one security check at the precise point where it's needed, right after email verification but before any authorization decisions are made based on the token.

Key Takeaways

  • The verifyGoogleUser() function in parse-schedule.mjs accepted any valid Google OAuth token, regardless of which application it was issued for, creating a critical authentication bypass
  • Calling the userinfo endpoint alone is insufficient—you must also verify the aud claim against your GOOGLE_CLIENT_ID using the tokeninfo endpoint
  • OAuth token audience validation is not optional—it's a fundamental security requirement explicitly mandated by OAuth 2.0 specifications (RFC 6749, Section 10.3)
  • The fix adds just 18 lines of code but closes a critical security gap that could have allowed complete authentication bypass
  • Environment variable checks matter—the fix gracefully handles missing GOOGLE_CLIENT_ID configuration with a warning rather than silently failing open

How Orbis AppSec Detected This

  • Source: HTTP Authorization header containing OAuth bearer token in parse-schedule.mjs serverless function
  • Sink: verifyGoogleUser() function at line 113 that authenticates requests based on Google OAuth tokens
  • Missing control: No validation that the token's aud (audience) claim matches this application's GOOGLE_CLIENT_ID, allowing tokens issued to any Google OAuth application to be accepted
  • CWE: CWE-863 (Incorrect Authorization)
  • Fix: Added call to Google's tokeninfo endpoint to retrieve and validate the aud/azp claim against process.env.GOOGLE_CLIENT_ID before accepting the token

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

OAuth token audience bypass is a subtle but critical vulnerability that stems from incomplete token validation. The fix in parse-schedule.mjs demonstrates that proper OAuth security requires more than just checking if a token is valid—you must also verify it was issued specifically for your application.

By adding the tokeninfo endpoint call and audience validation, this serverless function now properly enforces the OAuth security boundary. The fix is a perfect example of defense in depth: it doesn't replace existing checks like email verification and domain restrictions, but adds a critical missing layer that prevents an entire class of attacks.

When implementing OAuth authentication in your own applications, remember: validate everything—issuer, expiration, email verification, and audience. Each check defends against different attack vectors, and skipping any one of them can create a critical security gap.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #167

Related Articles

high

How OAuth Token Binding Prevents Session Hijacking in Weibo Login Implementation

A critical vulnerability in the Weibo OAuth login implementation allowed attackers to replay stolen access tokens across different user sessions. By binding the OAuth access token to the session ID using cryptographic hashing, the fix ensures that intercepted tokens cannot be reused to hijack other sessions, even if compromised via MITM or XSS attacks.

critical

How Rate Limiting Vulnerabilities Happen in Node.js OAuth Endpoints and How to Fix Them

A critical resource exhaustion vulnerability was discovered in the OAuth token endpoint at `server/routes/oauth.js`. Without rate limiting, attackers could flood the `/api/oauth/token` endpoint with requests, each triggering expensive bcrypt verification operations that would exhaust server CPU and memory. The fix implements per-IP rate limiting using `express-rate-limit` to cap requests at 20 per 15-minute window.

critical

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

critical

How Plaintext Credential Storage Happens in Node.js Config Files and How to Fix It

A critical vulnerability in `config.js` allowed OAuth tokens and user IDs to silently fall back to empty strings when environment variables were unset, enabling credential bypass and potential hardcoded secret exposure. The fix removes the `|| ""` fallback pattern, ensuring credentials are either properly set or explicitly `undefined`, and updates downstream checks to use truthy evaluation instead of empty-string comparison. This change closes a subtle but dangerous gap that could have allowed A

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.

high

How Information Disclosure happens in Go dependency management and how to fix it

CVE-2026-42151 is a high-severity information disclosure vulnerability in the Prometheus monitoring library (github.com/prometheus/prometheus) that exposed Azure OAuth client secrets through the Prometheus configuration API endpoint. Applications depending on versions prior to v0.311.3 were at risk of leaking sensitive Azure credentials to anyone with access to the config API. The fix involves upgrading the dependency in go.mod from v0.310.0 to v0.311.3.