Testing Authentication Security: JWT, OAuth2, and Session Management

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  401

Testing 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 locked

These 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:

  1. In CI against every PR — catches regressions immediately
  2. After auth refactors — login systems change and break in non-obvious ways
  3. 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.

Read more