Engineering Journal
Ginexys
Ginexys

The Authentication Security Checklist: What Gets You Breached, What Protects You, and the Tradeoffs Nobody Talks About

2026-06-03

TLDR

Authentication has a well-documented set of requirements derived from RFC 6749, RFC 7636, OWASP ASVS, and NIST SP 800-63B. Most breaches are not novel attacks -- they are the same six failure modes repeated across thousands of applications. This post covers the full checklist, why each requirement exists, what skipping it actually costs, and the real incidents that prove it.


The Six Failure Modes That Cause Most Auth Breaches

Before the checklist: every auth breach in the last decade falls into one of these categories.

  1. Credentials in the wrong place -- URL query strings, log files, git history, localStorage
  2. Tokens that never expire -- JWTs with exp: 2099, refresh tokens with no rotation
  3. Signatures trusted without verification -- the alg: none attack, JWTs decoded but not verified
  4. OAuth flow shortcuts -- no PKCE, no state verification, wildcard redirect_uri
  5. Silent session inheritance -- shared machines, no prompt=select_account, cold-load token reuse
  6. No defense in depth -- no CSP, no rate limiting, no account enumeration protection
Everything else is a variation on these six.

Part 1: Credential Transport

Never put credentials in URLs

The requirement: passwords, tokens, secrets, and API keys must never appear in URL query strings.

URLs are logged everywhere. Browser history. Server access logs. CDN logs. Referer headers sent to third-party analytics. Browser extension APIs that read window.location. Proxy logs. Load balancer logs. If a credential is in a URL, it is in at least four of those places simultaneously.

The correct transport: POST body (Content-Type: application/json) or Authorization: Bearer header. Neither appears in standard access logs.

The Ugly: GitHub 2020 -- OAuth tokens were being written to internal access logs because they appeared in request URLs. Mass rotation required. The failure was not a sophisticated attack. Someone wrote GET /api/resource?token=xxx instead of Authorization: Bearer xxx, and that pattern made it into production.

The Bad: OAuth state parameters that carry credentials, not just nonces. Poll endpoints like GET /auth/poll?secret=xxx. Any authentication flow where secret appears as a query param name.

Tradeoff: POST bodies and headers require slightly more client code than URL construction. The security difference is not "slightly better" -- it is categorical. Credentials in URLs are compromised at rest in logs before any attacker does anything.


TLS everywhere, HSTS preloaded

TLS is table stakes. The non-obvious part is HSTS.

A Strict-Transport-Security: max-age=31536000; includeSubDomains header tells browsers to refuse HTTP connections to your domain for the next year. But the first visit is still vulnerable to SSL stripping -- an attacker on the same network intercepts the first HTTP request before the HSTS header is ever received.

The HSTS preload list (hstspreload.org) embeds your domain directly in browser binaries. There is no first-visit vulnerability. No HTTP request is ever sent.

Tradeoff: Preload is a one-way door. You cannot serve HTTP from that domain afterward. Plan your subdomain structure before submitting. includeSubDomains means every subdomain must also be HTTPS -- a development subdomain on HTTP will break for preloaded users.


Part 2: Password Storage

The only acceptable hash functions

bcrypt (cost ≥ 12), scrypt, Argon2id, PBKDF2 (≥ 600,000 iterations with SHA-256). Nothing else.

The reason is offline brute force. If your database is breached, the attacker takes the hash file offline and attacks it with a GPU cluster. SHA-256 can be computed at 10+ billion attempts per second on a modern GPU. A 12-character alphanumeric password has about 62^12 ≈ 3 × 10^21 combinations. At 10 billion hashes per second, that is 3 × 10^11 seconds. But most users do not use 12-character alphanumeric passwords.

Memory-hard functions (Argon2id, scrypt) force the attacker to use memory bandwidth, not just compute. GPU memory bandwidth is the bottleneck, not CUDA cores. The effective brute force rate drops by 3-4 orders of magnitude.

The Ugly: LinkedIn 2012 -- 6.5 million SHA-1 unsalted hashes leaked. About 90% cracked within days using precomputed rainbow tables. Adobe 2013 -- 153 million accounts, passwords encrypted (not hashed) with 3DES, encryption key also leaked. The entire dataset was effectively plaintext.

