JWT & Session Attacks
Why This Matters for Our Stack
Our entire auth layer is Supabase Auth which issues JWTs. Every auth decision in our apps ultimately trusts a JWT. If the JWT verification is wrong, everything downstream is compromised.
Attack 1: Algorithm Confusion
alg:none Attack
JWT header specifies "alg": "none". If the verifier doesn't enforce algorithm, it accepts unsigned tokens.
// Attacker forges token:
{"alg": "none", "typ": "JWT"}
.
{"sub": "admin-user-id", "role": "service_role"}
.
(empty signature)
Detection in code review:
- Does JWT verification explicitly specify allowed algorithms?
- Is there a
algorithmsparameter in the verify call?
// VULNERABLE — no algorithm restriction
jwt.verify(token, secret)
// SAFE — algorithm explicitly specified
jwt.verify(token, secret, { algorithms: ['HS256'] })
RS256 → HS256 Key Confusion
If the server uses RS256 (asymmetric), the attacker:
- Obtains the public key (often available via JWKS endpoint)
- Signs a token with HS256 using the public key as the HMAC secret
- If the verifier doesn't enforce the algorithm, it verifies the HS256 signature using the public key (which it now treats as an HMAC secret)
Detection: Does the verifier enforce that RS256 tokens must be verified with RS256?
JWKS Confusion / JKU Injection
JWT header can contain jku (JWK Set URL) or jwk (embedded key):
{"alg": "RS256", "jku": "https://attacker.com/.well-known/jwks.json"}
If the verifier fetches keys from the JKU URL without validating it against a whitelist, the attacker provides their own signing key.
Detection:
- Does the verifier trust
jku/jwkheaders from the token itself? - Is the JWKS URL hardcoded or configurable only via environment?
- Is there a JWKS URL whitelist?
Attack 2: Claim Manipulation
Supabase-Specific: getSession() vs getUser()
This is the #1 auth bug in Supabase apps.
// VULNERABLE — reads JWT claims without verification
const { data: { session } } = await supabase.auth.getSession()
const userId = session?.user?.id // Trusts unverified JWT
// SAFE — verifies token against Supabase Auth server
const { data: { user } } = await supabase.auth.getUser()
const userId = user?.id // Server-verified identity
getSession() reads from the local JWT without hitting the auth server. An attacker who can tamper with the JWT cookie can forge claims.
getUser() makes an API call to verify the token against Supabase Auth.
Rule: ALWAYS use getUser() for server-side auth decisions. getSession() is acceptable only for client-side UI hints (showing username, etc.) where security isn't at stake.
Custom Claims Injection
If the application stores role or permissions in JWT custom claims and doesn't re-verify them server-side:
{
"sub": "user-123",
"role": "user",
"custom_claims": {"is_admin": true} // Attacker adds this
}
Detection:
- Are authorization decisions based on JWT claims alone?
- Are custom claims verified against the database on sensitive operations?
- Does the application trust
rolefrom the JWT for RLS policies?
JWT ID (jti) and Expiration Bypass
- Missing
expcheck → token never expires - Missing
jtiuniqueness → token replay - Missing
nbf(not-before) check → future-dated tokens accepted early - Missing
iss(issuer) check → tokens from other Supabase projects accepted
Attack 3: Token Storage & Transmission
Client-Side Storage Risks
| Storage | XSS Accessible | CSRF Risk | Recommendation |
|---|---|---|---|
| localStorage | ✅ Yes | ❌ No | ❌ Avoid for auth tokens |
| sessionStorage | ✅ Yes | ❌ No | ❌ Avoid for auth tokens |
| Cookie (no HttpOnly) | ✅ Yes | ✅ Yes | ❌ Worst option |
| Cookie (HttpOnly, Secure, SameSite=Lax) | ❌ No | ⚠️ Limited | ✅ Best option |
| Memory only | ❌ No (unless XSS dumps) | ❌ No | ✅ Most secure, worst UX |
Supabase Auth stores tokens in cookies by default with @supabase/ssr. Verify:
-
HttpOnlyflag set -
Secureflag set (HTTPS only) -
SameSite=Lax(minimum) - Cookie path is specific, not
/
Token in URL
https://app.com/callback?access_token=eyJhbG...
Tokens in URLs leak via:
- Browser history
- Referrer headers
- Server logs
- Shared links
Detection: Any route that reads tokens from query parameters.
Attack 4: OAuth Flow Vulnerabilities
CSRF on OAuth Callback
If the state parameter is missing or not validated:
- Attacker initiates OAuth flow
- Gets callback URL with attacker's auth code
- Tricks victim into visiting the callback URL
- Victim's session is now linked to attacker's account
Detection: Is state parameter generated, stored, and verified on callback?
Authorization Code Injection
Attacker intercepts or generates an authorization code and replays it to a different client.
Mitigation: PKCE (Proof Key for Code Exchange) — required for public clients.
Detection: Is PKCE used? Supabase Auth supports PKCE — verify it's enabled:
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
queryParams: { access_type: 'offline', prompt: 'consent' },
// PKCE should be default in @supabase/ssr
}
})
Open Redirect via OAuth
If the redirect URL after OAuth is user-controllable:
/auth/callback?redirect_to=https://evil.com
Detection:
- Is
redirect_to/nextparameter validated against an allowlist? - Can an attacker control where the user lands after auth?
Attack 5: Timing Attacks on Authentication
String Comparison Timing
// VULNERABLE — early exit on first byte mismatch
if (providedToken === storedToken) { /* grant access */ }
// SAFE — constant-time comparison
import { timingSafeEqual } from 'crypto'
const isValid = timingSafeEqual(
Buffer.from(providedToken),
Buffer.from(storedToken)
)
Standard === comparison returns faster when the first byte doesn't match. Attacker measures response times to brute-force tokens byte by byte.
Username Enumeration via Timing
// VULNERABLE — different code paths for existing vs non-existing users
if (!user) return { error: 'User not found' } // Fast path
const valid = await bcrypt.compare(password, user.hash) // Slow path
Timing difference reveals whether the username exists.
Fix:
// Always run the same code path
const user = await getUser(email)
const hash = user?.hash || DUMMY_HASH // Always compare against something
const valid = await bcrypt.compare(password, hash)
if (!user || !valid) return { error: 'Invalid credentials' }
Bcrypt Truncation
bcrypt silently truncates passwords at 72 bytes. Two passwords sharing the same first 72 bytes hash identically.
- Most users won't hit this, but API keys and tokens can
- Detection: Is bcrypt used for hashing long secrets (>72 chars)? Use SHA-256 pre-hash.
Supabase Auth Review Checklist
Server-Side (API routes, Server Actions, middleware)
- Uses
getUser()notgetSession()for auth decisions - Validates JWT claims against database for sensitive operations
- Rate limits login/signup endpoints
- Validates
redirect_toURLs against allowlist - Uses PKCE for OAuth flows
- Service role key never exposed to client
Client-Side
- Tokens stored in HttpOnly cookies (via @supabase/ssr)
- No tokens in localStorage or URL parameters
- Token refresh handled correctly (not exposing refresh token)
- Logout clears all token storage
RLS Integration
- RLS policies use
auth.uid()not client-provided user IDs - RLS policies don't trust JWT claims for authorization without verification
- Service role operations are server-only and never client-triggered
References
For OAuth/OIDC vulnerability catalog, see references/oauth-vulnerabilities.md.
For Supabase-specific auth patterns, see the auth-patterns skill.