OAuth 2.0 implementation mistakes that expose user accounts

By Fatima Al-Rashid · 21 July 20260 views

A security researcher reports a vulnerability in an application's "Sign in with Google" implementation. Any attacker who can observe a user's OAuth authorization code — through a shared browser, a browser extension, or network interception on an insecure connection — can redeem it for an access token and access the victim's account.

The root cause: the application does not validate the state parameter in the OAuth callback. The state parameter is the CSRF protection mechanism. Without it, any OAuth callback to the application can be completed by anyone who has the authorization code.

This is not an obscure edge case in the specification. The state parameter requirement is explicit in RFC 6749, the core OAuth 2.0 specification. The vulnerability exists because someone read a tutorial that showed the minimum working implementation — and minimum working is not the same as minimum secure.

This pattern repeats across OAuth implementations. The specification is over 75 pages long and details many requirements that affect security. Tutorials focus on the happy path. The gap between tutorial and specification is where vulnerabilities live.

The root cause: OAuth is a protocol, not a library call

The fundamental reason OAuth 2.0 is frequently misimplemented is that developers treat it as a library call rather than a protocol. A library call has a correct return value and an incorrect one. A protocol has a sequence of steps, each with security properties that must be maintained.

When a developer uses a well-maintained OAuth library — passport-google-oauth20, Firebase Authentication's built-in Google sign-in, or Auth.js — the library implements the protocol. When a developer implements OAuth manually or uses a minimal helper that only handles token exchange, the security requirements become the developer's responsibility.

The six mistakes below are common precisely because they are not visible in the happy path. An implementation that skips state validation, skips token verification, or stores tokens incorrectly will appear to work correctly in development and testing. The vulnerability is only exploitable by an attacker who understands the protocol.

Mistake 1: Missing state parameter validation

The state parameter prevents CSRF attacks on the OAuth flow. The application generates a random, unpredictable state value, includes it in the authorization request, and verifies it matches in the callback.

// CORRECT: Generate and verify state
import { randomBytes } from 'crypto';

// Step 1: Start OAuth flow
function initiateOAuth(req: Request, res: Response) {
  const state = randomBytes(32).toString('hex');

  // Store state in session (server-side)
  req.session.oauthState = state;

  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
  authUrl.searchParams.set('client_id', process.env.GOOGLE_CLIENT_ID!);
  authUrl.searchParams.set('redirect_uri', 'https://myapp.com/auth/callback');
  authUrl.searchParams.set('response_type', 'code');
  authUrl.searchParams.set('scope', 'openid email profile');
  authUrl.searchParams.set('state', state);  // Include in request

  res.redirect(authUrl.toString());
}

// Step 2: Handle callback
function handleOAuthCallback(req: Request, res: Response) {
  const { code, state } = req.query;

  // CRITICAL: Verify state matches what was sent
  if (!state || state !== req.session.oauthState) {
    res.status(400).json({ error: 'Invalid state parameter' });
    return;
  }

  // Clear the used state
  delete req.session.oauthState;

  // Exchange code for tokens...
  exchangeCodeForTokens(code as string);
}

The CSRF attack this prevents: an attacker crafts a URL pointing to the application's callback endpoint with a valid authorization code that the attacker obtained by initiating their own OAuth flow. If the application does not validate state, it will complete authentication and log the victim into the attacker's account.

The state value must be:

  • Cryptographically random (not predictable from user ID, timestamp, or other knowable values)
  • Stored server-side in the session (not in a cookie that the attacker can read)
  • Single-use (deleted after verification to prevent replay)

Mistake 2: Trusting the token without verification

After receiving an ID token or access token, many implementations decode the JWT payload and use the claims without verifying the token's signature or audience.

// WRONG: Decoding without verification
function getUserFromToken(idToken: string) {
  // atob(idToken.split('.')[1]) decodes the payload
  // but does NOT verify the signature or audience
  const payload = JSON.parse(atob(idToken.split('.')[1]));
  return { userId: payload.sub, email: payload.email };
  // An attacker could forge a token with any user's sub claim
}

