Security Testing Auth Flows: PKCE, State Parameter, and Token Leakage
OAuth security testing isn't about checking if login works. It's about verifying your implementation can't be abused to steal tokens, impersonate users, or hijack sessions. These are the attacks that happen to real applications, and every one of them is testable.
This guide covers security testing for PKCE, state parameter validation, token leakage vectors, and authorization code interception—with test code for each attack scenario.
Why Auth Security Testing Matters
Every OAuth security vulnerability has been seen in production:
- Missing state validation → CSRF attacks that log users into attacker-controlled accounts
- Missing PKCE → authorization code interception by malicious apps
- Token in URL fragment → tokens in server logs, referrer headers, browser history
- Open redirects → phishing with legitimate-looking OAuth URLs
- Token leakage via referrer → tokens sent to analytics services or third-party scripts
None of these are theoretical. All of them are testable before they happen.
Testing PKCE (Proof Key for Code Exchange)
PKCE prevents authorization code interception. In public clients (SPAs, mobile apps), there's no client secret—so an attacker who intercepts the authorization code can exchange it for tokens. PKCE adds a code_verifier/code_challenge pair that proves the same party that started the flow is completing it.
How PKCE Works
1. Client generates: code_verifier (random, 43-128 chars)
2. Client computes: code_challenge = BASE64URL(SHA256(code_verifier))
3. Authorization request includes: code_challenge + code_challenge_method=S256
4. Token request includes: code_verifier
5. Server verifies: SHA256(code_verifier) == code_challengeIf an attacker intercepts the code, they don't have the code_verifier—so the exchange fails.
Testing PKCE Validation
import hashlib
import base64
import secrets
def generate_pkce():
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).rstrip(b"=").decode()
return code_verifier, code_challenge
def test_pkce_happy_path():
code_verifier, code_challenge = generate_pkce()
# Authorization request with code_challenge
code = get_authorization_code(
code_challenge=code_challenge,
code_challenge_method="S256"
)
# Token request with code_verifier
response = exchange_code(code, code_verifier=code_verifier)
assert response.status_code == 200
assert "access_token" in response.json()
def test_pkce_wrong_verifier_rejected():
code_verifier, code_challenge = generate_pkce()
wrong_verifier, _ = generate_pkce() # Different verifier
code = get_authorization_code(
code_challenge=code_challenge,
code_challenge_method="S256"
)
# Send wrong verifier—server must reject
response = exchange_code(code, code_verifier=wrong_verifier)
assert response.status_code == 400
assert response.json()["error"] == "invalid_grant"
def test_pkce_missing_verifier_rejected():
_, code_challenge = generate_pkce()
code = get_authorization_code(
code_challenge=code_challenge,
code_challenge_method="S256"
)
# No verifier in token request
response = exchange_code(code, code_verifier=None)
assert response.status_code == 400
def test_pkce_plain_method_rejected():
# S256 is required; plain is deprecated and weaker
# If your server is configured to reject plain, test it
code_verifier = secrets.token_urlsafe(32)
response = get_authorization_code(
code_challenge=code_verifier, # plain: challenge == verifier
code_challenge_method="plain"
)
# Server should reject if plain is disabled
if plain_pkce_disabled():
assert response.status_code in [400, 302] # Error or redirect with errorTesting PKCE Requirement for Public Clients
Public clients (SPAs, mobile apps) should require PKCE. Test that flows without PKCE are rejected:
def test_public_client_requires_pkce():
# Public client (no client_secret)—PKCE should be required
response = get_authorization_code_without_pkce(
client_id="public-client"
)
# Server should require PKCE for public clients
assert_pkce_required(response)
def test_authorization_code_interception_prevented():
code_verifier, code_challenge = generate_pkce()
# Legitimate client starts flow with PKCE
code = get_authorization_code(
client_id="public-client",
code_challenge=code_challenge,
code_challenge_method="S256"
)
# Attacker intercepts the code—but doesn't have code_verifier
attacker_response = exchange_code(code, code_verifier=None)
assert attacker_response.status_code == 400
# With wrong verifier
_, attacker_challenge = generate_pkce()
attacker_response2 = exchange_code(code, code_verifier=attacker_challenge)
assert attacker_response2.status_code == 400Testing State Parameter (CSRF Protection)
The state parameter prevents CSRF attacks in OAuth flows. An attacker can trick a user's browser into starting an OAuth flow, and then inject the authorization response into the victim's session. State prevents this.
The Attack Scenario
- Attacker starts OAuth flow, captures the authorization code
- Attacker tricks victim into visiting:
/callback?code=attacker-code&state=forged-state - If the app doesn't validate state, the victim gets logged in as the attacker
def test_state_parameter_validated():
# Start a legitimate flow with state
session = create_session()
state = "random-state-12345"
store_state(session, state)
# Simulate callback with different state
response = client.get(
f"/callback?code=valid-code&state=different-state",
cookies=session.cookies
)
# Must reject—state doesn't match
assert response.status_code in [400, 403]
assert not is_authenticated(session)
def test_missing_state_in_callback_rejected():
session = create_session()
state = "random-state-12345"
store_state(session, state)
# No state in callback at all
response = client.get(
"/callback?code=valid-code",
cookies=session.cookies
)
assert response.status_code in [400, 403]
def test_empty_state_rejected():
session = create_session()
state = "random-state-12345"
store_state(session, state)
response = client.get(
"/callback?code=valid-code&state=",
cookies=session.cookies
)
assert response.status_code in [400, 403]
def test_state_is_random_and_unpredictable():
# State should be cryptographically random
states = []
for _ in range(10):
session = create_session()
auth_url = start_oauth_flow(session)
state = extract_state_from_url(auth_url)
states.append(state)
# All states should be unique
assert len(set(states)) == len(states)
# States should be long enough to be unpredictable (at least 16 bytes)
for state in states:
assert len(state) >= 22 # Base64url(16 bytes) = 22 chars minimum
def test_csrf_attack_fails():
# Attacker sets up their own OAuth flow
attacker_session = create_session()
attacker_auth_url = start_oauth_flow(attacker_session)
attacker_code = get_code_for_attacker(attacker_auth_url)
attacker_state = extract_state_from_url(attacker_auth_url)
# Victim has their own session (different state)
victim_session = create_session()
victim_auth_url = start_oauth_flow(victim_session)
victim_state = extract_state_from_url(victim_auth_url)
assert attacker_state != victim_state # Different sessions, different states
# Attacker tries to inject their code into victim's callback
response = client.get(
f"/callback?code={attacker_code}&state={attacker_state}",
cookies=victim_session.cookies # Victim's session
)
# State doesn't match victim's session—rejected
assert response.status_code in [400, 403]
assert not is_authenticated(victim_session)Testing Token Leakage
Tokens can leak through referrer headers, server logs, browser history, and analytics scripts. Test each vector.
Referrer Header Leakage
If the access token appears in a URL (e.g., /dashboard?token=...), the browser sends it in the Referer header to any third-party resource on that page.
def test_token_not_in_url_after_login(browser):
browser.login("user", "password")
# Token must not appear in any URL after login
current_url = browser.current_url
assert "access_token" not in current_url
assert "token=" not in current_url
assert "id_token" not in current_url
def test_callback_redirects_to_clean_url(browser):
# OAuth callback URL contains the code
# After processing, the app should redirect to a clean URL
browser.login("user", "password")
# After login, URL should not contain OAuth parameters
final_url = browser.current_url
assert "code=" not in final_url
assert "state=" not in final_url
assert "session_state=" not in final_url
def test_referrer_policy_set():
response = requests.get("http://localhost:3000/dashboard")
# Referrer-Policy should prevent token leakage
referrer_policy = response.headers.get("Referrer-Policy", "")
# Any of these prevent full URL leakage
safe_policies = [
"no-referrer",
"same-origin",
"strict-origin",
"strict-origin-when-cross-origin"
]
assert any(policy in referrer_policy for policy in safe_policies), \
f"Unsafe Referrer-Policy: {referrer_policy}"Token Logging
def test_access_token_not_in_logs():
tokens = authenticate("user", "password")
access_token = tokens["access_token"]
# Make several authenticated requests
for _ in range(5):
client.get("/api/data", headers=auth(access_token))
# Check logs
log_content = read_application_logs()
assert access_token not in log_content, \
"Access token found in application logs"
def test_refresh_token_not_in_logs():
tokens = authenticate("user", "password")
refresh_token = tokens["refresh_token"]
# Use the refresh token
use_refresh_token(refresh_token)
log_content = read_application_logs()
assert refresh_token not in log_content, \
"Refresh token found in application logs"
def test_authorization_code_not_in_logs():
# The callback endpoint receives the code—make sure it's not logged
with capture_logs() as log_stream:
complete_oauth_flow("user", "password")
log_content = log_stream.getvalue()
# Code should be short-lived but still shouldn't appear in logs
# Check that no JWT-like strings appear in logs
assert not re.search(r'[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}', log_content), \
"JWT-format string found in logs"Fragment Leakage (Implicit Flow Artifacts)
def test_spa_does_not_store_token_in_url_fragment(browser):
# Some SPAs briefly put the token in the URL hash
# It must not persist or be accessible to scripts
browser.login("user", "password")
# After login redirect processing, fragment should be cleared
current_url = browser.current_url
assert "#" not in current_url or "access_token" not in current_url
def test_token_not_in_browser_history(browser):
browser.login("user", "password")
# Navigate away and back
browser.goto("http://example.com")
browser.go_back()
# History should not expose tokens
history = browser.get_history()
for entry in history:
assert "access_token" not in entry.get("url", "")Testing Open Redirect Prevention
OAuth redirect URIs must be exactly registered. Open redirects allow attackers to steal authorization codes.
def test_exact_redirect_uri_required():
# Registered URI: https://app.example.com/callback
# Must reject anything different
malicious_uris = [
"https://app.example.com.evil.com/callback", # Subdomain attack
"https://app.example.com/callback/../steal", # Path traversal
"https://app.example.com/callback?next=https://evil.com", # Open redirect
"https://evil.com", # Completely different domain
"https://app.example.com/callbacK", # Case variation
"http://app.example.com/callback", # HTTP instead of HTTPS
"https://app.example.com/callback/extra", # Longer path
]
for uri in malicious_uris:
response = requests.get(
"/oauth/authorize",
params={
"client_id": "my-app",
"redirect_uri": uri,
"response_type": "code",
"scope": "openid"
},
allow_redirects=False
)
# Must NOT redirect to the malicious URI
location = response.headers.get("Location", "")
assert not location.startswith(uri), \
f"Server redirected to malicious URI: {uri}"
assert response.status_code in [400, 403], \
f"Expected error for URI {uri}, got {response.status_code}"
def test_redirect_uri_not_in_error_response():
# Some servers echo back the redirect URI in error responses
# Avoid doing this for unregistered URIs (prevents reflected XSS)
response = requests.get(
"/oauth/authorize",
params={
"client_id": "my-app",
"redirect_uri": "https://evil.com/<script>alert(1)</script>",
"response_type": "code"
}
)
assert "<script>" not in response.textTesting Implicit Flow Security (Legacy)
If you have any implicit flow usage, test that tokens aren't leaked:
def test_implicit_flow_tokens_not_in_server_logs():
# In implicit flow, tokens come back in the URL fragment
# Server never sees them—but check middleware doesn't log them either
# (This test depends on your specific setup)
pass
def test_implicit_flow_disabled_for_new_clients():
# Implicit flow is deprecated (OAuth 2.1 removes it)
# New clients should not be able to use it
response = requests.get(
"/oauth/authorize",
params={
"client_id": "new-client",
"redirect_uri": "https://app.example.com/callback",
"response_type": "token", # Implicit
"scope": "openid"
},
allow_redirects=False
)
# Should be rejected for new clients
assert response.status_code in [400, 403] or \
"unsupported_response_type" in response.headers.get("Location", "")Testing Token Binding
def test_access_token_bound_to_client():
# Token issued to client-A can't be used by client-B
# (relevant when tokens carry client-specific claims)
token_a = get_token_for_client("client-A", "user", "password")
token_b = get_token_for_client("client-B", "user", "password")
# client-A's token should work for client-A's resources
assert client_a_resource(token_a["access_token"]).status_code == 200
# client-A's token should NOT work for client-B's resources
result = client_b_resource(token_a["access_token"])
assert result.status_code in [401, 403]Security Testing Checklist
PKCE:
- Correct
code_verifiervalidates - Wrong
code_verifierrejected - Missing
code_verifierrejected plainmethod rejected (if S256-only)- Public clients require PKCE
State Parameter:
- Mismatched state rejected
- Missing state rejected
- State is random (not predictable)
- State is long enough (≥ 16 bytes)
- CSRF attack scenario fails end-to-end
Token Leakage:
- Tokens not in URLs after callback
- Callback redirects to clean URL
Referrer-Policyheader set- Tokens not in application logs
- Tokens not in browser history
Open Redirect:
- Subdomain attacks rejected
- Path traversal rejected
- Different domain rejected
- HTTP vs HTTPS mismatch rejected
Auth security testing is not box-checking. Each test represents a real attack that has happened to real applications. Run them before shipping.