OAuth Security Vulnerabilities: Open Redirects, State & Token Leakage
OAuth security vulnerabilities are among the trickiest to catch in code review because they involve multi-step flows where the vulnerability lives in the interaction between components rather than in any single line of code. This guide covers every major OAuth 2.0 attack and the exact code changes that prevent them.
Check your site's security configuration: Free ZeriFlow scan in 60 seconds →
OAuth 2.0 Flow Overview
Before diving into vulnerabilities, a quick recap of the Authorization Code flow — the only OAuth 2.0 flow that should be used for web applications in 2026:
- 1Your app redirects the user to the Identity Provider (IdP) with
client_id,redirect_uri,scope,state, and optionallycode_challenge(PKCE). - 2User authenticates at the IdP.
- 3IdP redirects back to your
redirect_uriwith an authorizationcodeand the originalstate. - 4Your app validates
state, then exchangescode+client_secret(+code_verifierfor PKCE) for an access token at the token endpoint. - 5Your app uses the access token to access resources.
Every vulnerability stems from a flaw in one of these steps.
Vulnerability 1: Missing State Parameter (CSRF on OAuth)
The state parameter is a CSRF token for your OAuth flow. Without it, an attacker can trick a victim's browser into completing an OAuth authorization with the attacker's account, binding the victim's app account to the attacker's IdP account.
Attack (CSRF account linking):
1. Attacker initiates an OAuth flow but does not complete it — captures the authorization code.
2. Attacker embeds a URL (or img tag) that sends the victim's browser to /oauth/callback?code=ATTACKER_CODE.
3. Victim's browser completes the callback. Their account is now linked to the attacker's IdP identity.
// WRONG — no state validation
app.get('/oauth/callback', async (req, res) => {
const { code } = req.query;
const token = await exchangeCodeForToken(code);
req.session.userId = token.userId;
res.redirect('/dashboard');
});
// CORRECT — validate state
app.get('/oauth/google', (req, res) => {
const state = crypto.randomBytes(32).toString('hex');
req.session.oauthState = state;
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
redirect_uri: 'https://yourapp.com/oauth/callback',
response_type: 'code',
scope: 'openid email profile',
state
});
res.redirect(`https://accounts.google.com/o/oauth2/auth?${params}`);
});
app.get('/oauth/callback', async (req, res) => {
const { code, state } = req.query;
// Validate state before anything else
if (!state || state !== req.session.oauthState) {
return res.status(403).json({ error: 'Invalid OAuth state. Possible CSRF attack.' });
}
delete req.session.oauthState; // single-use
const token = await exchangeCodeForToken(code);
req.session.userId = token.userId;
res.redirect('/dashboard');
});Vulnerability 2: Open Redirect in redirect_uri
OAuth authorization servers should strictly validate redirect_uri against a registered whitelist. But misconfigurations in either the IdP or your application can introduce open redirects that leak the authorization code.
Attacker scenario:
1. Attacker finds an open redirect on your site: https://yourapp.com/go?url=https://attacker.com
2. Attacker initiates OAuth with redirect_uri=https://yourapp.com/go?url=https://attacker.com
3. If the IdP matches only the domain (not the full path), it approves the redirect.
4. The authorization code is sent to attacker.com via the Referer header or the URL.
// On your OAuth client — register exact URIs, never prefixes
// Register in IdP console:
// WRONG: https://yourapp.com/oauth (prefix match accepted)
// CORRECT: https://yourapp.com/oauth/callback (exact match only)
// On your callback endpoint — validate against your own allowlist too
const ALLOWED_REDIRECT_URIS = new Set([
'https://yourapp.com/oauth/callback'
]);
app.get('/oauth/callback', (req, res) => {
// Validate redirect_uri before using
if (!ALLOWED_REDIRECT_URIS.has(req.query.redirect_uri)) {
return res.status(400).json({ error: 'Invalid redirect URI' });
}
// ...
});Additionally, eliminate open redirects throughout your application — they can be chained with OAuth flows even if your OAuth implementation is correct.
Vulnerability 3: Authorization Code Interception (without PKCE)
For public clients (SPAs, mobile apps), there is no client_secret. An attacker who intercepts the authorization code can exchange it for tokens immediately.
PKCE (Proof Key for Code Exchange, RFC 7636) prevents this:
// SPA/mobile client — generate PKCE pair
function generatePKCE() {
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
return { verifier, challenge };
}
// Authorization request
const { verifier, challenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = new URL('https://idp.example.com/authorize');
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
// ... other params
// Token exchange — include verifier
const tokenResponse = await fetch('https://idp.example.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
code_verifier: sessionStorage.getItem('pkce_verifier'),
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID
})
});PKCE should now be used for all OAuth clients, including confidential clients (server-side apps). OAuth 2.1 makes PKCE mandatory.
Vulnerability 4: Token Leakage via Referer Headers
If your post-OAuth redirect lands on a page that loads third-party resources, the access token or authorization code may leak via the Referer header if it appears in the URL.
The Implicit Flow (response_type=token) was deprecated specifically because of this. Access tokens in the fragment were originally considered safe, but client-side JavaScript can read them and many logging/analytics tools capture full URLs.
Fix: always use Authorization Code flow. Never use Implicit flow. After the OAuth callback, redirect to a URL that does not contain the token in the query string or fragment.
ZeriFlow checks that your application sets proper Referrer-Policy headers, preventing sensitive URL parameters from leaking to third-party resources. A Referrer-Policy: strict-origin-when-cross-origin or no-referrer header limits what gets shared across origins.
Referrer-Policy: strict-origin-when-cross-originVulnerability 5: Improper Scope Validation
OAuth scopes define what an access token can do. Vulnerabilities arise when:
- Your resource server does not validate the scope before serving a request.
- You request broader scopes than needed (violating least privilege).
- Scope escalation is possible (user consents to read, token somehow grants write).
// Resource server — validate scope on every endpoint
function requireScope(requiredScope) {
return (req, res, next) => {
const tokenScopes = req.token.scope?.split(' ') || [];
if (!tokenScopes.includes(requiredScope)) {
return res.status(403).json({
error: 'insufficient_scope',
scope: requiredScope
});
}
next();
};
}
app.get('/api/profile', requireScope('profile:read'), (req, res) => { ... });
app.put('/api/profile', requireScope('profile:write'), (req, res) => { ... });
app.delete('/api/user', requireScope('admin'), (req, res) => { ... });FAQ
### Q: Should I implement OAuth myself or use a library?
A: Use a well-maintained library for the OAuth client (e.g., passport.js, python-social-auth, spring-security-oauth). Implementing OAuth from scratch introduces subtle state management and validation bugs. Build your business logic on top of a library rather than raw HTTP calls.
### Q: Is the Implicit Flow still acceptable for legacy SPAs? A: No. The Implicit Flow (response_type=token) is deprecated in OAuth 2.1. Migrate to Authorization Code + PKCE. Every major IdP (Google, Microsoft, Auth0) supports PKCE for public clients, and most have explicit guidance recommending migration away from Implicit.
### Q: How do I handle token refresh securely?
A: Store refresh tokens in an HttpOnly cookie or your server-side session store, never in localStorage. Rotate refresh tokens on every use (refresh token rotation) and invalidate the old token immediately. Issue short-lived access tokens (15 minutes) and refresh them transparently in the background.
### Q: What is the risk if I omit the state parameter? A: CSRF attacks against your OAuth flow. An attacker can trick users into authorizing an OAuth connection to the attacker's account. This enables account takeover: the victim's app account becomes linked to (or logged in as) the attacker's identity.
### Q: Should I validate id_token claims in OpenID Connect?
A: Yes. Always validate: iss (issuer), aud (audience — must be your client_id), exp (expiry), and nonce (if you sent one). Failure to validate audience allows tokens issued to other clients to be used against your application.
Conclusion
OAuth security requires getting every step right: generating and validating state, registering exact redirect URIs, using PKCE for all clients, sticking to Authorization Code flow, and validating scopes at every resource endpoint. A single omission — a missing state check, an open redirect, or no PKCE — can turn a benign-looking OAuth flow into a complete account takeover.
Run a free ZeriFlow scan → — no credit card required.