// CORRECT: Verify the token using the OAuth provider's library or JWKS
import { OAuth2Client } from 'google-auth-library';

const client = new OAuth2Client(process.env.GOOGLE_CLIENT_ID);

async function getUserFromGoogleIdToken(idToken: string) {
  const ticket = await client.verifyIdToken({
    idToken,
    audience: process.env.GOOGLE_CLIENT_ID,  // Verify the token was issued for this app
  });

  const payload = ticket.getPayload();
  if (!payload) throw new Error('Invalid token');

  // payload.sub is verified — it is the Google-assigned user ID
  return { userId: payload.sub, email: payload.email! };
}

The library's verification checks the token signature, expiry, issuer, and audience — all of which must match for the token to be valid.

What audience verification prevents: an attacker who has a valid Google ID token from a different application cannot use it with this application. The audience claim (aud) must match the application's client ID. Without this check, any valid Google token — obtained for any application — can authenticate to this one.

What signature verification prevents: token forgery. A JWT without signature verification is just a base64-encoded JSON blob that anyone can modify. An attacker can change the sub claim to any user ID and gain access to that account.

For implementations using the JWKS endpoint directly (useful for custom OAuth providers):

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://www.googleapis.com/oauth2/v3/certs')
);

async function verifyGoogleToken(idToken: string) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: 'https://accounts.google.com',
    audience: process.env.GOOGLE_CLIENT_ID,
  });

  return payload;
}

Mistake 3: Storing tokens in localStorage

Access tokens and refresh tokens stored in localStorage are accessible to any JavaScript on the page. XSS vulnerabilities — even from a third-party script — can extract tokens from localStorage.

// WRONG: Storing tokens in localStorage
localStorage.setItem('access_token', token);
localStorage.setItem('refresh_token', refreshToken);

// CORRECT for web applications: Store refresh tokens in HttpOnly cookies
// The access token can be kept in memory (not persisted)

// Server-side: Set refresh token as HttpOnly cookie
res.cookie('refresh_token', refreshToken, {
  httpOnly: true,     // Not accessible to JavaScript
  secure: true,       // HTTPS only
  sameSite: 'strict', // CSRF protection
  maxAge: 30 * 24 * 60 * 60 * 1000,  // 30 days
  path: '/auth',      // Only sent to auth endpoints
});

// Client-side: Access token in memory (lost on page reload — refresh from cookie)
let accessToken: string | null = null;

async function getAccessToken(): Promise<string> {
  if (accessToken && !isExpired(accessToken)) return accessToken;

  // Refresh using the HttpOnly cookie (sent automatically)
  const response = await fetch('/auth/refresh', { credentials: 'include' });
  const data = await response.json();
  accessToken = data.accessToken;
  return accessToken;
}

For Firebase Authentication, the SDK handles token storage securely using IndexedDB in web environments, not localStorage.

The argument that "localStorage is fine because we don't have XSS vulnerabilities" misunderstands the threat model. XSS can come from third-party scripts included in the page — analytics, A/B testing, customer support widgets. Any of these can be compromised and serve malicious code that exfiltrates localStorage contents. HttpOnly cookies are not accessible from JavaScript regardless of XSS — they are a browser-enforced protection, not an application-level one.

Mistake 4: Not revoking tokens on logout

Tokens have expiry times, but a user who logs out expects their session to be immediately invalidated — not 60 minutes later when the access token expires.

// Correct logout implementation
async function logout(userId: string, refreshToken: string) {
  // 1. Revoke the refresh token at the OAuth provider
  await fetch('https://oauth2.googleapis.com/revoke', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ token: refreshToken }),
  });

  // 2. Invalidate the server-side session
  await sessionStore.destroy(userId);

  // 3. Clear the HttpOnly cookie
  res.clearCookie('refresh_token');

  // 4. Add the access token to a revocation list (short-lived, until token expires)
  await tokenRevocationList.add(accessToken, expiresAt);
}

