Testing Authentication Security: JWT, OAuth2, and Session Management
Authentication is the most critical security boundary in any web application. Broken authentication is consistently in the OWASP Top 10 because the implementation surface is enormous — JWTs, OAuth2 flows, session tokens, password reset flows, MFA. Each component can fail independently. This guide covers how to test each layer systematically.
Key Takeaways
Test JWT validation explicitly. The alg: none attack and RS256-to-HS256 confusion still catch production APIs in 2026. Never trust "we're using JWTs" without verifying the signature check.
OAuth2 state parameter prevents CSRF. If your OAuth flow doesn't validate the state parameter, the login endpoint is vulnerable to CSRF. Test this before launch.
Session fixation requires testing the session ID lifecycle. The ID before login must differ from the ID after login. Many frameworks do this correctly — but verify it.
Password reset tokens expire. A reset token valid for 24 hours is exploitable. Test that tokens are single-use and expire within minutes.
Automate the happy path, not just the attacks. Auth tests that verify login works under normal conditions give you a regression safety net when auth refactors happen.
Why Authentication Testing Is Non-Negotiable
OWASP consistently ranks broken authentication as a top-5 web vulnerability. Unlike injection flaws that require specific code paths, authentication bugs affect every user of your application simultaneously. A single bypassed login check gives an attacker access to all accounts, not just one.
Authentication testing covers:
- Token signing and validation (JWT)
- OAuth2 flow integrity (authorization codes, state parameters)
- Session management (fixation, expiry, invalidation on logout)
- Password reset security (token entropy, expiry, single-use)
- Multi-factor authentication bypass
Testing JWT Validation
The alg: none Attack
JWTs have three parts: header, payload, and signature. The header specifies the signing algorithm. The alg: none attack exploits libraries that trust the header — crafting a token with "alg": "none" and no signature, expecting the server to accept it as valid.
Test:
import base64, json
# Craft a JWT with alg: none
header = base64.urlsafe_b64encode(
json.dumps({"alg": "none", "typ": "JWT"}).encode()
).rstrip(b'=').decode()
payload = base64.urlsafe_b64encode(
json.dumps({"sub": "admin", "role": "admin", "exp": 9999999999}).encode()
).rstrip(b'=').decode()
forged_token = f"{header}.{payload}." # no signature
# Test the forged token
import requests
resp = requests.get(
"https://api.example.com/admin/users",
headers={"Authorization": f"Bearer {forged_token}"}
)
assert resp.status_code == 401, f"VULNERABLE: server accepted alg:none token, got {resp.status_code}"
print("PASS: alg:none rejected")RS256 to HS256 Confusion
Some JWT libraries accept both RSA and HMAC algorithms. An attacker who knows the RS256 public key (often exposed at /jwks.json) can sign a token with HMAC using the public key as the HMAC secret — if the server switches to HS256 verification based on the header's alg claim.
Test:
import jwt, requests
# Fetch the public key
jwks = requests.get("https://api.example.com/.well-known/jwks.json").json()
public_key = jwks["keys"][0]["x5c"][0] # simplified
# Sign a JWT with HS256 using the public key as secret
forged = jwt.encode(
{"sub": "attacker", "role": "admin", "exp": 9999999999},
public_key, # using PUBLIC key as HMAC secret
algorithm="HS256"
)
resp = requests.get(
"https://api.example.com/protected",
headers={"Authorization": f"Bearer {forged}"}
)
assert resp.status_code == 401, f"VULNERABLE: algorithm confusion accepted, got {resp.status_code}"Expiry Validation
import jwt, time, requests
# Create a valid token, then use it after expiry
token = jwt.encode(
{"sub": "user123", "exp": int(time.time()) - 3600}, # expired 1 hour ago
"your-secret-key",
algorithm="HS256"
)
resp = requests.get(
"https://api.example.com/profile",
headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 401, f"VULNERABLE: expired token accepted, got {resp.status_code}"Automated JWT Tests with HelpMeTest
*** Test Cases ***
Expired JWT Is Rejected
${expired_token}= Generate Expired JWT user123
Set Request Header Authorization Bearer ${expired_token}
GET https://api.example.com/profile
Response Status Code Should Be 401
Response Body Should Contain expired
Missing JWT Returns 401
GET https://api.example.com/profile
Response Status Code Should Be 401Testing OAuth2 Flows
State Parameter Validation
The OAuth2 state parameter binds the authorization request to the callback — preventing CSRF attacks where an attacker tricks a user into completing an OAuth flow with the attacker's authorization code.
Test:
import requests, re
session = requests.Session()
# Start OAuth flow — capture the state
auth_start = session.get(
"https://app.example.com/oauth/login",
params={"provider": "github"},
allow_redirects=False
)
location = auth_start.headers["Location"]
state = re.search(r"state=([^&]+)", location).group(1)
# Simulate callback with wrong state
resp = session.get(
"https://app.example.com/oauth/callback",
params={
"code": "valid-auth-code",
"state": "attacker-controlled-state" # wrong state
}
)
assert resp.status_code in [400, 403], \
f"VULNERABLE: wrong state accepted, got {resp.status_code}"Authorization Code Reuse
Authorization codes are single-use. Reusing a code should fail:
import requests
valid_code = "code-obtained-from-oauth-flow"
# First use — should succeed
resp1 = requests.post("https://app.example.com/oauth/token", data={
"code": valid_code,
"grant_type": "authorization_code",
"redirect_uri": "https://app.example.com/callback",
"client_id": "your-client-id"
})
assert resp1.status_code == 200
# Second use — must fail
resp2 = requests.post("https://app.example.com/oauth/token", data={
"code": valid_code,
"grant_type": "authorization_code",
"redirect_uri": "https://app.example.com/callback",
"client_id": "your-client-id"
})
assert resp2.status_code in [400, 401], \
f"VULNERABLE: authorization code reuse accepted, got {resp2.status_code}"Token Scope Enforcement
# Get a token with read-only scope
token = get_oauth_token(scope="read:profile")
# Attempt write operation with read-only token
resp = requests.post(
"https://api.example.com/profile",
headers={"Authorization": f"Bearer {token}"},
json={"name": "Hacker"}
)
assert resp.status_code == 403, \
f"VULNERABLE: write with read-only scope accepted, got {resp.status_code}"Testing Session Management
Session Fixation
Session fixation attacks occur when an application accepts a session ID set by the attacker before login, and continues using the same ID after authentication. If the post-login session ID matches the pre-login session ID, the attacker who knows the pre-login ID can hijack the session.
Test:
import requests
session = requests.Session()
# Get a session ID before login
session.get("https://app.example.com/login")
pre_login_session_id = session.cookies.get("session_id")
# Log in
session.post("https://app.example.com/login", data={
"username": "testuser",
"password": "TestPassword123"
})
post_login_session_id = session.cookies.get("session_id")
assert pre_login_session_id != post_login_session_id, \
"VULNERABLE: session ID unchanged after login (session fixation)"
print(f"PASS: session ID rotated on login")Session Invalidation on Logout
import requests
session = requests.Session()
# Log in
session.post("https://app.example.com/login", data={
"username": "testuser",
"password": "TestPassword123"
})
session_cookie = dict(session.cookies)
# Verify authenticated access
protected = session.get("https://app.example.com/dashboard")
assert protected.status_code == 200
# Log out
session.post("https://app.example.com/logout")
# Attempt to reuse the old session cookie
replay_session = requests.Session()
replay_session.cookies.update(session_cookie)
after_logout = replay_session.get("https://app.example.com/dashboard")
assert after_logout.status_code in [302, 401, 403], \
f"VULNERABLE: session cookie valid after logout, got {after_logout.status_code}"Session Timeout
import requests, time
session = requests.Session()
session.post("https://app.example.com/login", data={
"username": "testuser",
"password": "TestPassword123"
})
# Wait for idle timeout (test in fast-expiry test environment)
time.sleep(1800) # 30 minutes
resp = session.get("https://app.example.com/dashboard")
assert resp.url.startswith("https://app.example.com/login"), \
"VULNERABLE: session not expired after idle timeout"Testing Password Reset Flows
Token Entropy
Password reset tokens must be cryptographically random — not predictable sequences or encoded timestamps:
import requests, re, math
def get_reset_token(email):
"""Trigger password reset and extract token from the response or email service."""
resp = requests.post("https://app.example.com/forgot-password", data={"email": email})
# In test environments, the token may be returned in the response or a test email inbox
return resp.json().get("token")
tokens = [get_reset_token(f"test+{i}@example.com") for i in range(5)]
# Check tokens are unique
assert len(set(tokens)) == len(tokens), "FAIL: duplicate reset tokens"
# Measure character set size and entropy
def check_entropy(token):
charset = set(token)
bits_per_char = math.log2(len(charset))
entropy = bits_per_char * len(token)
return entropy
for token in tokens:
entropy = check_entropy(token)
assert entropy >= 64, f"FAIL: low entropy token detected ({entropy:.1f} bits): {token}"
print("PASS: password reset tokens have sufficient entropy")Token Single-Use
token = get_reset_token("test@example.com")
# Use the token
resp1 = requests.post("https://app.example.com/reset-password", json={
"token": token,
"new_password": "NewSecurePassword123!"
})
assert resp1.status_code == 200
# Reuse the token — must fail
resp2 = requests.post("https://app.example.com/reset-password", json={
"token": token,
"new_password": "AnotherPassword456!"
})
assert resp2.status_code in [400, 410], \
f"VULNERABLE: reset token reusable, got {resp2.status_code}"Token Expiry
# In a test environment configured with short token TTL
import time
token = get_reset_token("test@example.com")
# Wait for token to expire (e.g., 5-minute TTL in test env)
time.sleep(300)
resp = requests.post("https://app.example.com/reset-password", json={
"token": token,
"new_password": "NewPassword123!"
})
assert resp.status_code in [400, 410], \
f"VULNERABLE: expired reset token accepted, got {resp.status_code}"Automated End-to-End Auth Tests
The code examples above are great for unit-level security testing. For end-to-end validation of the complete login flow including UI interactions, HelpMeTest lets you write tests in plain English that run in a real browser:
*** Test Cases ***
Login Flow Works End-to-End
Go To https://app.example.com/login
Input Text id=email testuser@example.com
Input Text id=password TestPassword123
Click Button id=login-btn
Wait Until Page Contains Dashboard
Current URL Should Contain /dashboard
Logout Invalidates Session
As AuthenticatedUser
Go To https://app.example.com/dashboard
Page Should Contain Dashboard
Click Element id=logout-btn
Go To https://app.example.com/dashboard
Current URL Should Contain /login
Failed Login Shows Error
Go To https://app.example.com/login
Input Text id=email testuser@example.com
Input Text id=password WrongPassword!
Click Button id=login-btn
Page Should Contain Invalid credentials
Current URL Should Not Contain /dashboard
Account Locked After Failed Attempts
FOR ${i} IN RANGE 5
Go To https://app.example.com/login
Input Text id=email target@example.com
Input Text id=password WrongPassword
Click Button id=login-btn
END
Page Should Contain Account lockedThese tests run on every deployment — catching auth regressions before they reach production.
Testing MFA
Multi-factor authentication adds a second verification step. Test that it's actually enforced:
import requests, pyotp
# Log in with correct username and password
session = requests.Session()
resp = session.post("https://app.example.com/login", json={
"username": "mfa-enabled-user",
"password": "CorrectPassword123"
})
# After password verification, should require MFA
assert resp.json().get("requires_mfa"), "FAIL: MFA not prompted"
assert resp.json().get("access_token") is None, "VULNERABLE: access token issued before MFA"
# Complete MFA with valid TOTP
totp = pyotp.TOTP("USER_MFA_SECRET")
resp2 = session.post("https://app.example.com/mfa/verify", json={
"code": totp.now()
})
assert resp2.status_code == 200
assert resp2.json().get("access_token") is not None
# Test MFA bypass attempts
resp3 = session.post("https://app.example.com/mfa/verify", json={
"code": "000000" # wrong code
})
assert resp3.status_code == 401, "VULNERABLE: wrong MFA code accepted"Building an Auth Test Suite
A complete auth test suite covers these areas:
| Category | Tests |
|---|---|
| JWT | alg:none, algorithm confusion, expiry, tampered claims |
| OAuth2 | state validation, code reuse, scope enforcement, PKCE |
| Sessions | fixation, logout invalidation, timeout, concurrent sessions |
| Password reset | entropy, single-use, expiry, enumeration |
| Brute force | rate limiting, lockout, captcha bypass |
| MFA | bypass attempts, backup code entropy, replay attack |
Run this suite:
- In CI against every PR — catches regressions immediately
- After auth refactors — login systems change and break in non-obvious ways
- On a schedule — monthly automated auth pen test keeps you honest
Conclusion
Authentication bugs are high-impact because they affect every user and every data boundary in your application. The good news is that most authentication vulnerabilities are testable — they follow known patterns, and automated tests can verify the security invariants hold on every build.
Start with the highest-risk items: JWT validation, OAuth2 state parameters, and session invalidation on logout. These are the three most commonly broken in production applications. Add password reset testing next. Then build up your full auth test suite over time, running it in CI on every push.