OAuth is everywhere. It powers "Sign in with Google," connects your SPA to your API, and handles authorization for mobile apps. It is also one of the most commonly misconfigured protocols in production. The spec is flexible by design, and that flexibility is exactly where things go wrong.
Summer dev cycles are the perfect time to audit your OAuth implementation. Teams are shipping new features, onboarding contractors, and spinning up integrations. Before you push that next release, let's walk through the seven OAuth and OIDC misconfigurations that attackers exploit most often, with code examples showing what the vulnerability looks like and how to fix it.
1. Open Redirects in Callback URLs
This is the most common OAuth vulnerability, and it has been around for years. If your authorization server accepts wildcard or loosely validated redirect URIs, an attacker can intercept the authorization code by redirecting the callback to a domain they control.
The vulnerability
# Registered redirect URI allows wildcards
redirect_uri: https://yourapp.com/*
# Attacker crafts this authorization URL:
https://auth.provider.com/authorize?
client_id=your_client_id&
redirect_uri=https://yourapp.com/callback/../../../evil.com&
response_type=code&
scope=openid profile
The authorization server sees a URI that technically matches the wildcard pattern and sends the authorization code to a path the attacker controls. Path traversal, open redirectors on your domain, or subdomain takeover can all make this worse.
The fix
# Register exact redirect URIs only
redirect_uris:
- https://yourapp.com/auth/callback
- https://yourapp.com/auth/callback/mobile
# Validate with exact string matching, not pattern matching
def validate_redirect_uri(uri, registered_uris):
return uri in registered_uris # Exact match only
Register the full, exact URI. No wildcards. No pattern matching. Every redirect URI should be an exact string match against a whitelist you control.
2. PKCE Not Enforced on Public Clients
If you are building a single-page application or a mobile app, your client cannot keep a secret. There is no way to securely store a client_secret in JavaScript that runs in the browser or in a mobile binary that can be decompiled. PKCE (Proof Key for Code Exchange) was designed specifically for this problem, but many implementations still skip it.
The vulnerability
// SPA authorization request without PKCE
const authUrl = `https://auth.provider.com/authorize?
client_id=${clientId}&
redirect_uri=${redirectUri}&
response_type=code&
scope=openid profile`;
// An attacker who intercepts the authorization code
// can exchange it directly since there is no proof key
The fix
// Generate PKCE challenge before authorization request
function generatePKCE() {
const verifier = crypto.randomUUID() + crypto.randomUUID();
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
return crypto.subtle.digest('SHA-256', data).then(hash => {
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { verifier, challenge };
});
}
// Include in authorization request
const { verifier, challenge } = await generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier);
const authUrl = `https://auth.provider.com/authorize?
client_id=${clientId}&
redirect_uri=${redirectUri}&
response_type=code&
scope=openid profile&
code_challenge=${challenge}&
code_challenge_method=S256`;
// Token exchange includes the verifier
const tokenResponse = await fetch('https://auth.provider.com/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: sessionStorage.getItem('pkce_verifier')
})
});
Enforce PKCE on every public client. Better yet, require it for all clients, including confidential ones. The overhead is minimal and it closes the authorization code interception attack entirely.
3. Overly Broad Scopes
Requesting more permissions than your app actually needs is the OAuth equivalent of running everything as root. When a token with broad scopes gets leaked, the blast radius is massive.
The vulnerability
# App only needs to read the user's profile,
# but requests full account access
scope=openid profile email
read:repos write:repos
admin:org
delete:packages
The fix
# Request only what you need
scope=openid profile
# If you need more later, use incremental authorization
# to request additional scopes at the point of use
scope=openid profile read:repos
Apply the principle of least privilege to your scopes. Request the minimum set at login, and use incremental or step-up authorization when your app actually needs elevated permissions. Review your scope requests quarterly, because features get removed but the scopes tend to stick around.
4. Token Storage in localStorage
This one shows up constantly in SPAs. Storing access tokens or refresh tokens in localStorage makes them accessible to any JavaScript running on your page. A single XSS vulnerability, a compromised third-party script, or a malicious browser extension can read every token you have stored.
The vulnerability
// After token exchange, storing in localStorage
const tokenData = await response.json();
localStorage.setItem('access_token', tokenData.access_token);
localStorage.setItem('refresh_token', tokenData.refresh_token);
// Any script on the page can read these
// Including injected scripts from XSS attacks
const stolen = localStorage.getItem('access_token');
The fix
// Server-side: Set tokens in httpOnly cookies
app.post('/auth/callback', async (req, res) => {
const tokenData = await exchangeCode(req.body.code);
res.cookie('access_token', tokenData.access_token, {
httpOnly: true, // Not accessible via JavaScript
secure: true, // HTTPS only
sameSite: 'Strict', // No cross-site requests
maxAge: 900000, // 15 minutes
path: '/api' // Only sent to API routes
});
res.cookie('refresh_token', tokenData.refresh_token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 604800000, // 7 days
path: '/auth/refresh' // Only sent to refresh endpoint
});
res.json({ authenticated: true });
});
Move tokens out of JavaScript entirely. Use httpOnly cookies with SameSite=Strict and the Secure flag. Scope the cookie path so it is only sent where it is needed. For SPAs, consider a Backend-for-Frontend (BFF) pattern where the server handles all token management and your frontend never touches a token directly.
5. Missing State Parameter (CSRF)
The state parameter in OAuth is your CSRF protection. Without it, an attacker can trick a user into completing an OAuth flow that links the attacker's account to the victim's session. This is called a login CSRF attack, and it is surprisingly easy to pull off.
The vulnerability
// Authorization request without state parameter
const authUrl = `https://auth.provider.com/authorize?
client_id=${clientId}&
redirect_uri=${redirectUri}&
response_type=code&
scope=openid profile`;
// No state parameter = no CSRF protection
// Callback handler does not validate state
app.get('/auth/callback', async (req, res) => {
const { code } = req.query;
// Attacker can initiate OAuth with their own account
// and have the victim complete the flow
const tokens = await exchangeCode(code);
});
The fix
// Generate a cryptographically random state value
const state = crypto.randomBytes(32).toString('hex');
req.session.oauth_state = state;
const authUrl = `https://auth.provider.com/authorize?
client_id=${clientId}&
redirect_uri=${redirectUri}&
response_type=code&
scope=openid profile&
state=${state}`;
// Validate state in callback
app.get('/auth/callback', async (req, res) => {
const { code, state } = req.query;
if (!state || state !== req.session.oauth_state) {
return res.status(403).json({ error: 'Invalid state parameter' });
}
delete req.session.oauth_state; // One-time use
const tokens = await exchangeCode(code);
});
Always generate a unique, cryptographically random state value for each authorization request. Store it in the session, validate it on callback, and delete it after use. This is non-negotiable.
6. Refresh Token Mishandling
Refresh tokens are long-lived credentials that can generate new access tokens. If they are not rotated, bound to the client, or properly revoked, a stolen refresh token gives an attacker persistent access to your user's account for weeks or months.
The vulnerability
// Refresh token that never rotates and never expires
app.post('/auth/refresh', async (req, res) => {
const { refresh_token } = req.body;
// Same refresh token can be used indefinitely
const newTokens = await provider.refreshToken(refresh_token);
// Old refresh token is still valid after use
res.json({
access_token: newTokens.access_token
// Returns same refresh_token, no rotation
});
});
The fix
// Implement refresh token rotation with reuse detection
app.post('/auth/refresh', async (req, res) => {
const { refresh_token } = req.body;
// Check if this token has already been used
const tokenRecord = await db.findRefreshToken(refresh_token);
if (!tokenRecord) {
return res.status(401).json({ error: 'Invalid refresh token' });
}
if (tokenRecord.used) {
// Token reuse detected - possible theft
// Revoke the entire token family
await db.revokeTokenFamily(tokenRecord.family_id);
return res.status(401).json({ error: 'Token reuse detected' });
}
// Mark current token as used
await db.markTokenUsed(refresh_token);
// Issue new token pair
const newRefreshToken = crypto.randomBytes(64).toString('hex');
await db.createRefreshToken({
token: newRefreshToken,
family_id: tokenRecord.family_id,
expires_at: Date.now() + 7 * 24 * 60 * 60 * 1000
});
res.json({
access_token: generateAccessToken(tokenRecord.user_id),
refresh_token: newRefreshToken
});
});
Rotate refresh tokens on every use. Implement token family tracking so that if a previously used refresh token shows up again, you can revoke the entire family and force re-authentication. Set reasonable expiration times, and always provide a revocation endpoint.
7. Insecure Dynamic Client Registration
OAuth 2.0 Dynamic Client Registration (RFC 7591) lets clients register themselves with an authorization server programmatically. When this endpoint is open and unauthenticated, attackers can register their own clients with custom redirect URIs, effectively creating a backdoor into your OAuth flow.
The vulnerability
# Open registration endpoint with no authentication
POST /oauth/register HTTP/1.1
Content-Type: application/json
{
"client_name": "Totally Legitimate App",
"redirect_uris": ["https://attacker.com/steal-tokens"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"scope": "openid profile email"
}
# Server responds with valid client credentials
# Attacker can now initiate OAuth flows through your server
The fix
# Require authentication for dynamic registration
POST /oauth/register HTTP/1.1
Content-Type: application/json
Authorization: Bearer <initial_access_token>
{
"client_name": "Partner Integration",
"redirect_uris": ["https://verified-partner.com/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"scope": "openid profile"
}
# Server-side validation
def register_client(request):
# Verify the initial access token
if not verify_registration_token(request.headers['Authorization']):
return Response(status=401)
# Validate redirect URIs against allowed domains
for uri in request.json['redirect_uris']:
if not is_approved_domain(uri):
return Response(status=400, body='Redirect URI domain not approved')
# Restrict available scopes
allowed_scopes = get_allowed_scopes_for_token(request.headers['Authorization'])
requested_scopes = request.json.get('scope', '').split()
if not set(requested_scopes).issubset(allowed_scopes):
return Response(status=400, body='Requested scope exceeds allowed scopes')
return create_client(request.json)
If you do not need dynamic registration, disable it entirely. If you do, require an initial access token, validate redirect URI domains against an allowlist, and restrict the scopes that dynamically registered clients can request. Treat every dynamically registered client as untrusted until manually reviewed.
Your Summer OAuth Audit Checklist
Before your next deploy, run through these checks:
- Audit every registered redirect URI. Remove wildcards, remove anything you do not recognize, and switch to exact string matching.
- Enforce PKCE on all public clients. SPAs, mobile apps, CLI tools. If it cannot keep a secret, it needs PKCE with S256.
- Review your scopes. Are you requesting permissions your app no longer uses? Trim them down.
- Check where tokens live. If you find tokens in
localStorageorsessionStorage, move them to httpOnly cookies withSameSite=Strict. - Verify state parameter handling. Every authorization request should include a unique state value, and every callback should validate it.
- Confirm refresh token rotation. Used tokens should be invalidated. Reuse should trigger a revocation of the entire token family.
- Lock down dynamic registration. If it is enabled, make sure it requires authentication and validates redirect URIs against an allowlist.
"OAuth does not break because the spec is weak. It breaks because implementations skip the parts that feel optional but are not."
OAuth is powerful, but its flexibility means there are a lot of ways to get it wrong. The seven misconfigurations covered here are not theoretical. They show up in real penetration tests, real bug bounty reports, and real breach disclosures. Take advantage of the summer dev cycle to lock these down before an attacker takes advantage of them instead.