// Middleware: Check revocation list on each request
async function validateToken(token: string) {
  if (await tokenRevocationList.has(token)) {
    throw new UnauthorizedError('Token has been revoked');
  }
  // Continue with normal token validation
}

The revocation list for access tokens is a short-lived cache keyed on token hash. Access tokens have short lifetimes (15-60 minutes), so the revocation list only needs to hold entries for that duration. Redis with automatic TTL expiry is a natural fit:

import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });

async function revokeAccessToken(token: string, expiresAt: Date) {
  const ttl = Math.ceil((expiresAt.getTime() - Date.now()) / 1000);
  if (ttl > 0) {
    await redis.setEx(`revoked:${hashToken(token)}`, ttl, '1');
  }
}

async function isTokenRevoked(token: string): Promise<boolean> {
  return (await redis.exists(`revoked:${hashToken(token)}`)) === 1;
}

Mistake 5: Authorization code exposed in server logs

The authorization code in the callback URL is logged by many web frameworks by default. A server log that includes GET /auth/callback?code=4/0AbC...&state=xyz exposes a short-lived but valid authorization code.

// Before processing the callback, ensure the code is not logged
// Most frameworks log the full request URL — configure logging to redact sensitive params

// Express: Custom logger that redacts OAuth codes
app.use((req, res, next) => {
  if (req.path === '/auth/callback') {
    // Log without the code
    const safeUrl = new URL(req.url, `https://${req.hostname}`);
    safeUrl.searchParams.delete('code');
    console.log(`OAuth callback: ${safeUrl.toString()}`);
  }
  next();
});

Authorization codes are short-lived (typically 10 minutes) and single-use, which limits the damage. But log access is often broader than API access — logs may be accessible to more people, shipped to external log aggregators, or stored for longer periods than tokens. A code that expires in 10 minutes but sits in a log shipped to a third-party analytics service is a risk.

Configuring log redaction for the callback endpoint is a low-effort, high-value protection:

// Pino logger with redaction
import pino from 'pino';

const logger = pino({
  redact: {
    paths: ['req.query.code', 'req.query.state'],
    censor: '[REDACTED]',
  },
});

Mistake 6: Open redirects in the redirect_uri

If the application accepts a redirect_after_login parameter and uses it to redirect after OAuth completes, an attacker can pass a malicious URL that redirects to their server with the authorization code.

// WRONG: Using user-controlled redirect
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  const redirectTo = req.query.redirect_to as string;

  await handleAuth(code);
  res.redirect(redirectTo);  // Open redirect — attacker can pass their own URL
});

// CORRECT: Whitelist allowed redirect destinations
const ALLOWED_REDIRECTS = new Set([
  '/dashboard',
  '/profile',
  '/settings',
]);

function safeRedirect(destination: string): string {
  // Only allow relative paths from the whitelist
  if (ALLOWED_REDIRECTS.has(destination)) return destination;
  return '/dashboard';  // Default safe destination
}

app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;
  const redirectTo = req.query.redirect_to as string;

  await handleAuth(code);
  res.redirect(safeRedirect(redirectTo));  // Validated destination
});

If a dynamic redirect is needed (e.g., redirect back to the page the user was on before logging in), store the destination in the server-side session before initiating the OAuth flow — not as a URL parameter:

// Store the intended destination in session before redirecting to OAuth
function initiateOAuthWithReturn(req: Request, res: Response) {
  const returnTo = req.query.return_to as string;
  const state = randomBytes(32).toString('hex');

  req.session.oauthState = state;
  req.session.returnTo = isRelativePath(returnTo) ? returnTo : '/dashboard';

  // Redirect to OAuth provider...
}

// In the callback, use the session-stored destination
function handleOAuthCallback(req: Request, res: Response) {
  // Verify state...
  const returnTo = req.session.returnTo ?? '/dashboard';
  delete req.session.returnTo;

  res.redirect(returnTo);
}

