OAuth2 Is Everywhere. Most Implementations Are Broken.
If you're implementing OAuth2 in your app -- whether as a provider or consumer -- these are the mistakes that get developers breached.
Vulnerability 1: Missing State Parameter
The state parameter prevents CSRF attacks on OAuth flows. Without it, an attacker can trick a user into connecting their account to the attacker's credentials.
Wrong:
GET /oauth/authorize?client_id=...&redirect_uri=...&response_type=code
Right:
// Generate a random state, store in session
const state = crypto.randomBytes(32).toString('hex')
req.session.oauthState = state
const authUrl = new URL('https://provider.com/oauth/authorize')
authUrl.searchParams.set('client_id', CLIENT_ID)
authUrl.searchParams.set('redirect_uri', REDIRECT_URI)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('state', state) // Critical
// In callback:
if (req.query.state !== req.session.oauthState) {
throw new Error('State mismatch -- possible CSRF attack')
}
Vulnerability 2: Tokens in URL Parameters
Access tokens in URLs land in:
- Browser history
- Server logs
- Referrer headers sent to third parties
- CDN and proxy logs
Wrong:
GET /callback?access_token=eyJhbG...&user_id=123
Right:
// Exchange code for token on the server
// The code goes in URL, token exchange happens server-side
GET /callback?code=auth_code_here&state=state_here
// Server exchanges code for token via POST
const response = await fetch('https://provider.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET, // Never in frontend
})
})
Vulnerability 3: Open Redirect in redirect_uri
Without strict validation, attackers can redirect OAuth tokens to their own server.
// WRONG: Accepting any redirect_uri
const redirectUri = req.query.redirect_uri // Attacker sets this to evil.com
// RIGHT: Whitelist exactly
const ALLOWED_REDIRECTS = [
'https://yourapp.com/auth/callback',
'https://yourapp.com/api/auth/callback/github',
]
if (!ALLOWED_REDIRECTS.includes(req.query.redirect_uri)) {
return res.status(400).json({ error: 'Invalid redirect_uri' })
}
Vulnerability 4: Storing Tokens in localStorage
localStorage is accessible via JavaScript -- any XSS vulnerability exposes all tokens.
Wrong:
localStorage.setItem('access_token', token) // Any XSS = token theft
Right:
// Store in httpOnly cookie -- not accessible via JS
res.setHeader('Set-Cookie', [
`access_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600`
])
// For SPAs: use BFF (Backend for Frontend) pattern
// Frontend never sees the token -- backend proxies API calls
Vulnerability 5: Not Validating the ID Token
For OIDC flows, the ID token signature MUST be verified.
import { jwtVerify, createRemoteJWKSet } from 'jose'
const JWKS = createRemoteJWKSet(
new URL('https://accounts.google.com/.well-known/openid-configuration')
)
async function verifyIdToken(idToken: string, clientId: string) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: clientId, // Must match YOUR client ID
})
// Verify nonce if you set one
if (payload.nonce !== expectedNonce) {
throw new Error('Nonce mismatch')
}
return payload
}
Vulnerability 6: Long-Lived Access Tokens
Access tokens should expire in 1 hour max. Use refresh tokens for continuity.
async function getValidAccessToken(userId: string) {
const tokenRecord = await db.oauthToken.findUnique({ where: { userId } })
// Check if expired (with 5-minute buffer)
if (tokenRecord.expiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
// Refresh the token
const response = await fetch('https://provider.com/oauth/token', {
method: 'POST',
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: tokenRecord.refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
})
})
const { access_token, expires_in, refresh_token } = await response.json()
// Store new tokens
await db.oauthToken.update({
where: { userId },
data: {
accessToken: access_token,
expiresAt: new Date(Date.now() + expires_in * 1000),
refreshToken: refresh_token ?? tokenRecord.refreshToken
}
})
return access_token
}
return tokenRecord.accessToken
}
NextAuth Handles Most of This
NextAuth implements state, PKCE, secure cookies, and token refresh automatically for all its providers. The AI SaaS Starter Kit ships with NextAuth pre-configured for Google and GitHub OAuth.
$99 one-time at whoffagents.com
For MCP servers specifically, audit your OAuth implementations with the MCP Security Scanner -- it checks for these exact vulnerabilities in 60 seconds.
Top comments (0)