Auth in the Browser — Cheat Sheet
Full-Stack Integration · 4 topics. Download the PDF or the Instagram carousel and share it.
Cheat Sheet · AiCanCode.org
Auth in the Browser
Full-Stack Integration4 topicsQuick revision reference
1
Session Cookies, JWTs and OAuth
Three mechanisms with different trade-offs. The real question is not which is more modern, it is whether you can revoke a credential before it expires.
- ✓A session cookie is an opaque id whose meaning lives server-side, which makes revocation immediate
- ✓A JWT carries readable claims verified by signature and cannot be revoked before it expires
- ✓Never put secrets in a JWT and always pass algorithms explicitly when decoding
- ✓Short-lived access tokens plus refresh tokens are the standard workaround for JWT revocation
- ✓Use the Authorization Code flow with PKCE, exchange the code server-side, and then issue your own session
Opaque id, state on the server
# Login
session_id = secrets.token_urlsafe(32)
await redis.setex(f"session:{session_id}", 86400, json.dumps({"user_id": u.id}))
response.set_cookie("sid", session_id, httponly=True, secure=True,
samesite="lax", max_age=86400)
# Every request
data = await redis.get(f"session:{request.cookies['sid']}")
# Logout — genuinely gone, immediately
await redis.delete(f"session:{session_id}")
# Properties:
# + revoke instantly, log out everywhere, change a role mid-session
# + the cookie is opaque, so nothing leaks if it is read
# + no size limit on what you store
# - a lookup per request (Redis: sub-millisecond, rarely the issue)
# - state to run and scale
# "Sessions do not scale" is repeated far more often than it is true.
# Redis handles this trivially at the size almost every product is.2
Where the Credential Lives — XSS and CSRF
localStorage is exposed to any script on the page; a cookie is sent automatically by any site. Each storage choice picks which attack you have to defend against.
- ✓localStorage is readable by any script, so one XSS — including one in a dependency — takes every session
- ✓An httpOnly cookie cannot be read by JavaScript but is sent automatically, which is what CSRF exploits
- ✓XSS defeats every storage option, so a Content-Security-Policy and escaping are the primary defence
- ✓SameSite=Lax blocks cross-site POSTs; a cross-site API needs SameSite=None with Secure plus CSRF tokens
- ✓A cookie that works on localhost can fail in production because two real domains are cross-site while two localhost ports are not
Every option loses to XSS; httpOnly limits the damage
// localStorage
// readable by any script on the origin — one XSS takes everything
// survives a tab close, so a shared machine keeps the session
// immune to CSRF, easy across origins
localStorage.setItem('token', jwt) // convenient, and exposed
// httpOnly cookie
// JavaScript cannot read it, so XSS cannot exfiltrate it
// sent automatically -> needs SameSite and/or a CSRF token
Set-Cookie: sid=...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400
// In-memory (a JS variable or React state)
// safest against persistence — gone on refresh, which is the point
// needs a refresh cookie to restore the session on reload
// still readable by an XSS while the page is open
// sessionStorage
// per-tab, cleared on close. Same XSS exposure as localStorage.
// The honest summary:
// XSS defeats every client-side storage option. httpOnly only
// stops the token being READ — an XSS can still make requests as
// the user from the page itself.
// So preventing XSS is the primary defence, and storage choice is
// damage limitation.3
Refresh Tokens, Expiry and Logout
Short access tokens buy back revocation, at the cost of a refresh flow that has to handle concurrent requests, rotation and theft detection.
- ✓Short access tokens plus a long refresh token restore revocation to a stateless scheme
- ✓Scope the refresh cookie by path and store only its hash — it is a credential
- ✓Rotate the refresh token on every use, and treat reuse of a rotated token as theft: revoke the whole family
- ✓Concurrent 401s must share a single in-flight refresh promise or rotation logs the user out
- ✓A complete logout revokes server-side, clears client state and clears the query cache; "log out everywhere" needs a version or a revoke-all
Short access, long path-scoped refresh
# Access token — short, sent with every request
# 5-15 minutes. In memory on the client, or a Bearer header.
# Refresh token — long, sent only to /auth/refresh
# 7-30 days. httpOnly cookie, Path=/auth/refresh, and stored
# server-side so it can be revoked.
@router.post("/auth/login")
async def login(body: LoginIn, response: Response):
user = await authenticate(body.email, body.password)
access = create_jwt(user.id, ttl=timedelta(minutes=15))
refresh = secrets.token_urlsafe(48)
await db.store_refresh(user.id, hash(refresh), # HASH it —
expires=timedelta(days=30)) # it is a credential
response.set_cookie("rt", refresh, httponly=True, secure=True,
samesite="lax", path="/auth/refresh")
return {"access_token": access, "user": public(user)}
# Path scoping means the refresh cookie is not sent with ordinary API
# calls at all, which shrinks its exposure considerably.
# Store a hash, never the token itself — a leaked database should not
# hand over live sessions.4
Authorisation — Who Enforces What
The frontend decides what to show; the backend decides what is allowed. Every rule needs to exist on both sides, and only one of them is security.
- ✓The frontend hides what a user cannot use; only the backend enforces it, because the bundle and the API are public
- ✓Gating in the UI still ships the data — strip solutions, prices and other users' fields server-side
- ✓Checking authentication without checking ownership is the most common API vulnerability there is
- ✓Put ownership and tenant filters into the query itself, and return 404 rather than 403 to avoid confirming existence
- ✓Send server-computed permissions to the client so both sides agree, and remember roles inside a JWT go stale until it expires
Hide in the UI, enforce and strip on the server
// Frontend — so users are not shown doors they cannot open
{user.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
{isPro ? <Solution /> : <UpgradePrompt />}
// Backend — so the rule is actually true
@router.delete("/problems/{slug}")
async def delete_problem(slug: str, user = Depends(require_role("admin"))):
...
// Skipping the frontend check: a poor experience, users hitting 403s.
// Skipping the backend check: a vulnerability.
// The two failures to internalise:
// 1. Hiding a link hides nothing. curl reaches the endpoint.
// 2. Gating in the client means the DATA was still sent. If the
// API returns the solution and the UI hides it behind a Pro
// gate, the solution is in the network tab.
// Strip it server-side:
if not user.is_pro:
problem.solution = None
problem.hints = []
// Same for prices, other users' emails, internal notes — anything
// the response contains is public to whoever requested it.Learn this free with Aria, your AI tutor → AiCanCode.org/learn/full-stack