Testing OAuth2 and OIDC: Catch the Auth Vulnerabilities That Can Expose All Your Users
OAuth2 is the foundation of modern web authentication. It powers "Sign in with Google," "Continue with GitHub," enterprise SSO, and API authorization for millions of applications. OpenID Connect (OIDC) extends OAuth2 with identity assertions — telling your application not just that a user is authorized, but specifically who they are.
Implementing these correctly is genuinely difficult. The OAuth2 specification has enough flexibility to allow dangerous implementations that technically comply with the spec while being completely insecure. The OIDC specification adds another layer of complexity around token validation and claims verification.
The good news: many of the most critical OAuth and OIDC vulnerabilities are testable. This guide walks through systematic security testing for OAuth flows in modern web applications.
The Top OAuth2/OIDC Vulnerabilities to Test For
Before writing tests, know what you are looking for. These are the most commonly exploited OAuth implementation flaws:
mindmap
root((OAuth Security Flaws))
Authorization Code Interception
Missing state parameter CSRF
Open redirect in redirect_uri
Code leakage in referrer headers
Token Security
Missing PKCE enforcement
Access token in URL fragment
Overly broad token scopes
Redirect Validation
Partial redirect_uri matching
Wildcard subdomain acceptance
Path traversal in redirect
Session Management
Missing nonce validation in OIDC
Stale token acceptance after logout
Insufficient token expiry
Test 1: CSRF Protection via state Parameter
The state parameter in OAuth2 requests is your primary CSRF defense. It must be:
- Generated randomly for each authorization request
- Stored server-side (or in a signed cookie)
- Verified on callback before exchanging the authorization code
// tests/security/oauth-csrf.spec.ts
import { test, expect } from '@playwright/test';
test('OAuth callback with wrong state parameter is rejected', async ({ page, request }) => {
// Initiate an OAuth flow to get a legitimate callback URL
await page.goto('/auth/login');
await page.getByRole('button', { name: 'Continue with Google' }).click();
// Intercept the OAuth callback URL that would come from the provider
// We simulate a callback with a tampered `state` parameter
const callbackWithWrongState = `/auth/callback?code=legit-code&state=attacker-controlled-state`;
const response = await request.get(callbackWithWrongState);
// Must NOT complete authentication with wrong state
expect(response.status()).not.toBe(200);
// Should redirect to error page or login page
const finalUrl = response.url();
expect(finalUrl).toMatch(/\/error|\/login/);
});
test('OAuth callback without state parameter is rejected', async ({ page, request }) => {
const callbackWithoutState = `/auth/callback?code=legitimate-auth-code`;
const response = await request.get(callbackWithoutState);
expect(response.status()).not.toBe(200);
});
Test 2: Open Redirect Prevention in redirect_uri
If your OAuth server accepts external redirect_uri values insecurely, an attacker can craft an authorization URL that redirects to their server, capturing the authorization code:
https://yourapp.com/oauth/authorize?
client_id=your-client&
redirect_uri=https://evil.com/steal-code ← attacker-controlled
Test that your auth server rejects unregistered redirect URIs:
test('auth server rejects unregistered redirect_uri', async ({ request }) => {
const unauthorizedRedirects = [
'https://evil.com/callback',
'https://yourapp.com.evil.com/callback', // homograph attack
'https://yourapp.com@evil.com/callback', // user info confusion
'javascript:alert(1)', // Javascript scheme
'//evil.com/callback', // Protocol-relative
];
for (const redirectUri of unauthorizedRedirects) {
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.OAUTH_CLIENT_ID!,
redirect_uri: redirectUri,
state: 'test-state',
});
const response = await request.get(`/api/auth/authorize?${params.toString()}`, { maxRedirects: 0 });
// Should return 400 Bad Request, not redirect
expect(response.status()).toBe(400);
// Must not contain the malicious URI in response
const body = await response.text();
expect(body).not.toContain('evil.com');
}
});
Test 3: PKCE Enforcement for Public Clients
PKCE (Proof Key for Code Exchange) was added to OAuth2 to protect authorization code flows for public clients (SPAs, mobile apps). Without PKCE, a stolen authorization code can be exchanged for tokens by an attacker.
As of 2023, PKCE is recommended for ALL OAuth flows, not just public clients. Test that your server enforces it:
test('authorization code exchange without PKCE is rejected', async ({ request }) => {
// Attempt to exchange an authorization code without providing code_verifier
const response = await request.post('/api/auth/token', {
form: {
grant_type: 'authorization_code',
code: process.env.TEST_AUTH_CODE!,
redirect_uri: `${process.env.BASE_URL}/auth/callback`,
client_id: process.env.OAUTH_CLIENT_ID!,
// Deliberately omitting code_verifier
},
});
// PKCE-enforcing servers should reject this
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toMatch(/code_verifier|pkce/i);
});
Test 4: OIDC ID Token Validation
When using OIDC, your application receives an ID token (JWT) that asserts the user's identity. Your application must validate critical claims:
| Claim | Validation Required |
|---|---|
iss (issuer) |
Must match expected identity provider |
aud (audience) |
Must match your client_id |
exp (expiration) |
Must not be in the past |
nonce |
Must match nonce sent in auth request |
iat (issued at) |
Should not be too far in the past |
test('application refuses ID tokens with mismatched audience', async ({ request }) => {
// Create a JWT with wrong `aud` claim (tampered token)
// In production tests, this would use a test provider that issues tokens for different audiences
const tamperedTokenPayload = {
sub: 'user-123',
iss: 'https://accounts.google.com',
aud: 'different-client-id', // Wrong audience
exp: Math.floor(Date.now() / 1000) + 3600,
nonce: 'test-nonce',
};
const response = await request.post('/api/auth/verify-token', {
data: { idToken: createTestJWT(tamperedTokenPayload) },
});
expect(response.status()).toBe(401);
const body = await response.json();
expect(body.error).toContain('audience');
});
test('application refuses expired ID tokens', async ({ request }) => {
const expiredTokenPayload = {
sub: 'user-123',
iss: 'https://accounts.google.com',
aud: process.env.OAUTH_CLIENT_ID,
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
nonce: 'test-nonce',
};
const response = await request.post('/api/auth/verify-token', {
data: { idToken: createTestJWT(expiredTokenPayload) },
});
expect(response.status()).toBe(401);
});
Test 5: Session Invalidation on Logout
OAuth sessions should be invalidated when a user logs out. Tokens should not remain valid after logout.
test('access tokens are invalidated after logout', async ({ page, request }) => {
// Login and capture the access token
await page.goto('/login');
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!);
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!);
await page.getByRole('button', { name: 'Sign In' }).click();
// Capture the auth token from localStorage or cookie
const accessToken = await page.evaluate(
() => localStorage.getItem('access_token') || document.cookie.match(/access_token=([^;]+)/)?.[1],
);
expect(accessToken).toBeTruthy();
// Logout
await page.getByRole('button', { name: 'Log Out' }).click();
await page.waitForURL('/login');
// Verify the old token no longer works
const apiResponse = await request.get('/api/user/profile', {
headers: { Authorization: `Bearer ${accessToken}` },
});
expect(apiResponse.status()).toBe(401);
});
Test 6: Token Scope Validation
OAuth tokens should only have the permissions (scopes) necessary for their intended use. Verify that your API enforces scope restrictions:
test('read-only token cannot perform write operations', async ({ request }) => {
// Use a token that only has read scope
const readOnlyToken = process.env.TEST_READ_ONLY_TOKEN!;
const createResponse = await request.post('/api/projects', {
headers: { Authorization: `Bearer ${readOnlyToken}` },
data: { name: 'Should Fail' },
});
expect(createResponse.status()).toBe(403);
const body = await createResponse.json();
expect(body.error).toMatch(/insufficient_scope|forbidden/i);
});
End-to-End OAuth Flow Testing
Beyond individual security checks, test the complete SSO flow end-to-end with a test identity provider:
// Using a test OIDC provider (e.g., Keycloak in test mode, or Auth0 test tenant)
test('complete OAuth2 PKCE flow works end-to-end', async ({ page }) => {
await page.goto('/login');
// Intercept the authorization redirect to capture PKCE parameters
let authRequestUrl: URL | null = null;
page.on('request', (req) => {
if (req.url().includes('/oauth/authorize')) {
authRequestUrl = new URL(req.url());
}
});
await page.getByRole('button', { name: 'Continue with Google' }).click();
// Verify PKCE parameters are present in the auth request
await page.waitForURL(/accounts\.google\.com|localhost:8080/);
if (authRequestUrl) {
expect(authRequestUrl.searchParams.get('code_challenge')).toBeTruthy();
expect(authRequestUrl.searchParams.get('code_challenge_method')).toBe('S256');
expect(authRequestUrl.searchParams.get('state')).toBeTruthy();
}
});
The OAuth Security Testing Checklist
| Check | Test Approach | Priority |
|---|---|---|
state parameter validated |
Direct callback with wrong state | 🔴 Critical |
redirect_uri whitelist enforced |
Request with unregistered URI | 🔴 Critical |
| PKCE enforced for public clients | Token exchange without code_verifier | 🔴 Critical |
ID token aud validated |
Present token for wrong client | 🟠 High |
ID token exp validated |
Present expired token | 🟠 High |
| Tokens invalidated on logout | Use old token after logout | 🟠 High |
| Scope restrictions enforced | Low-scope token attempts write | 🟡 Medium |
nonce validated in OIDC |
Replay old nonce | 🟡 Medium |
Related articles: Also see common auth and login flaws to test for alongside OAuth issues, automating MFA and 2FA flows that complement OAuth authentication, and the broader web security testing context for OAuth/OIDC work.
Why Authentication Testing Needs Production Monitoring
Your OAuth flows work correctly today. But dependencies on external identity providers, token rotation policies, and configuration drift can introduce authentication breakages that only appear in production. A misconfigured aud validation after a client ID change, or a broken callback route after a domain update, can lock every user out of your application.
This is why continuous scanning of your authentication flows — not just pre-deploy testing — matters. ScanlyApp can monitor your login flows on a schedule, verifying that users can successfully authenticate and reach protected resources after every deploy.
For more on securing your broader authentication surface, see our website login and auth flows analysis.
Further Reading
- RFC 6749 — The OAuth 2.0 Authorization Framework: The IETF specification defining the OAuth 2.0 authorization flows
- OpenID Connect Core 1.0 Specification: The full OIDC protocol specification including ID token validation rules
- RFC 7636 — PKCE for OAuth Public Clients: The specification for Proof Key for Code Exchange, required to prevent authorization code interception
- OWASP ASVS V2 — Authentication Requirements: OWASP's Application Security Verification Standard for authentication and session management controls
Monitor your OAuth flows in production: Try ScanlyApp free — schedule scans that validate your authentication flows are working correctly after every deployment.