The Bad: bcrypt with cost factor 4 (the default in older PHP/Ruby libraries). Fast enough to crack. "We use bcrypt" is not sufficient -- the cost factor matters. bcrypt(password, rounds=4) and bcrypt(password, rounds=12) are both "bcrypt" but one is crackable in minutes and the other takes decades.

Tradeoff: Argon2id at recommended parameters (64MB, 3 iterations) takes 100-300ms per verification. Your login endpoint cannot handle thousands of simultaneous sign-ins on a single server. This is the intended behavior -- it makes online brute force expensive too.


Part 3: Session Management

Sessions must die on the server when you log out

The most common session mistake: logout that only deletes the client-side token without invalidating it server-side.

If you are using JWTs with no server-side state, a "logged out" user's token is still cryptographically valid until exp. An attacker who stole that token before logout can use it for the remainder of its lifetime.

The correct approaches:

Short-TTL JWTs (≤ 15 minutes): Logout revokes the refresh token. The access token expires naturally. The stale window is ≤ 15 minutes. Acceptable for many applications.

Token revocation list: A fast in-memory store (Redis) checked on every request. Logout adds the token to the list. Checked before processing. Adds one cache read per request -- typically < 1ms.

Session store: Server-side session ID instead of JWT claims. Logout deletes the session record. All subsequent requests with that session ID are rejected immediately.

The Ugly: Firebase ID tokens remain valid for one hour after sign-out by default because Firebase does not maintain a revocation list. "Log out" in the Firebase console is cosmetic for that hour. The token the attacker stole is still live.

Tradeoff: Server-side revocation adds infrastructure (Redis, session store) and latency (one lookup per request). The alternative is accepting a stale token window. Size the window to your threat model.


Refresh tokens require rotation and reuse detection

Access tokens expire in minutes to an hour. Refresh tokens are used to get new access tokens and are valid for days or weeks. A stolen refresh token is effectively permanent access.

The two non-negotiable requirements:

Rotation on every use: Every refresh request issues a new refresh token and invalidates the old one. The refresh token is single-use.

Reuse detection: If a refresh token is used twice, the entire session family is revoked. The attacker got there first, the legitimate user's second use detects the theft, and both sessions are killed. Log the event.

The Ugly: Auth0 2020 security advisory -- applications storing refresh tokens in localStorage were compromised via XSS. The stolen refresh token was usable indefinitely. The advisory was to move refresh tokens to HttpOnly cookies. Thousands of applications required migration.

Tradeoff: Rotation breaks concurrent tab refreshes. If two tabs try to refresh simultaneously, one succeeds and the other's token is invalidated (it looks like a replay). Implement a short overlap window (e.g., the old token remains valid for 30 seconds after rotation) or handle the 401 gracefully with a retry.


Part 4: OAuth / PKCE

PKCE is not optional for public clients

Any OAuth client that cannot store a client secret securely -- browser SPA, mobile app, desktop app, VS Code extension -- must use PKCE (RFC 7636). OAuth 2.1 removes the authorization code flow without PKCE entirely for public clients.

The threat: authorization code interception. In the redirect-based OAuth flow, the authorization code appears in the redirect URL. A malicious app registered for the same custom URI scheme (on mobile), a malicious browser extension, or a network-level interceptor can capture that code. Without PKCE, the code alone is enough to get tokens. With PKCE, the code is useless without the verifier that only the legitimate client knows.

The Ugly: Twitter mobile apps (pre-2019) used authorization code flow without PKCE on mobile clients. Malicious apps registered the same custom URI scheme and intercepted codes. The codes were exchanged for tokens without any client secret. Systematic account takeover.

The Bad: Implicit flow (tokens in URL fragment) -- deprecated. SPAs that use the authorization code flow with a client secret embedded in the JavaScript bundle (it is not a secret). Applications that skipped PKCE because "we're not mobile."


state verification prevents CSRF on OAuth callbacks

The state parameter must be a cryptographically random nonce, stored before the authorization request, and verified on return.

Without it: CSRF on the OAuth callback. An attacker tricks the victim's browser into completing an OAuth flow with the attacker's authorization code. The victim's session becomes bound to the attacker's account. The attacker then signs in to the victim's session.

The Ugly: Multiple audit studies from 2012-2015 found that roughly 25% of OAuth implementations in popular PHP frameworks did not validate the state parameter. The CSRF attack was documented in the RFC and routinely exploited.


prompt=select_account on every OAuth initiation