Common patterns that look safe but are not

Pattern: Checking hd (hosted domain) for Google Workspace accounts. Some implementations check the hd claim in the Google ID token to restrict access to users from a specific Google Workspace organization. This check is correct, but it must be done after verifying the token signature — not by decoding the payload without verification.

Pattern: Using access_token as a user identifier. The access token is opaque to the application; its value is meaningless as a user identifier. The user identifier should come from a verified ID token or from the /userinfo endpoint, not from the access token itself.

Pattern: Re-using the authorization code. Authorization codes are single-use. Attempting to reuse a code should be treated as a security event — it may indicate an attacker is attempting to replay a captured code. OAuth providers reject reused codes, but the application should also detect and log this pattern.

Mistake 7: Not validating the token audience across multiple services

In microservice architectures where multiple services accept the same OAuth tokens, each service must validate that the token was issued for it specifically — not just that the token is valid. A token issued for Service A should not be accepted by Service B.

// WRONG: Only verifying signature, not audience
async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS);
  return payload;  // Does not check payload.aud
}

// CORRECT: Verify audience matches this service
async function verifyToken(token: string, expectedAudience: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    audience: expectedAudience,
    issuer: 'https://accounts.google.com',
  });
  return payload;
}

// Each service specifies its own audience
// Payment service:
const decoded = await verifyToken(token, 'payment-service');
// Order service:
const decoded = await verifyToken(token, 'order-service');

Without audience validation, a token stolen from one service can be replayed against any other service that accepts the same issuer. This is particularly relevant in systems that use a single authorization server across multiple backend services.

Using OAuth libraries correctly

The safest approach to OAuth 2.0 is using a library that implements the full specification. The risks are not in understanding the specification — they are in the implementation details that are easy to overlook.

For Firebase applications, firebase/auth on the client and firebase-admin/auth on the server implement OAuth correctly. For Next.js applications, Auth.js (formerly NextAuth.js) handles the full flow:

// Auth.js (NextAuth) configuration — correct OAuth implementation
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    async session({ session, token }) {
      // Add custom claims to the session
      session.userId = token.sub!;
      return session;
    },
  },
});

// app/api/auth/[...nextauth]/route.ts
export const { GET, POST } = handlers;

Auth.js handles state parameter generation and validation, token verification, CSRF protection, and secure cookie management. The callbacks expose extension points for custom logic without requiring re-implementation of the security mechanisms.

When implementing OAuth manually — for instance, when integrating with an OAuth provider that does not have library support — test each security requirement explicitly:

// Manual OAuth implementation test checklist
describe('OAuth implementation security', () => {
  test('rejects callback without state parameter', async () => {
    const response = await request(app)
      .get('/auth/callback?code=valid-code')
      // No state parameter
      .expect(400);
    expect(response.body.error).toContain('state');
  });

  test('rejects callback with mismatched state', async () => {
    // Initialize a session with a known state
    const agent = request.agent(app);
    await agent.get('/auth/initiate');  // Sets session state

    const response = await agent
      .get('/auth/callback?code=valid-code&state=wrong-state')
      .expect(400);
  });

  test('rejects decoded-but-unverified token', async () => {
    const forgedToken = createForgedToken({ sub: 'victim-user-id' });
    const response = await request(app)
      .get('/api/me')
      .set('Authorization', `Bearer ${forgedToken}`)
      .expect(401);
  });
});

These tests are the minimum verification that the critical security properties hold. They should be part of the test suite for any OAuth implementation and run on every CI build.

OAuth 2.0's specification is over 75 pages long. The mistakes above are not edge cases in the specification — they are clearly specified protections that are frequently omitted in implementations. Using a well-maintained OAuth library (Passport.js for Node, Firebase Auth SDK for Firebase applications) eliminates most of these risks by implementing the specification correctly on behalf of the application. When implementing manually, treat each section of RFC 6749 as a security requirement, not a suggestion.

Comments

No comments yet. Be the first!

Sign in to leave a comment.