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:
-
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)
-
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"}' -
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 inALLOWED_HDSif the attacker has a corporate account ✓
- The token'saudclaim points to a completely different application ✗ (but this was never checked!) -
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_HDSconfigured 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:
-
Calls Google's tokeninfo endpoint (line 19:
const GOOGLE_TOKENINFO = "https://www.googleapis.com/oauth2/v3/tokeninfo";) - This endpoint returns detailed token metadata including theaudclaim -
Retrieves the audience claim (line 147:
const aud = tiData?.aud || tiData?.azp || "";) - Checks bothaudandazp(authorized party) fields to handle different token types -
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 -
Rejects mismatched tokens - Returns a 401 error with a clear message if the audience doesn't match
-
Graceful degradation (line 137-139) - If
GOOGLE_CLIENT_IDisn'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.
Prevention & Best Practices
To prevent OAuth token audience bypass vulnerabilities in your own applications:
1. Always Validate the Audience Claim
For Google OAuth specifically:
// ✅ GOOD: Verify aud matches your client ID
const tokenInfo = await fetch('https://www.googleapis.com/oauth2/v3/tokeninfo', {
method: 'POST',
body: `access_token=${token}`
});
const data = await tokenInfo.json();
if (data.aud !== process.env.GOOGLE_CLIENT_ID) {
throw new Error('Token audience mismatch');
}
// ❌ BAD: Only checking if token is valid
const userInfo = await fetch('https://www.googleapis.com/oauth2/v3/userinfo', {
headers: { Authorization: `Bearer ${token}` }
});
if (userInfo.ok) {
// Token is valid, but for which app?
}
2. Use JWT Libraries with Built-in Validation
For JWT-based OAuth tokens, use libraries that enforce audience validation:
// Using jsonwebtoken library
const jwt = require('jsonwebtoken');
jwt.verify(token, publicKey, {
audience: process.env.GOOGLE_CLIENT_ID, // ✅ Enforces aud check
issuer: 'https://accounts.google.com'
}, (err, decoded) => {
if (err) throw new Error('Invalid token');
// Token is valid AND issued for this app
});
3. Follow OAuth 2.0 Security Best Practices
According to RFC 8252 (OAuth 2.0 for Native Apps):
- Always validate the aud claim matches your client ID
- Use the authorization code flow with PKCE for mobile/native apps
- Never accept tokens in URL fragments where they can leak
4. Implement Defense in Depth
Even with audience validation, add additional checks:
// Layer multiple validations
if (!tokenData.aud === expectedClientId) return reject();
if (!tokenData.email_verified) return reject();
if (tokenData.exp < Date.now() / 1000) return reject();
if (!ALLOWED_DOMAINS.includes(tokenData.hd)) return reject();
5. Use Static Analysis Tools
Configure tools like Semgrep to detect missing audience validation:
rules:
- id: oauth-missing-audience-check
pattern: |
fetch($USERINFO, ...)
...
pattern-not: |
fetch($USERINFO, ...)
...
$AUD === $CLIENT_ID
6. Test with Tokens from Other Applications
Include security tests that attempt authentication with valid OAuth tokens from different applications:
test('rejects valid Google token with wrong audience', async () => {
const tokenFromDifferentApp = 'eyJhbGc...DIFFERENT_AUD';
const result = await verifyGoogleUser(`Bearer ${tokenFromDifferentApp}`);
expect(result.ok).toBe(false);
expect(result.status).toBe(401);
});
Key Takeaways
- The
verifyGoogleUser()function inparse-schedule.mjsaccepted 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
audclaim against yourGOOGLE_CLIENT_IDusing 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_IDconfiguration with a warning rather than silently failing open
How Orbis AppSec Detected This
- Source: HTTP Authorization header containing OAuth bearer token in
parse-schedule.mjsserverless 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'sGOOGLE_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/azpclaim againstprocess.env.GOOGLE_CLIENT_IDbefore 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.