Browsers remember OAuth sessions. Clicking "Sign in with Google" on a machine with an existing Google session silently completes the OAuth flow and signs in as the Google account already in the browser. No credential was entered. No account was chosen.

On a shared machine: the previous user's OAuth session inherits to the next user. On a developer's machine: a demo environment can silently authenticate as the developer when handed to a colleague.

The fix is one parameter: prompt=select_account for Google, prompt=login for GitHub.

The Ugly: Enterprise SSO deployments where prompt is omitted. A developer hands a laptop to a presenter. Presenter clicks "Sign in with Google" to demo the application. Authenticates as the developer silently. No one notices until the developer sees account activity they did not initiate.

Tradeoff: Users with one account see an extra click. Users with multiple accounts are protected. No meaningful UX cost.


Part 5: Token Storage

HttpOnly cookies vs localStorage: the real comparison

The industry debates this constantly. The answer is clear.

localStorage:

HttpOnly cookie: If your application has any XSS vulnerability (and most applications have at least one, on at least one page, at some point in their lifetime), localStorage tokens are compromised. HttpOnly cookies are not.

The developer control argument for localStorage ("I control when the token is sent") is not a security feature -- it is a footgun. You want the session credential sent automatically on authenticated requests. The HttpOnly cookie does this correctly. The CSRF concern is handled with SameSite=Lax and an Authorization: Bearer header requirement on mutating endpoints.

The Ugly: OWASP estimates that localStorage token theft via XSS is one of the most prevalent real-world web auth vulnerabilities. Multiple major tutorials still recommend localStorage for JWTs. Every application following that advice has a systematic XSS-to-account-takeover vulnerability.


Native clients belong in the OS keychain

The npm CLI stored auth tokens in ~/.npmrc in plaintext for years. Any process that could read the home directory could steal them. Multiple supply chain attacks exploited this -- malicious packages published using stolen npm tokens.

The OS keychain (macOS Keychain, Windows Credential Manager, Linux SecretService) is encrypted at rest, access-controlled per application, and never written to disk as plaintext.

In VS Code: vscode.ExtensionContext.secrets.store(). In Node.js: keytar. In Python: keyring. No reason to use anything else.


Part 6: Defense in Depth

CSP on auth pages is not optional

Content Security Policy restricts which scripts can execute on a page. An XSS injection cannot load an external exfiltration script if CSP blocks unauthorized origins.

Auth pages are exactly where CSP matters most. They handle live credentials. They are the highest-value XSS target on your site.

A minimal CSP for auth pages:

Content-Security-Policy:   default-src 'self';   script-src 'self' https://known-cdn.com;   connect-src 'self' https://your-auth-server.com;   frame-ancestors 'none'

The Ugly: British Airways 2018 -- a 22-line JavaScript skimmer was injected onto the payment page via a compromised third-party script. It ran for 15 days, exfiltrating card data for 500,000 customers. The injected script loaded from an unauthorized external domain. A CSP blocking unauthorized script sources would have prevented execution.

The Bad: CSP on most pages but not the login page. script-src *. 'unsafe-inline' everywhere (defeats the purpose -- inline scripts are the primary XSS vector).


Rate limiting must be per-account, not just per-IP

IP-based rate limiting is defeated by IP rotation. Per-account rate limiting is harder to bypass -- the attacker would need a different account, which is the thing they are trying to crack.

Both are necessary. IP rate limiting blocks volumetric attacks. Account rate limiting blocks targeted brute force and credential stuffing.

The implementation detail that matters: implement soft lockouts (increasing delay, CAPTCHA) rather than hard lockouts. Hard lockouts can be weaponized -- an attacker can lock out a specific user by deliberately triggering N failures.

The Ugly: The 2012 LinkedIn breach involved credential stuffing -- automated testing of leaked username/password pairs from other breaches. LinkedIn had no rate limiting on the auth endpoint. Millions of accounts were compromised not through cryptography but through missing engineering controls.


The Takeaway

Authentication security is not about clever cryptography. It is about consistently applying a known set of requirements. Every item in this checklist has a documented breach demonstrating what happens when it is skipped. The requirements exist because the failures happened at scale, repeatedly, across thousands of applications that each thought they had "good enough" auth.

The checklist is public knowledge. The RFC is public knowledge. The failure modes are public knowledge. The only reason auth keeps failing is that teams treat these requirements as optional or as something to add later.

Add them first.

Read this post in the full Engineering Journal →