DEV Community

Atlas Whoff
Atlas Whoff

Posted on

OAuth2 Security Best Practices: 6 Vulnerabilities That Get Apps Breached

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
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  })
})
Enter fullscreen mode Exit fullscreen mode

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' })
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)