# Part 11: Authentication: passwords, JWTs and refresh tokens that can't be replayed

*Until now anyone who can reach the server can do anything. We add accounts, secure password storage, short-lived access tokens, and revocable sessions, and we look at the attacks each design choice defends against.*

---

**Series:** Build a production-grade REST API in Go · **Part 11 of 17** **Where we are:** the rate-limited API from Part 10.

Two words people mix up:

- **Authentication (authn)**: *who are you?* Prove an identity (password, token). Failure: `401 Unauthorized`.
- **Authorization (authz)**: *what are you allowed to do?* Decide permissions. Failure: `403 Forbidden`.

This post builds authentication. The next builds authorization. Keep them separate in your head and in your code.

That separation is not just vocabulary: we are going to build login *first*, run it, and see for ourselves that **being logged in is not enough**. Part 12 closes the hole this post deliberately leaves open.

## The design at a glance

```plaintext
POST /auth/register {name,email,password}  → 201 {access_token, refresh_token, user}
POST /auth/login    {email,password}       → 200 {access_token, refresh_token, user}
GET  /orders  -H 'Authorization: Bearer <access_token>'
POST /auth/refresh  {refresh_token}        → 200 NEW access + NEW refresh token
POST /auth/logout   {refresh_token}        → 204
GET  /auth/me                              → the caller's profile
```

Two kinds of token, for a reason that will become clear:

|  | Access token | Refresh token |
| --- | --- | --- |
| What | a signed **JWT** | a random 256-bit string |
| Lifetime | 15 minutes | 7 days |
| Checked by | verifying a signature (no DB) | a database lookup |
| Can we revoke it? | **No** (valid until it expires) | **Yes** |
| Sent | with every API call | only to `/auth/refresh` and `/auth/logout` |

## Part 1: storing passwords

**Rule zero: never store a password. Store a slow, salted hash.** If your database leaks (it happens to everyone eventually), the attacker should be able to test only a few guesses per second per CPU core, not billions.

- Plain `SHA-256` is *fast*, which is exactly wrong here: attackers compute billions per second on GPUs.
- **bcrypt** is deliberately slow and has a tunable **cost** (each +1 doubles the work; cost 12 ≈ 180 ms on my laptop). It also generates a random **salt** per password and embeds it in the output, so two users with the same password get different hashes and precomputed ("rainbow") tables are useless.
- Argon2id is the modern alternative; bcrypt remains a solid, simple, widely-reviewed choice.

```bash
go get golang.org/x/crypto
```

`internal/auth/password.go (lines 12-19)`

```go

// MaxPasswordBytes: bcrypt silently ignores everything after 72 bytes, so two
// passwords sharing a 72-byte prefix would be "equal". We reject longer input
// at registration instead of truncating silently.
const (
	MinPasswordLen = 10
	MaxPasswordLen = 72
)
```

`internal/auth/password.go (excerpt)`

```go
// NewHasher: cost is bcrypt's work factor; each +1 doubles the time (cost 12
// is roughly 250ms on a laptop). The point of a password hash is to be SLOW so
// that an attacker who steals the table can only guess a few passwords/second.
func NewHasher(cost int) (*Hasher, error) {
	h := &Hasher{cost: cost}
	// A real hash of a random password, used to equalise timing in Login (below).
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	d, err := bcrypt.GenerateFromPassword([]byte(hex.EncodeToString(b)), cost)
	if err != nil {
		return nil, err
	}
	h.dummy = d
	return h, nil
}

func (h *Hasher) Hash(password string) (string, error) {
	b, err := bcrypt.GenerateFromPassword([]byte(password), h.cost)
	return string(b), err
}

// Verify compares in constant time (bcrypt does this internally).
func (h *Hasher) Verify(hash, password string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

// BurnTime does the same expensive work as Verify against a throwaway hash.
// Login calls it when the email is unknown. Without it, "no such user" returns in
// ~1ms while "wrong password" takes ~250ms, and an attacker can enumerate which
// emails are registered purely from response times.
func (h *Hasher) BurnTime(password string) {
	_ = bcrypt.CompareHashAndPassword(h.dummy, []byte(password))
}
```

Things to notice:

- **Passwords are limited to 10..72 bytes.** bcrypt silently ignores everything after byte 72, so two long passwords with the same 72-byte prefix would be "equal". We reject over-long input instead of truncating silently. (A minimum length matters more than composition rules; long passphrases beat `P@ssw0rd1`.)
- `Verify` **is constant-time**: bcrypt's comparison doesn't leak how many characters matched.
- `BurnTime` looks strange: it does a pointless bcrypt comparison against a throwaway hash. Its job is explained in the login section below.
- The hash never touches a struct that is serialized to JSON; see the `User` type next.

## Part 2: the database

`internal/storage/migrations/00004_auth.sql`

```sql
-- +goose Up
-- Credentials live on users. Existing rows (created before auth) keep NULL
-- email/password and simply cannot log in.
ALTER TABLE users
    ADD COLUMN email         TEXT,
    ADD COLUMN password_hash TEXT,
    ADD COLUMN role          TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'));

-- Case-insensitive uniqueness: "Ann@x.com" and "ann@x.com" are the same person.
-- Partial index (WHERE email IS NOT NULL) because legacy rows have no email.
CREATE UNIQUE INDEX users_email_key ON users (lower(email)) WHERE email IS NOT NULL;

-- Refresh tokens are random bearer secrets. We store only their SHA-256 hash, so
-- a database leak does not hand out working tokens (same idea as password hashing,
-- but a fast hash is correct here because the token is 256 random bits, not a
-- guessable human password).
CREATE TABLE refresh_tokens (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    BIGINT      NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    token_hash BYTEA       NOT NULL UNIQUE,
    expires_at TIMESTAMPTZ NOT NULL,
    revoked_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);

-- +goose Down
DROP TABLE refresh_tokens;
DROP INDEX users_email_key;
ALTER TABLE users DROP COLUMN role, DROP COLUMN password_hash, DROP COLUMN email;
```

- `UNIQUE INDEX ... ON users (lower(email))`: emails are unique *case-insensitively* (`Ann@x.com` is `ann@x.com`). The index is *partial* (`WHERE email IS NOT NULL`) because rows created before this migration have no email.
- `role` with a `CHECK` constraint: only `user` or `admin`.
- `refresh_tokens` **stores** `token_hash`**, not the token.** Same principle as passwords: a leaked table must not hand out working credentials. Here a fast SHA-256 is correct, because a refresh token is 256 random bits; there's nothing to brute force, unlike a human-chosen password.

The Go side:

`internal/storage/users.go (excerpt)`

```go
type User struct {
	ID        int       `json:"id"`
	Name      string    `json:"name"`
	Email     string    `json:"email,omitempty"`
	Role      string    `json:"role"`
	CreatedAt time.Time `json:"created_at"`
	// The password hash is deliberately NOT a field here: if it is not on the
	// struct we serialise to JSON, it cannot leak through a response by accident.
}

// Credentials is what login needs; kept separate from User for the reason above.
type Credentials struct {
	UserID       int
	PasswordHash string
	Role         string
}

// isUniqueViolation reports Postgres error 23505. We detect conflicts by the
// error code rather than "SELECT then INSERT", which would race: two concurrent
// registrations could both pass the check.
func isUniqueViolation(err error) bool {
	var pg *pgconn.PgError
	return errors.As(err, &pg) && pg.Code == "23505"
}

// CreateWithCredentials registers a normal (role=user) account. email must
// already be normalised (trimmed, lower-cased) and passwordHash already hashed.
func (s *UserStore) CreateWithCredentials(ctx context.Context, name, email, passwordHash string) (*User, error) {
	u, err := scanUser(s.pool.QueryRow(ctx,
		"INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING "+userCols,
		name, email, passwordHash))
	if isUniqueViolation(err) {
		return nil, ErrEmailTaken
	}
	if err != nil {
		return nil, fmt.Errorf("insert user: %w", err)
	}
	return u, nil
}

// GetCredentials looks a user up by (normalised) email for login.
func (s *UserStore) GetCredentials(ctx context.Context, email string) (*Credentials, error) {
	c := &Credentials{}
	err := s.pool.QueryRow(ctx,
		"SELECT id, password_hash, role FROM users WHERE lower(email) = $1 AND password_hash IS NOT NULL", email).
		Scan(&c.UserID, &c.PasswordHash, &c.Role)
	if errors.Is(err, pgx.ErrNoRows) {
		return nil, ErrNotFound
	}
	if err != nil {
		return nil, fmt.Errorf("get credentials: %w", err)
	}
	return c, nil
}
```

- The **password hash is not a field of** `User`. `User` is what gets JSON-encoded into responses; if the hash isn't on that struct, no future code can leak it by accident. Login uses a separate `Credentials` type that is never serialized.
- **Duplicate emails are detected by the error, not by a pre-check.** Doing `SELECT` then `INSERT` has a race: two simultaneous registrations could both pass the check. Instead we `INSERT` and translate PostgreSQL error `23505` **(unique violation)** into `ErrEmailTaken`. Let the database be the referee.

## Part 3: access tokens (JWT)

A **JSON Web Token** looks like `xxxxx.yyyyy.zzzzz`: base64url-encoded header, payload and signature. Decode the middle part of a real token from this point in the series:

```bash
$ echo 'eyJyb2xlIjoidXNlciIsImlzcyI6InJlc3QtZ28iLCJzdWIiOiIyNDgiLCJleHAiOjE3OTIzMDQ4NTAsImlhdCI6MTc5MjMwMzk1MH0' | base64 -d
{"role":"user","iss":"rest-go","sub":"248","exp":1792304850,"iat":1792303950}
```

**The payload is readable by anyone.** It is signed, not encrypted. So never put secrets in a JWT; ours carries only the user id (`sub`), role, issuer, and times.

What the signature gives us: when a request arrives with a token, we recompute the HMAC-SHA256 over header+payload with our secret key and compare. If it matches, the token was issued by us and wasn't modified, **without any database lookup**. That's the appeal: authentication on every request costs microseconds.

The catch: since verification is stateless, **a JWT can't be revoked before it expires**. A stolen token works until `exp`. That is exactly why access tokens are short-lived (15 min) and why we pair them with refresh tokens.

`internal/auth/token.go (excerpt)`

```go
// TokenManager issues and verifies short-lived access tokens.
//
// A JWT is "header.payload.signature". The payload is only base64, NOT encrypted:
// anyone can read it, so never put secrets in it. The signature (HMAC-SHA256 with
// our secret) proves it was issued by us and not modified. Verification needs no
// database lookup, which is the point: cheap per-request auth. The price is that
// a token cannot be revoked before it expires, so keep the TTL short (15m) and
// use refresh tokens (revocable, stored in the DB) for long sessions.
type TokenManager struct {
	secret []byte
	issuer string
	ttl    time.Duration
	now    func() time.Time
}

func NewTokenManager(secret, issuer string, ttl time.Duration) (*TokenManager, error) {
	if len(secret) < 32 {
		return nil, errors.New("JWT secret must be at least 32 bytes")
	}
	return &TokenManager{secret: []byte(secret), issuer: issuer, ttl: ttl, now: time.Now}, nil
}

func (m *TokenManager) Issue(userID int, role string) (string, error) {
	now := m.now()
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{
		Role: role,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   strconv.Itoa(userID),
			Issuer:    m.issuer,
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)),
		},
	})
	return t.SignedString(m.secret)
}

func (m *TokenManager) Parse(token string) (Claims, error) {
	var c jwtClaims
	_, err := jwt.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { return m.secret, nil },
		// Pin the algorithm. The classic JWT attack sends alg "none" (no
		// signature) or switches algorithms; an allow-list defeats both.
		jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
		jwt.WithIssuer(m.issuer),
		jwt.WithExpirationRequired(),
		jwt.WithTimeFunc(m.now),
		jwt.WithLeeway(5*time.Second), // tolerate small clock skew between servers
	)
	if err != nil {
		return Claims{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
	}
	id, err := strconv.Atoi(c.Subject)
	if err != nil || id <= 0 {
		return Claims{}, ErrInvalidToken
	}
	return Claims{UserID: id, Role: c.Role}, nil
}
```

Every option in `Parse` closes a known attack:

| Setting | Attack it stops |
| --- | --- |
| `WithValidMethods(["HS256"])` | `alg: none`: an attacker sends an unsigned token claiming `"alg":"none"`; naive libraries accept it. Also stops algorithm-confusion tricks. |
| `WithExpirationRequired()` | tokens without an expiry that would live forever |
| `WithIssuer(...)` | tokens minted by another system that happens to share a key |
| `WithLeeway(5s)` | not an attack: tolerates small clock differences between servers |
| secret ≥ 32 bytes (checked in `NewTokenManager`) | guessable signing keys, which would let anyone forge admin tokens |

The tests prove it. They *build* hostile tokens and assert they're all rejected:

`internal/auth/auth_test.go (excerpt)`

```go
func TestTokenRejections(t *testing.T) {
	tm := newTM(t)
	good, _ := tm.Issue(1, RoleUser)

	other, _ := NewTokenManager("ffffffffffffffffffffffffffffffff", "test", time.Minute)
	otherTok, _ := other.Issue(1, RoleUser)

	wrongIssuer, _ := NewTokenManager("0123456789abcdef0123456789abcdef", "someone-else", time.Minute)
	wrongIssuerTok, _ := wrongIssuer.Issue(1, RoleUser)

	expired := newTM(t)
	expired.now = func() time.Time { return time.Now().Add(-time.Hour) }
	expiredTok, _ := expired.Issue(1, RoleUser)

	// alg=none: an unsigned token claiming to be admin.
	none, _ := jwt.NewWithClaims(jwt.SigningMethodNone, jwtClaims{Role: RoleAdmin,
		RegisteredClaims: jwt.RegisteredClaims{Subject: "1", Issuer: "test", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
	}).SignedString(jwt.UnsafeAllowNoneSignatureType)

	// Different HMAC algorithm with the right key must also be refused (algorithm pinning).
	hs512, _ := jwt.NewWithClaims(jwt.SigningMethodHS512, jwtClaims{Role: RoleAdmin,
		RegisteredClaims: jwt.RegisteredClaims{Subject: "1", Issuer: "test", ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour))},
	}).SignedString(tm.secret)

	tampered := good[:len(good)-2] + "xx"

	for name, tok := range map[string]string{
		"wrong key": otherTok, "wrong issuer": wrongIssuerTok, "expired": expiredTok,
		"alg none": none, "alg hs512": hs512, "tampered": tampered, "garbage": "a.b.c", "empty": "",
	} {
		if _, err := tm.Parse(tok); !errors.Is(err, ErrInvalidToken) {
			t.Errorf("%s: want ErrInvalidToken, got %v", name, err)
		}
	}
}
```

The signing secret (`JWT_SECRET`) is the crown jewel: whoever holds it can mint a token for any user, including an admin. It comes from the environment (a secrets manager in real deployments), must be at least 32 random bytes (`openssl rand -hex 32`), and is never logged. `make .env` generates one for you.

## Part 4: refresh tokens and rotation

Short access tokens would force users to log in every 15 minutes, so we give them a **refresh token**: a random string kept in the database, valid for 7 days, exchangeable for a new access token.

`internal/auth/token.go (excerpt)`

```go
// NewRefreshToken returns a random token to give the client and its SHA-256 hash
// to store. 32 random bytes = 256 bits of entropy: unguessable, so a fast hash is
// enough (bcrypt is for low-entropy human passwords).
func NewRefreshToken() (token string, hash []byte) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		panic(err) // crypto/rand failing means the OS is broken; do not continue
	}
	token = base64.RawURLEncoding.EncodeToString(b)
	return token, HashRefreshToken(token)
}

func HashRefreshToken(token string) []byte {
	h := sha256.Sum256([]byte(token))
	return h[:]
}
```

The clever part is **rotation with reuse detection**. Every refresh token is **single-use**: using it returns a new one and invalidates the old one. If an *old* one ever shows up again, someone copied it, so we revoke everything:

![](https://cdn.hashnode.com/uploads/covers/6398c90a452f4e3f4cb65bac/1efbb3a9-d13e-4929-b967-e8b787fcaf89.png align="center")

`internal/storage/tokens.go (excerpt)`

```go
// Rotate atomically exchanges a valid refresh token for a new one.
//
// Rotation = every refresh token is single-use. Why: a stolen token then works
// at most once, and if both the thief and the real user try to use the same
// token, the second attempt reveals the theft (reuse detection).
func (s *TokenStore) Rotate(ctx context.Context, oldHash, newHash []byte, newExpires time.Time) (int, error) {
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("begin: %w", err)
	}
	defer tx.Rollback(ctx) // no-op after a successful Commit

	var (
		id      int
		userID  int
		expires time.Time
		revoked *time.Time
	)
	// FOR UPDATE locks the row: two concurrent refreshes with the same token
	// are serialised, so exactly one wins and the other is seen as reuse.
	err = tx.QueryRow(ctx,
		"SELECT id, user_id, expires_at, revoked_at FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
		oldHash).Scan(&id, &userID, &expires, &revoked)
	if errors.Is(err, pgx.ErrNoRows) {
		return 0, ErrInvalidToken
	}
	if err != nil {
		return 0, fmt.Errorf("lookup refresh token: %w", err)
	}

	if revoked != nil {
		// Reuse: kill every live token of this user, and COMMIT that decision
		// (rolling back would undo the security response).
		if _, err := tx.Exec(ctx,
			"UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", userID); err != nil {
			return 0, fmt.Errorf("revoke family: %w", err)
		}
		if err := tx.Commit(ctx); err != nil {
			return 0, fmt.Errorf("commit: %w", err)
		}
		return userID, ErrTokenReuse
	}
	if time.Now().After(expires) {
		return 0, ErrInvalidToken
	}

	if _, err := tx.Exec(ctx, "UPDATE refresh_tokens SET revoked_at = now() WHERE id = $1", id); err != nil {
		return 0, fmt.Errorf("revoke old: %w", err)
	}
	if _, err := tx.Exec(ctx,
		"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
		userID, newHash, newExpires); err != nil {
		return 0, fmt.Errorf("insert new: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return 0, fmt.Errorf("commit: %w", err)
	}
	return userID, nil
}
```

Why is this safer? Suppose an attacker steals a refresh token.

1. If the real user refreshes first, the stolen token is now *already used*. When the attacker tries it, we see a revoked token being presented again. Legitimate clients never do that, so it's a **theft signal**. We can't tell who is who, so we **revoke every token of that user** (forcing a fresh login), and we log a warning.
2. If the attacker refreshes first, the real user's next refresh trips the same wire.

Implementation details that matter:

- `SELECT ... FOR UPDATE` locks the row, so two *simultaneous* refreshes with the same token are serialized: exactly one wins. A test fires two goroutines at one token and asserts exactly one success.
- The revoke-everything on reuse is **committed**, not rolled back. Returning an error doesn't mean we undo the security response.
- Rotation and the new token's insert happen in **one transaction**, so we never end up with the old token revoked and no new one issued.

## Part 5: the handlers

Registration validates everything, hashes the password, and returns tokens:

`internal/handlers/auth.go (excerpt)`

```go
// normaliseEmail trims and lower-cases, and accepts only a bare address
// ("a@b.com"), not display-name forms like "Eve <a@b.com>".
func normaliseEmail(s string) (string, bool) {
	s = strings.ToLower(strings.TrimSpace(s))
	a, err := mail.ParseAddress(s)
	if err != nil || a.Address != s || len(s) > 254 {
		return "", false
	}
	return s, true
}

func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
	var in registerInput
	if !h.decodeJSON(w, r, &in) {
		return
	}
	name := strings.TrimSpace(in.Name)
	email, emailOK := normaliseEmail(in.Email)
	switch {
	case name == "" || utf8.RuneCountInString(name) > h.limits.MaxNameLen:
		h.writeError(w, r, http.StatusUnprocessableEntity, "name is required and must be short")
		return
	case !emailOK:
		h.writeError(w, r, http.StatusUnprocessableEntity, "a valid email is required")
		return
	case len(in.Password) < auth.MinPasswordLen || len(in.Password) > auth.MaxPasswordLen:
		h.writeError(w, r, http.StatusUnprocessableEntity, "password must be 10 to 72 bytes")
		return
	}

	hash, err := h.hasher.Hash(in.Password)
	if err != nil {
		h.internalError(w, r, "hash password", err)
		return
	}
	user, err := h.users.CreateWithCredentials(r.Context(), name, email, hash)
	if errors.Is(err, storage.ErrEmailTaken) {
		// Trade-off: this reveals that the email is registered (account
		// enumeration). Hiding it needs an email-verification flow
		// ("check your inbox"); the per-IP limits bound the abuse meanwhile.
		h.writeError(w, r, http.StatusConflict, "email already registered")
		return
	}
	if err != nil {
		h.internalError(w, r, "register", err)
		return
	}
	h.issueTokens(w, r, http.StatusCreated, user)
}
```

- `mail.ParseAddress` plus the `a.Address != s` comparison accepts only a bare address (`ann@example.com`), not `Eve <ann@example.com>`.
- Duplicate email → `409 Conflict`. (A trade-off: this reveals that an email is registered. Fully hiding that needs an email-verification flow, "check your inbox", out of scope here.)
- The new account's role is always `user`. There is no way to send `"role":"admin"` (unknown JSON fields are rejected, and `registerInput` has no such field).

Now login. Read the comments:

`internal/handlers/auth.go (excerpt)`

```go
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
	var in loginInput
	if !h.decodeJSON(w, r, &in) {
		return
	}
	email, ok := normaliseEmail(in.Email)
	// Every failure below returns the SAME message and takes similar time, so a
	// caller cannot tell "no such account" from "wrong password".
	invalid := func() { h.writeError(w, r, http.StatusUnauthorized, "invalid credentials") }
	if !ok || len(in.Password) > auth.MaxPasswordLen {
		h.hasher.BurnTime(in.Password)
		invalid()
		return
	}

	creds, err := h.users.GetCredentials(r.Context(), email)
	if errors.Is(err, storage.ErrNotFound) {
		h.hasher.BurnTime(in.Password)
		invalid()
		return
	}
	if err != nil {
		h.internalError(w, r, "login lookup", err)
		return
	}
	if !h.hasher.Verify(creds.PasswordHash, in.Password) {
		h.log.InfoContext(r.Context(), "login failed", "user_id", creds.UserID)
		invalid()
		return
	}
	user, err := h.users.Get(r.Context(), creds.UserID)
	if err != nil {
		h.storeError(w, r, "login load user", err)
		return
	}
	h.issueTokens(w, r, http.StatusOK, user)
}
```

**Login must not reveal which emails have accounts.** Two leaks to avoid:

1. *Different messages* ("no such user" vs "wrong password"): we use one message, `invalid credentials`, for both.
2. *Different timing*: finding no such user would return in about 2 ms, while checking a wrong password burns ~180 ms of bcrypt. An attacker can tell the two apart by stopwatch and enumerate your user base. `BurnTime` makes the unknown-user path spend the same time.

A test asserts the two failure responses are byte-identical.

## Part 6: protecting routes with middleware

`internal/middleware/auth.go (excerpt)`

```go
// Authenticate requires a valid "Authorization: Bearer <access token>" header,
// puts the caller's Principal in the request context and rejects everyone else
// with 401. It wraps individual routes (see routes.go), so public endpoints like
// /auth/login simply don't use it.
//
// 401 vs 403: 401 = "I don't know who you are" (missing/invalid credentials);
// 403 = "I know who you are and you may not do this" (handlers use it).
func Authenticate(tm *auth.TokenManager, log *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token, ok := bearerToken(r)
			if !ok {
				w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
				writeErr(w, http.StatusUnauthorized, "authentication required")
				return
			}
			claims, err := tm.Parse(token)
			if err != nil {
				// The client only learns "unauthorized"; the reason stays in our logs.
				log.DebugContext(r.Context(), "token rejected", "err", err)
				w.Header().Set("WWW-Authenticate", `Bearer realm="api", error="invalid_token"`)
				writeErr(w, http.StatusUnauthorized, "invalid or expired token")
				return
			}

			ctx := auth.WithPrincipal(r.Context(), auth.Principal{UserID: claims.UserID, Role: claims.Role})
			observability.SetUserID(ctx, claims.UserID) // visible to the access logger
			trace.SpanFromContext(ctx).SetAttributes(attribute.Int("enduser.id", claims.UserID))
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func bearerToken(r *http.Request) (string, bool) {
	scheme, token, found := strings.Cut(r.Header.Get("Authorization"), " ")
	if !found || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(token) == "" {
		return "", false
	}
	return strings.TrimSpace(token), true
}
```

- It reads `Authorization: Bearer <token>`, verifies it, and stores a `Principal` (user id + role) in the request context. Handlers read it with `auth.FromContext`. The *reason* for a rejection goes to the debug log; the client only learns "invalid or expired token".
- `WWW-Authenticate: Bearer` is the standard header telling clients how to authenticate.
- It also records the user id for the access logger and as a trace attribute, so "what did user 248 do?" becomes a Loki query: `{service="api"} | json | user_id = 248`.

Routing makes the security posture visible at a glance:

**Replace the contents of** `internal/routes/routes.go` **with**

```go
package routes

import (
	"net/http"

	"go-rest/internal/handlers"
)

// Deps are the handlers plus the authentication middleware to guard them.
type Deps struct {
	Auth         *handlers.AuthHandler
	Users        *handlers.UserHandler
	Orders       *handlers.OrderHandler
	Authenticate func(http.Handler) http.Handler
}

// SetupRoutes registers every endpoint. Reading this file answers "which routes
// are public?": only those registered with `public`.
func SetupRoutes(mux *http.ServeMux, d Deps) {
	public := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, h) }
	protected := func(pattern string, h http.HandlerFunc) { mux.Handle(pattern, d.Authenticate(h)) }

	public("POST /auth/register", d.Auth.Register)
	public("POST /auth/login", d.Auth.Login)
	public("POST /auth/refresh", d.Auth.Refresh)
	public("POST /auth/logout", d.Auth.Logout)
	protected("GET /auth/me", d.Auth.Me)

	protected("GET /users", d.Users.List)
	protected("POST /users", d.Users.Create)
	protected("GET /users/{id}", d.Users.Get)
	protected("PUT /users/{id}", d.Users.Update)
	protected("DELETE /users/{id}", d.Users.Delete)

	protected("GET /orders", d.Orders.List)
	protected("POST /orders", d.Orders.Create)
	protected("GET /orders/{id}", d.Orders.Get)
	protected("DELETE /orders/{id}", d.Orders.Delete)
}
```

Public routes are exactly those registered with `public(...)`; everything else goes through `protected(...)`. **Secure by default**: leaving a route unprotected requires an explicit choice. (In Part 13 we'll wrap some of these routes in rate limiters.)

## Part 7: how do you become an admin?

Not through the API. That is deliberate: any HTTP path that can grant admin is a path an attacker will try. We use a command-line subcommand that needs shell access to the deployment *and* the database:

`cmd/rest/main.go (excerpt)`

```go
// createAdmin is the ONLY way to obtain the admin role: it needs shell access to
// the deployment and the database, not just an HTTP request. The password comes
// from the environment, not argv, because command lines are visible in `ps`.
func createAdmin() error {
	cfg, err := config.Load()
	if err != nil {
		return err
	}
	email, ok := normaliseAdminEmail(os.Getenv("ADMIN_EMAIL"))
	password := os.Getenv("ADMIN_PASSWORD")
	if !ok || len(password) < auth.MinPasswordLen || len(password) > auth.MaxPasswordLen {
		return fmt.Errorf("set ADMIN_EMAIL and ADMIN_PASSWORD (%d-%d bytes)", auth.MinPasswordLen, auth.MaxPasswordLen)
	}
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	pool, err := storage.NewPool(ctx, poolConfig(cfg))
	if err != nil {
		return err
	}
	defer pool.Close()

	hasher, err := auth.NewHasher(cfg.BcryptCost)
	if err != nil {
		return err
	}
	hash, err := hasher.Hash(password)
	if err != nil {
		return err
	}
	name := os.Getenv("ADMIN_NAME")
	if name == "" {
		name = "admin"
	}
	u, err := storage.NewUserStore(pool).UpsertAdmin(ctx, name, email, hash)
	if err != nil {
		return err
	}
	slog.Info("admin ready", "user_id", u.ID, "email", u.Email)
	return nil
}
```

```bash
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='a-long-passphrase' make admin
```

The password comes from the **environment, not a command-line argument**, because command lines are visible to every user on the machine via `ps`.

## Complete code for this part

Everything to type, file by file: the new `auth` package, the token store, the handlers and middleware, plus the edits that wire them in. Then `go mod tidy` (it adds `golang-jwt` and `x/crypto`) and `go build ./...`.

*Already given earlier in this post:* `internal/routes/routes.go`*.*

**Create** `internal/storage/migrations/00004_auth.sql`

```sql
-- +goose Up
-- Credentials live on users. Existing rows (created before auth) keep NULL
-- email/password and simply cannot log in.
ALTER TABLE users
    ADD COLUMN email         TEXT,
    ADD COLUMN password_hash TEXT,
    ADD COLUMN role          TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin'));

-- Case-insensitive uniqueness: "Ann@x.com" and "ann@x.com" are the same person.
-- Partial index (WHERE email IS NOT NULL) because legacy rows have no email.
CREATE UNIQUE INDEX users_email_key ON users (lower(email)) WHERE email IS NOT NULL;

-- Refresh tokens are random bearer secrets. We store only their SHA-256 hash, so
-- a database leak does not hand out working tokens (same idea as password hashing,
-- but a fast hash is correct here because the token is 256 random bits, not a
-- guessable human password).
CREATE TABLE refresh_tokens (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    user_id    BIGINT      NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    token_hash BYTEA       NOT NULL UNIQUE,
    expires_at TIMESTAMPTZ NOT NULL,
    revoked_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens (user_id);

-- +goose Down
DROP TABLE refresh_tokens;
DROP INDEX users_email_key;
ALTER TABLE users DROP COLUMN role, DROP COLUMN password_hash, DROP COLUMN email;
```

**Create** `internal/auth/password.go`

```go
// Package auth holds the security primitives: password hashing, access tokens
// (JWT), refresh tokens, and the request principal. It knows nothing about HTTP
// or SQL, so it is easy to test and audit.
package auth

import (
	"crypto/rand"
	"encoding/hex"

	"golang.org/x/crypto/bcrypt"
)

// MaxPasswordBytes: bcrypt silently ignores everything after 72 bytes, so two
// passwords sharing a 72-byte prefix would be "equal". We reject longer input
// at registration instead of truncating silently.
const (
	MinPasswordLen = 10
	MaxPasswordLen = 72
)

type Hasher struct {
	cost  int
	dummy []byte
}

// NewHasher: cost is bcrypt's work factor; each +1 doubles the time (cost 12
// is roughly 250ms on a laptop). The point of a password hash is to be SLOW so
// that an attacker who steals the table can only guess a few passwords/second.
func NewHasher(cost int) (*Hasher, error) {
	h := &Hasher{cost: cost}
	// A real hash of a random password, used to equalise timing in Login (below).
	b := make([]byte, 16)
	_, _ = rand.Read(b)
	d, err := bcrypt.GenerateFromPassword([]byte(hex.EncodeToString(b)), cost)
	if err != nil {
		return nil, err
	}
	h.dummy = d
	return h, nil
}

func (h *Hasher) Hash(password string) (string, error) {
	b, err := bcrypt.GenerateFromPassword([]byte(password), h.cost)
	return string(b), err
}

// Verify compares in constant time (bcrypt does this internally).
func (h *Hasher) Verify(hash, password string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

// BurnTime does the same expensive work as Verify against a throwaway hash.
// Login calls it when the email is unknown. Without it, "no such user" returns in
// ~1ms while "wrong password" takes ~250ms, and an attacker can enumerate which
// emails are registered purely from response times.
func (h *Hasher) BurnTime(password string) {
	_ = bcrypt.CompareHashAndPassword(h.dummy, []byte(password))
}
```

**Create** `internal/auth/principal.go`

```go
package auth

import "context"

const (
	RoleUser  = "user"
	RoleAdmin = "admin"
)

// Principal is "who is making this request", established by the Authenticate
// middleware and read by handlers.
type Principal struct {
	UserID int
	Role   string
}

func (p Principal) IsAdmin() bool { return p.Role == RoleAdmin }

type ctxKey struct{}

func WithPrincipal(ctx context.Context, p Principal) context.Context {
	return context.WithValue(ctx, ctxKey{}, p)
}

func FromContext(ctx context.Context) (Principal, bool) {
	p, ok := ctx.Value(ctxKey{}).(Principal)
	return p, ok
}
```

**Create** `internal/auth/token.go`

```go
package auth

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"fmt"
	"strconv"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// ErrInvalidToken deliberately carries no detail: callers (and attackers) learn
// only "rejected". The precise reason is for server-side logs.
var ErrInvalidToken = errors.New("invalid or expired token")

type Claims struct {
	UserID int
	Role   string
}

type jwtClaims struct {
	Role string `json:"role"`
	jwt.RegisteredClaims
}

// TokenManager issues and verifies short-lived access tokens.
//
// A JWT is "header.payload.signature". The payload is only base64, NOT encrypted:
// anyone can read it, so never put secrets in it. The signature (HMAC-SHA256 with
// our secret) proves it was issued by us and not modified. Verification needs no
// database lookup, which is the point: cheap per-request auth. The price is that
// a token cannot be revoked before it expires, so keep the TTL short (15m) and
// use refresh tokens (revocable, stored in the DB) for long sessions.
type TokenManager struct {
	secret []byte
	issuer string
	ttl    time.Duration
	now    func() time.Time
}

func NewTokenManager(secret, issuer string, ttl time.Duration) (*TokenManager, error) {
	if len(secret) < 32 {
		return nil, errors.New("JWT secret must be at least 32 bytes")
	}
	return &TokenManager{secret: []byte(secret), issuer: issuer, ttl: ttl, now: time.Now}, nil
}

func (m *TokenManager) TTL() time.Duration { return m.ttl }

func (m *TokenManager) Issue(userID int, role string) (string, error) {
	now := m.now()
	t := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{
		Role: role,
		RegisteredClaims: jwt.RegisteredClaims{
			Subject:   strconv.Itoa(userID),
			Issuer:    m.issuer,
			IssuedAt:  jwt.NewNumericDate(now),
			ExpiresAt: jwt.NewNumericDate(now.Add(m.ttl)),
		},
	})
	return t.SignedString(m.secret)
}

func (m *TokenManager) Parse(token string) (Claims, error) {
	var c jwtClaims
	_, err := jwt.ParseWithClaims(token, &c, func(*jwt.Token) (any, error) { return m.secret, nil },
		// Pin the algorithm. The classic JWT attack sends alg "none" (no
		// signature) or switches algorithms; an allow-list defeats both.
		jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}),
		jwt.WithIssuer(m.issuer),
		jwt.WithExpirationRequired(),
		jwt.WithTimeFunc(m.now),
		jwt.WithLeeway(5*time.Second), // tolerate small clock skew between servers
	)
	if err != nil {
		return Claims{}, fmt.Errorf("%w: %v", ErrInvalidToken, err)
	}
	id, err := strconv.Atoi(c.Subject)
	if err != nil || id <= 0 {
		return Claims{}, ErrInvalidToken
	}
	return Claims{UserID: id, Role: c.Role}, nil
}

// ---- refresh tokens ---------------------------------------------------------

// NewRefreshToken returns a random token to give the client and its SHA-256 hash
// to store. 32 random bytes = 256 bits of entropy: unguessable, so a fast hash is
// enough (bcrypt is for low-entropy human passwords).
func NewRefreshToken() (token string, hash []byte) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		panic(err) // crypto/rand failing means the OS is broken; do not continue
	}
	token = base64.RawURLEncoding.EncodeToString(b)
	return token, HashRefreshToken(token)
}

func HashRefreshToken(token string) []byte {
	h := sha256.Sum256([]byte(token))
	return h[:]
}
```

**Change** `internal/config/config.go`

```diff
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -46,6 +46,12 @@ type Config struct {
 	RateLimitBurst int
 	TrustedProxies string // CIDRs whose X-Forwarded-For we believe, e.g. "10.0.0.0/8"
 
+	// Authentication.
+	JWTSecret       string        // HMAC key for access tokens; >= 32 bytes, from a secret store
+	AccessTokenTTL  time.Duration // short: access tokens cannot be revoked
+	RefreshTokenTTL time.Duration // long: the session length; revocable (stored in DB)
+	BcryptCost      int
+
 	ServiceName      string
 	LogLevel         slog.Level
 	OTLPEndpoint     string // host:port of an OTLP/gRPC collector; empty disables tracing
@@ -78,6 +84,10 @@ func Load() (Config, error) {
 		RateLimitRPS:       20,
 		RateLimitBurst:     40,
 		TrustedProxies:     os.Getenv("TRUSTED_PROXIES"),
+		JWTSecret:          os.Getenv("JWT_SECRET"),
+		AccessTokenTTL:     15 * time.Minute,
+		RefreshTokenTTL:    7 * 24 * time.Hour,
+		BcryptCost:         12,
 	}
 
 	var err error
@@ -95,6 +105,8 @@ func Load() (Config, error) {
 		{"SHUTDOWN_TIMEOUT", &c.ShutdownTimeout},
 		{"SHUTDOWN_DELAY", &c.ShutdownDelay},
 		{"REQUEST_TIMEOUT", &c.RequestTimeout},
+		{"ACCESS_TOKEN_TTL", &c.AccessTokenTTL},
+		{"REFRESH_TOKEN_TTL", &c.RefreshTokenTTL},
 	}
 	for _, d := range durations {
 		if v := os.Getenv(d.key); v != "" {
@@ -128,7 +140,7 @@ func Load() (Config, error) {
 	for _, i := range []struct {
 		key string
 		dst *int
-	}{{"DEFAULT_PAGE_SIZE", &c.DefaultPageSize}, {"MAX_PAGE_SIZE", &c.MaxPageSize}} {
+	}{{"DEFAULT_PAGE_SIZE", &c.DefaultPageSize}, {"MAX_PAGE_SIZE", &c.MaxPageSize}, {"BCRYPT_COST", &c.BcryptCost}} {
 		if v := os.Getenv(i.key); v != "" {
 			if *i.dst, err = strconv.Atoi(v); err != nil {
 				return c, fmt.Errorf("%s: %w", i.key, err)
@@ -166,6 +178,10 @@ func Load() (Config, error) {
 		return c, fmt.Errorf("REQUEST_TIMEOUT (%s) must be below WRITE_TIMEOUT (%s)", c.RequestTimeout, c.WriteTimeout)
 	case c.DefaultPageSize < 1 || c.MaxPageSize < c.DefaultPageSize:
 		return c, fmt.Errorf("need 1 <= DEFAULT_PAGE_SIZE <= MAX_PAGE_SIZE")
+	case c.BcryptCost < 4 || c.BcryptCost > 16:
+		return c, fmt.Errorf("BCRYPT_COST must be within [4,16]")
+	case c.AccessTokenTTL <= 0 || c.RefreshTokenTTL <= c.AccessTokenTTL:
+		return c, fmt.Errorf("need 0 < ACCESS_TOKEN_TTL < REFRESH_TOKEN_TTL")
 	case c.RateLimitRPS < 0 || (c.RateLimitRPS > 0 && c.RateLimitBurst < 1):
 		return c, fmt.Errorf("RATE_LIMIT_RPS must be >= 0 and RATE_LIMIT_BURST >= 1 when enabled")
 	case c.DBMaxConns < 1 || c.DBMinConns < 0 || c.DBMinConns > c.DBMaxConns:
@@ -176,6 +192,15 @@ func Load() (Config, error) {
 	return c, nil
 }
 
+// ValidateServe checks settings only the API server needs, so one-shot commands
+// (migrate, healthcheck) don't have to be given the JWT secret.
+func (c Config) ValidateServe() error {
+	if len(c.JWTSecret) < 32 {
+		return fmt.Errorf("JWT_SECRET is required and must be at least 32 bytes (try: openssl rand -hex 32)")
+	}
+	return nil
+}
+
 func env(key, fallback string) string {
 	if v := os.Getenv(key); v != "" {
 		return v
```

**Create** `internal/handlers/auth.go`

```go
package handlers

import (
	"context"
	"errors"
	"log/slog"
	"net/http"
	"net/mail"
	"strings"
	"time"
	"unicode/utf8"

	"go-rest/internal/auth"
	"go-rest/internal/storage"
)

type AuthUserStore interface {
	CreateWithCredentials(ctx context.Context, name, email, passwordHash string) (*storage.User, error)
	GetCredentials(ctx context.Context, email string) (*storage.Credentials, error)
	Get(ctx context.Context, id int) (*storage.User, error)
}

type TokenStore interface {
	Create(ctx context.Context, userID int, hash []byte, expires time.Time) error
	Rotate(ctx context.Context, oldHash, newHash []byte, newExpires time.Time) (int, error)
	Revoke(ctx context.Context, hash []byte) error
}

type AuthHandler struct {
	base
	users      AuthUserStore
	tokens     TokenStore
	hasher     *auth.Hasher
	tm         *auth.TokenManager
	refreshTTL time.Duration
}

func NewAuthHandler(users AuthUserStore, tokens TokenStore, hasher *auth.Hasher, tm *auth.TokenManager,
	refreshTTL time.Duration, log *slog.Logger, limits Limits) *AuthHandler {
	return &AuthHandler{base: base{log: log, limits: limits}, users: users, tokens: tokens,
		hasher: hasher, tm: tm, refreshTTL: refreshTTL}
}

type tokenResponse struct {
	AccessToken  string        `json:"access_token"`
	TokenType    string        `json:"token_type"`
	ExpiresIn    int           `json:"expires_in"` // seconds
	RefreshToken string        `json:"refresh_token"`
	User         *storage.User `json:"user"`
}

type registerInput struct {
	Name     string `json:"name"`
	Email    string `json:"email"`
	Password string `json:"password"`
}

type loginInput struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

type refreshInput struct {
	RefreshToken string `json:"refresh_token"`
}

// normaliseEmail trims and lower-cases, and accepts only a bare address
// ("a@b.com"), not display-name forms like "Eve <a@b.com>".
func normaliseEmail(s string) (string, bool) {
	s = strings.ToLower(strings.TrimSpace(s))
	a, err := mail.ParseAddress(s)
	if err != nil || a.Address != s || len(s) > 254 {
		return "", false
	}
	return s, true
}

func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
	var in registerInput
	if !h.decodeJSON(w, r, &in) {
		return
	}
	name := strings.TrimSpace(in.Name)
	email, emailOK := normaliseEmail(in.Email)
	switch {
	case name == "" || utf8.RuneCountInString(name) > h.limits.MaxNameLen:
		h.writeError(w, r, http.StatusUnprocessableEntity, "name is required and must be short")
		return
	case !emailOK:
		h.writeError(w, r, http.StatusUnprocessableEntity, "a valid email is required")
		return
	case len(in.Password) < auth.MinPasswordLen || len(in.Password) > auth.MaxPasswordLen:
		h.writeError(w, r, http.StatusUnprocessableEntity, "password must be 10 to 72 bytes")
		return
	}

	hash, err := h.hasher.Hash(in.Password)
	if err != nil {
		h.internalError(w, r, "hash password", err)
		return
	}
	user, err := h.users.CreateWithCredentials(r.Context(), name, email, hash)
	if errors.Is(err, storage.ErrEmailTaken) {
		// Trade-off: this reveals that the email is registered (account
		// enumeration). Hiding it needs an email-verification flow
		// ("check your inbox"); the per-IP limits bound the abuse meanwhile.
		h.writeError(w, r, http.StatusConflict, "email already registered")
		return
	}
	if err != nil {
		h.internalError(w, r, "register", err)
		return
	}
	h.issueTokens(w, r, http.StatusCreated, user)
}

func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
	var in loginInput
	if !h.decodeJSON(w, r, &in) {
		return
	}
	email, ok := normaliseEmail(in.Email)
	// Every failure below returns the SAME message and takes similar time, so a
	// caller cannot tell "no such account" from "wrong password".
	invalid := func() { h.writeError(w, r, http.StatusUnauthorized, "invalid credentials") }
	if !ok || len(in.Password) > auth.MaxPasswordLen {
		h.hasher.BurnTime(in.Password)
		invalid()
		return
	}

	creds, err := h.users.GetCredentials(r.Context(), email)
	if errors.Is(err, storage.ErrNotFound) {
		h.hasher.BurnTime(in.Password)
		invalid()
		return
	}
	if err != nil {
		h.internalError(w, r, "login lookup", err)
		return
	}
	if !h.hasher.Verify(creds.PasswordHash, in.Password) {
		h.log.InfoContext(r.Context(), "login failed", "user_id", creds.UserID)
		invalid()
		return
	}
	user, err := h.users.Get(r.Context(), creds.UserID)
	if err != nil {
		h.storeError(w, r, "login load user", err)
		return
	}
	h.issueTokens(w, r, http.StatusOK, user)
}

// Refresh trades a valid refresh token for a NEW access token and a NEW refresh
// token. The old refresh token stops working (rotation).
func (h *AuthHandler) Refresh(w http.ResponseWriter, r *http.Request) {
	var in refreshInput
	if !h.decodeJSON(w, r, &in) {
		return
	}
	if in.RefreshToken == "" {
		h.writeError(w, r, http.StatusUnprocessableEntity, "refresh_token is required")
		return
	}
	newToken, newHash := auth.NewRefreshToken()
	userID, err := h.tokens.Rotate(r.Context(), auth.HashRefreshToken(in.RefreshToken), newHash, time.Now().Add(h.refreshTTL))
	switch {
	case errors.Is(err, storage.ErrTokenReuse):
		h.log.WarnContext(r.Context(), "refresh token reuse detected; all sessions revoked", "user_id", userID)
		h.writeError(w, r, http.StatusUnauthorized, "invalid refresh token")
		return
	case errors.Is(err, storage.ErrInvalidToken):
		h.writeError(w, r, http.StatusUnauthorized, "invalid refresh token")
		return
	case err != nil:
		h.internalError(w, r, "rotate refresh token", err)
		return
	}
	user, err := h.users.Get(r.Context(), userID)
	if err != nil {
		h.storeError(w, r, "refresh load user", err)
		return
	}
	access, err := h.tm.Issue(user.ID, user.Role)
	if err != nil {
		h.internalError(w, r, "issue access token", err)
		return
	}
	h.writeJSON(w, r, http.StatusOK, tokenResponse{
		AccessToken: access, TokenType: "Bearer", ExpiresIn: int(h.tm.TTL().Seconds()),
		RefreshToken: newToken, User: user,
	})
}

// Logout revokes the refresh token. The short-lived access token simply expires
// (JWTs can't be recalled; that's the price of stateless verification).
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
	var in refreshInput
	if !h.decodeJSON(w, r, &in) {
		return
	}
	if in.RefreshToken != "" {
		if err := h.tokens.Revoke(r.Context(), auth.HashRefreshToken(in.RefreshToken)); err != nil {
			h.internalError(w, r, "logout", err)
			return
		}
	}
	w.WriteHeader(http.StatusNoContent)
}

// Me returns the authenticated caller's own profile.
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
	p, _ := auth.FromContext(r.Context())
	user, err := h.users.Get(r.Context(), p.UserID)
	if err != nil {
		h.storeError(w, r, "me", err)
		return
	}
	h.writeJSON(w, r, http.StatusOK, user)
}

func (h *AuthHandler) issueTokens(w http.ResponseWriter, r *http.Request, status int, user *storage.User) {
	access, err := h.tm.Issue(user.ID, user.Role)
	if err != nil {
		h.internalError(w, r, "issue access token", err)
		return
	}
	refresh, hash := auth.NewRefreshToken()
	if err := h.tokens.Create(r.Context(), user.ID, hash, time.Now().Add(h.refreshTTL)); err != nil {
		h.internalError(w, r, "store refresh token", err)
		return
	}
	h.writeJSON(w, r, status, tokenResponse{
		AccessToken: access, TokenType: "Bearer", ExpiresIn: int(h.tm.TTL().Seconds()),
		RefreshToken: refresh, User: user,
	})
}
```

**Create** `internal/middleware/auth.go`

```go
package middleware

import (
	"log/slog"
	"net/http"
	"strings"

	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/trace"

	"go-rest/internal/auth"
	"go-rest/internal/observability"
)

func writeErr(w http.ResponseWriter, status int, msg string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, _ = w.Write([]byte(`{"error":"` + msg + `"}` + "\n"))
}

// Authenticate requires a valid "Authorization: Bearer <access token>" header,
// puts the caller's Principal in the request context and rejects everyone else
// with 401. It wraps individual routes (see routes.go), so public endpoints like
// /auth/login simply don't use it.
//
// 401 vs 403: 401 = "I don't know who you are" (missing/invalid credentials);
// 403 = "I know who you are and you may not do this" (handlers use it).
func Authenticate(tm *auth.TokenManager, log *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token, ok := bearerToken(r)
			if !ok {
				w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
				writeErr(w, http.StatusUnauthorized, "authentication required")
				return
			}
			claims, err := tm.Parse(token)
			if err != nil {
				// The client only learns "unauthorized"; the reason stays in our logs.
				log.DebugContext(r.Context(), "token rejected", "err", err)
				w.Header().Set("WWW-Authenticate", `Bearer realm="api", error="invalid_token"`)
				writeErr(w, http.StatusUnauthorized, "invalid or expired token")
				return
			}

			ctx := auth.WithPrincipal(r.Context(), auth.Principal{UserID: claims.UserID, Role: claims.Role})
			observability.SetUserID(ctx, claims.UserID) // visible to the access logger
			trace.SpanFromContext(ctx).SetAttributes(attribute.Int("enduser.id", claims.UserID))
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func bearerToken(r *http.Request) (string, bool) {
	scheme, token, found := strings.Cut(r.Header.Get("Authorization"), " ")
	if !found || !strings.EqualFold(scheme, "Bearer") || strings.TrimSpace(token) == "" {
		return "", false
	}
	return strings.TrimSpace(token), true
}
```

**Change** `internal/middleware/middleware.go`

```diff
--- a/internal/middleware/middleware.go
+++ b/internal/middleware/middleware.go
@@ -34,7 +34,8 @@ func RequestID(next http.Handler) http.Handler {
 			id = hex.EncodeToString(b)
 		}
 		w.Header().Set("X-Request-ID", id)
-		next.ServeHTTP(w, r.WithContext(observability.WithRequestID(r.Context(), id)))
+		ctx := observability.WithRequestInfo(observability.WithRequestID(r.Context(), id))
+		next.ServeHTTP(w, r.WithContext(ctx))
 	})
 }
 
```

**Change** `internal/observability/logging.go`

```diff
--- a/internal/observability/logging.go
+++ b/internal/observability/logging.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"io"
 	"log/slog"
+	"sync/atomic"
 
 	"go.opentelemetry.io/otel/trace"
 )
@@ -21,6 +22,33 @@ func RequestID(ctx context.Context) string {
 	return id
 }
 
+// requestInfo is a mutable box placed in the context at the START of a request.
+// Middleware that runs later (authentication) fills it in; middleware that ran
+// EARLIER (the access logger) can still read it afterwards. Plain context values
+// can't do this: a child context is invisible to code holding the parent.
+type requestInfo struct{ userID atomic.Int64 }
+
+type infoKey struct{}
+
+func WithRequestInfo(ctx context.Context) context.Context {
+	return context.WithValue(ctx, infoKey{}, &requestInfo{})
+}
+
+// SetUserID records the authenticated user (no-op if the box is absent).
+func SetUserID(ctx context.Context, id int) {
+	if ri, ok := ctx.Value(infoKey{}).(*requestInfo); ok {
+		ri.userID.Store(int64(id))
+	}
+}
+
+// UserID returns the authenticated user's id, or 0 for anonymous requests.
+func UserID(ctx context.Context) int {
+	if ri, ok := ctx.Value(infoKey{}).(*requestInfo); ok {
+		return int(ri.userID.Load())
+	}
+	return 0
+}
+
 // contextHandler decorates any slog.Handler: on every log call made with a
 // context (logger.InfoContext(ctx, ...)) it appends request_id, trace_id and
 // span_id taken from that context. Application code never has to remember to
@@ -31,6 +59,9 @@ func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
 	if id := RequestID(ctx); id != "" {
 		r.AddAttrs(slog.String("request_id", id))
 	}
+	if id := UserID(ctx); id > 0 {
+		r.AddAttrs(slog.Int("user_id", id))
+	}
 	if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
 		r.AddAttrs(slog.String("trace_id", sc.TraceID().String()), slog.String("span_id", sc.SpanID().String()))
 	}
```

**Create** `internal/storage/tokens.go`

```go
package storage

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
)

var (
	// ErrInvalidToken: unknown, expired or already-revoked-by-logout refresh token.
	ErrInvalidToken = errors.New("invalid refresh token")
	// ErrTokenReuse: a token that was already rotated was presented again. That
	// means it was copied (stolen) and used twice, so we revoke the whole session.
	ErrTokenReuse = errors.New("refresh token reuse detected")
)

type TokenStore struct {
	pool *pgxpool.Pool
}

func NewTokenStore(pool *pgxpool.Pool) *TokenStore { return &TokenStore{pool: pool} }

func (s *TokenStore) Create(ctx context.Context, userID int, hash []byte, expires time.Time) error {
	_, err := s.pool.Exec(ctx,
		"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
		userID, hash, expires)
	if err != nil {
		return fmt.Errorf("insert refresh token: %w", err)
	}
	return nil
}

// Rotate atomically exchanges a valid refresh token for a new one.
//
// Rotation = every refresh token is single-use. Why: a stolen token then works
// at most once, and if both the thief and the real user try to use the same
// token, the second attempt reveals the theft (reuse detection).
func (s *TokenStore) Rotate(ctx context.Context, oldHash, newHash []byte, newExpires time.Time) (int, error) {
	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return 0, fmt.Errorf("begin: %w", err)
	}
	defer tx.Rollback(ctx) // no-op after a successful Commit

	var (
		id      int
		userID  int
		expires time.Time
		revoked *time.Time
	)
	// FOR UPDATE locks the row: two concurrent refreshes with the same token
	// are serialised, so exactly one wins and the other is seen as reuse.
	err = tx.QueryRow(ctx,
		"SELECT id, user_id, expires_at, revoked_at FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE",
		oldHash).Scan(&id, &userID, &expires, &revoked)
	if errors.Is(err, pgx.ErrNoRows) {
		return 0, ErrInvalidToken
	}
	if err != nil {
		return 0, fmt.Errorf("lookup refresh token: %w", err)
	}

	if revoked != nil {
		// Reuse: kill every live token of this user, and COMMIT that decision
		// (rolling back would undo the security response).
		if _, err := tx.Exec(ctx,
			"UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", userID); err != nil {
			return 0, fmt.Errorf("revoke family: %w", err)
		}
		if err := tx.Commit(ctx); err != nil {
			return 0, fmt.Errorf("commit: %w", err)
		}
		return userID, ErrTokenReuse
	}
	if time.Now().After(expires) {
		return 0, ErrInvalidToken
	}

	if _, err := tx.Exec(ctx, "UPDATE refresh_tokens SET revoked_at = now() WHERE id = $1", id); err != nil {
		return 0, fmt.Errorf("revoke old: %w", err)
	}
	if _, err := tx.Exec(ctx,
		"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
		userID, newHash, newExpires); err != nil {
		return 0, fmt.Errorf("insert new: %w", err)
	}
	if err := tx.Commit(ctx); err != nil {
		return 0, fmt.Errorf("commit: %w", err)
	}
	return userID, nil
}

// Revoke is logout. Idempotent: revoking an unknown or already-revoked token is not an error.
func (s *TokenStore) Revoke(ctx context.Context, hash []byte) error {
	if _, err := s.pool.Exec(ctx,
		"UPDATE refresh_tokens SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL", hash); err != nil {
		return fmt.Errorf("revoke refresh token: %w", err)
	}
	return nil
}
```

**Change** `internal/storage/users.go`

```diff
--- a/internal/storage/users.go
+++ b/internal/storage/users.go
@@ -7,13 +7,28 @@ import (
 	"time"
 
 	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgconn"
 	"github.com/jackc/pgx/v5/pgxpool"
 )
 
+// ErrEmailTaken: registering an email that already has an account.
+var ErrEmailTaken = errors.New("email already registered")
+
 type User struct {
 	ID        int       `json:"id"`
 	Name      string    `json:"name"`
+	Email     string    `json:"email,omitempty"`
+	Role      string    `json:"role"`
 	CreatedAt time.Time `json:"created_at"`
+	// The password hash is deliberately NOT a field here: if it is not on the
+	// struct we serialise to JSON, it cannot leak through a response by accident.
+}
+
+// Credentials is what login needs; kept separate from User for the reason above.
+type Credentials struct {
+	UserID       int
+	PasswordHash string
+	Role         string
 }
 
 type UserStore struct {
@@ -24,16 +39,24 @@ func NewUserStore(pool *pgxpool.Pool) *UserStore {
 	return &UserStore{pool: pool}
 }
 
-const userCols = "id, name, created_at"
+const userCols = "id, name, COALESCE(email, ''), role, created_at"
 
 func scanUser(row pgx.Row) (*User, error) {
 	u := &User{}
-	if err := row.Scan(&u.ID, &u.Name, &u.CreatedAt); err != nil {
+	if err := row.Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.CreatedAt); err != nil {
 		return nil, err
 	}
 	return u, nil
 }
 
+// isUniqueViolation reports Postgres error 23505. We detect conflicts by the
+// error code rather than "SELECT then INSERT", which would race: two concurrent
+// registrations could both pass the check.
+func isUniqueViolation(err error) bool {
+	var pg *pgconn.PgError
+	return errors.As(err, &pg) && pg.Code == "23505"
+}
+
 // Every method takes a context and passes it to the pool. When the client
 // disconnects or the request deadline passes, the in-flight query is
 // cancelled server-side and the connection returns to the pool.
@@ -87,8 +110,7 @@ func (s *UserStore) Get(ctx context.Context, id int) (*User, error) {
 	return u, nil
 }
 
-// Create uses INSERT ... RETURNING: one round trip returns the row exactly as
-// stored, including database-generated id and created_at.
+// Create adds a user without credentials. Part 12 removes it: accounts are created through registration.
 func (s *UserStore) Create(ctx context.Context, name string) (*User, error) {
 	u, err := scanUser(s.pool.QueryRow(ctx,
 		"INSERT INTO users (name) VALUES ($1) RETURNING "+userCols, name))
@@ -98,6 +120,51 @@ func (s *UserStore) Create(ctx context.Context, name string) (*User, error) {
 	return u, nil
 }
 
+// CreateWithCredentials registers a normal (role=user) account. email must
+// already be normalised (trimmed, lower-cased) and passwordHash already hashed.
+func (s *UserStore) CreateWithCredentials(ctx context.Context, name, email, passwordHash string) (*User, error) {
+	u, err := scanUser(s.pool.QueryRow(ctx,
+		"INSERT INTO users (name, email, password_hash) VALUES ($1, $2, $3) RETURNING "+userCols,
+		name, email, passwordHash))
+	if isUniqueViolation(err) {
+		return nil, ErrEmailTaken
+	}
+	if err != nil {
+		return nil, fmt.Errorf("insert user: %w", err)
+	}
+	return u, nil
+}
+
+// UpsertAdmin creates an admin or promotes/updates an existing account. Used by
+// the `create-admin` command: there is intentionally NO HTTP way to become
+// admin, so a bug in the API cannot grant it.
+func (s *UserStore) UpsertAdmin(ctx context.Context, name, email, passwordHash string) (*User, error) {
+	u, err := scanUser(s.pool.QueryRow(ctx, `
+		INSERT INTO users (name, email, password_hash, role) VALUES ($1, $2, $3, 'admin')
+		ON CONFLICT (lower(email)) WHERE email IS NOT NULL
+		DO UPDATE SET password_hash = EXCLUDED.password_hash, role = 'admin'
+		RETURNING `+userCols, name, email, passwordHash))
+	if err != nil {
+		return nil, fmt.Errorf("upsert admin: %w", err)
+	}
+	return u, nil
+}
+
+// GetCredentials looks a user up by (normalised) email for login.
+func (s *UserStore) GetCredentials(ctx context.Context, email string) (*Credentials, error) {
+	c := &Credentials{}
+	err := s.pool.QueryRow(ctx,
+		"SELECT id, password_hash, role FROM users WHERE lower(email) = $1 AND password_hash IS NOT NULL", email).
+		Scan(&c.UserID, &c.PasswordHash, &c.Role)
+	if errors.Is(err, pgx.ErrNoRows) {
+		return nil, ErrNotFound
+	}
+	if err != nil {
+		return nil, fmt.Errorf("get credentials: %w", err)
+	}
+	return c, nil
+}
+
 func (s *UserStore) Update(ctx context.Context, id int, name string) (*User, error) {
 	u, err := scanUser(s.pool.QueryRow(ctx,
 		"UPDATE users SET name = $1 WHERE id = $2 RETURNING "+userCols, name, id))
```

**Change** `cmd/rest/main.go`

```diff
--- a/cmd/rest/main.go
+++ b/cmd/rest/main.go
@@ -7,13 +7,16 @@ import (
 	"log/slog"
 	"net"
 	"net/http"
+	"net/mail"
 	"os"
 	"os/signal"
+	"strings"
 	"syscall"
 	"time"
 
 	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
 
+	"go-rest/internal/auth"
 	"go-rest/internal/config"
 	"go-rest/internal/handlers"
 	"go-rest/internal/middleware"
@@ -27,6 +30,7 @@ import (
 //
 //	server            run the API (default)
 //	server migrate    apply DB migrations, then exit
+//	server create-admin create/promote an admin from ADMIN_EMAIL + ADMIN_PASSWORD
 //	server healthcheck  probe the local admin port, exit 0/1 (for Docker HEALTHCHECK;
 //	                    the distroless image has no curl/wget)
 func main() {
@@ -41,10 +45,12 @@ func main() {
 		err = serve()
 	case "migrate":
 		err = migrate()
+	case "create-admin":
+		err = createAdmin()
 	case "healthcheck":
 		err = healthcheck()
 	default:
-		err = fmt.Errorf("unknown command %q (want serve|migrate|healthcheck)", cmd)
+		err = fmt.Errorf("unknown command %q (want serve|migrate|create-admin|healthcheck)", cmd)
 	}
 	// run-style functions return errors instead of calling log.Fatal: Fatal
 	// calls os.Exit, which skips deferred cleanup (closing the pool, etc.).
@@ -76,6 +82,55 @@ func migrate() error {
 	return nil
 }
 
+// createAdmin is the ONLY way to obtain the admin role: it needs shell access to
+// the deployment and the database, not just an HTTP request. The password comes
+// from the environment, not argv, because command lines are visible in `ps`.
+func createAdmin() error {
+	cfg, err := config.Load()
+	if err != nil {
+		return err
+	}
+	email, ok := normaliseAdminEmail(os.Getenv("ADMIN_EMAIL"))
+	password := os.Getenv("ADMIN_PASSWORD")
+	if !ok || len(password) < auth.MinPasswordLen || len(password) > auth.MaxPasswordLen {
+		return fmt.Errorf("set ADMIN_EMAIL and ADMIN_PASSWORD (%d-%d bytes)", auth.MinPasswordLen, auth.MaxPasswordLen)
+	}
+	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+	defer stop()
+	pool, err := storage.NewPool(ctx, poolConfig(cfg))
+	if err != nil {
+		return err
+	}
+	defer pool.Close()
+
+	hasher, err := auth.NewHasher(cfg.BcryptCost)
+	if err != nil {
+		return err
+	}
+	hash, err := hasher.Hash(password)
+	if err != nil {
+		return err
+	}
+	name := os.Getenv("ADMIN_NAME")
+	if name == "" {
+		name = "admin"
+	}
+	u, err := storage.NewUserStore(pool).UpsertAdmin(ctx, name, email, hash)
+	if err != nil {
+		return err
+	}
+	slog.Info("admin ready", "user_id", u.ID, "email", u.Email)
+	return nil
+}
+
+func normaliseAdminEmail(s string) (string, bool) {
+	a, err := mail.ParseAddress(strings.ToLower(strings.TrimSpace(s)))
+	if err != nil {
+		return "", false
+	}
+	return a.Address, true
+}
+
 func healthcheck() error {
 	addr := os.Getenv("ADMIN_ADDR")
 	if addr == "" {
@@ -113,6 +168,9 @@ func serve() error {
 	if err != nil {
 		return err
 	}
+	if err := cfg.ValidateServe(); err != nil {
+		return err
+	}
 	log := observability.NewLogger(os.Stdout, cfg.LogLevel, cfg.ServiceName)
 	slog.SetDefault(log)
 
@@ -141,10 +199,22 @@ func serve() error {
 		DefaultPageSize: cfg.DefaultPageSize,
 		MaxPageSize:     cfg.MaxPageSize,
 	}
-	users := handlers.NewUserHandler(storage.NewUserStore(pool), log, limits)
-	orders := handlers.NewOrderHandler(storage.NewOrderStore(pool), log, limits)
+	tm, err := auth.NewTokenManager(cfg.JWTSecret, cfg.ServiceName, cfg.AccessTokenTTL)
+	if err != nil {
+		return err
+	}
+	hasher, err := auth.NewHasher(cfg.BcryptCost)
+	if err != nil {
+		return err
+	}
+	userStore := storage.NewUserStore(pool)
 	mux := http.NewServeMux()
-	routes.SetupRoutes(mux, users, orders)
+	routes.SetupRoutes(mux, routes.Deps{
+		Auth:         handlers.NewAuthHandler(userStore, storage.NewTokenStore(pool), hasher, tm, cfg.RefreshTokenTTL, log, limits),
+		Users:        handlers.NewUserHandler(userStore, log, limits),
+		Orders:       handlers.NewOrderHandler(storage.NewOrderStore(pool), log, limits),
+		Authenticate: middleware.Authenticate(tm, log),
+	})
 
 	// Rate limiting: one bucket per client IP, applied to every API request.
 	var limiters []func(http.Handler) http.Handler
```

**Replace the contents of** `.env.example` **with**

```plaintext
# Copy to .env (git-ignored) and change values. docker compose reads it automatically.
POSTGRES_USER=app
POSTGRES_PASSWORD=change-me
POSTGRES_DB=app
GRAFANA_ADMIN_PASSWORD=change-me
# JWT_SECRET signs access tokens. Generate: openssl rand -hex 32   (`make .env` does this for you)
# JWT_SECRET=
```

**Change** `docker-compose.yml`

```diff
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -43,6 +43,8 @@ services:
     environment:
       <<: *dburl
       ADDR: ":7800"
+      # Required, >= 32 bytes. `make up` generates one into .env on first run.
+      JWT_SECRET: ${JWT_SECRET:?JWT_SECRET missing; run `make .env` or set it in .env}
       ADMIN_ADDR: ":9090"
       OTLP_ENDPOINT: jaeger:4317
       LOG_LEVEL: info
```

**Change** `makefile`

```diff
--- a/makefile
+++ b/makefile
@@ -1,8 +1,10 @@
-.PHONY: db run build test test-integration up down logs reset migrate psql load clean
+.PHONY: admin db run build test test-integration up down logs reset migrate psql load clean
 
 BINARY := bin/server
 # Local (non-Docker) default; matches the compose Postgres published on localhost.
 export DATABASE_URL ?= postgres://app:change-me@localhost:5432/app?sslmode=disable
+# Local `make run` needs a secret too; any 32+ byte value works for development.
+export JWT_SECRET ?= dev-only-secret-dev-only-secret-0123456789
 TEST_DATABASE_URL ?= $(DATABASE_URL)
 
 # ---- Go ----------------------------------------------------------------------
@@ -28,7 +30,15 @@ test-integration:         ## all tests incl. database tests, against compose Pos
 db:                       ## only Postgres (for running the API with `make run`)
 	docker compose up -d --wait postgres
 
-up:                       ## whole stack: API + Postgres + Prometheus + Grafana + Jaeger + Loki
+.env:                     ## create .env with a fresh random JWT secret (never overwrites)
+	cp .env.example .env
+	echo "JWT_SECRET=$$(openssl rand -hex 32)" >> .env
+	@echo "created .env"
+
+admin:                    ## create/promote an admin:  ADMIN_EMAIL=a@b.co ADMIN_PASSWORD=... make admin
+	docker compose run --rm --no-deps -e ADMIN_EMAIL -e ADMIN_PASSWORD api create-admin
+
+up: .env                  ## whole stack: API + Postgres + Prometheus + Grafana + Jaeger + Loki
 	docker compose up -d --build --wait
 
 down:                     ## stop, KEEP data
```

**Replace the contents of** `scripts/load.sh` **with**

```bash
#!/usr/bin/env bash
# Generates mixed traffic (including errors) so the dashboards have something to show.
# Registers a throwaway user per run, then uses its token.
set -euo pipefail
API=${API:-http://localhost:7800}
EMAIL="load$RANDOM$RANDOM@example.com"
TOKEN=$(curl -s -XPOST "$API/auth/register" \
  -d "{\"name\":\"load\",\"email\":\"$EMAIL\",\"password\":\"correct-horse-battery\"}" |
  python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])")
H="Authorization: Bearer $TOKEN"
for i in $(seq 1 ${N:-100}); do
  curl -s -o /dev/null -H "$H" -XPOST "$API/orders" -d '{"item":"book","quantity":2}'
  curl -s -o /dev/null -H "$H" "$API/orders"
  curl -s -o /dev/null -H "$H" "$API/orders/$((RANDOM % 500 + 1))"        # mostly 404s
  curl -s -o /dev/null -H "$H" -XPOST "$API/orders" -d '{"item":""}'      # 422s
  curl -s -o /dev/null "$API/orders"                                      # 401s
done
echo "done (user $EMAIL)"
```

## Try it

```bash
$ curl -i localhost:7800/orders
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api"
{"error":"authentication required"}

$ curl -XPOST localhost:7800/auth/register \
    -d '{"name":"Ann","email":"ann@example.com","password":"correct-horse-battery"}'
{"access_token":"eyJhbGciOi...","token_type":"Bearer","expires_in":900,"refresh_token":"rss1nfESTvZ...","user":{"id":1,"name":"Ann","email":"ann@example.com","role":"user","created_at":"2026-10-24T02:08:14.956793+05:30"}}

$ curl -XPOST localhost:7800/orders -H "Authorization: Bearer $TOKEN" -d '{"user_id":1,"item":"book","quantity":2}'
{"id":1,"user_id":1,"item":"book","quantity":2,"created_at":"2026-10-24T02:08:15.059174+05:30"}
```

Now watch rotation and theft detection:

```bash
# refresh once: works, and returns a NEW refresh token
$ curl -XPOST localhost:7800/auth/refresh -d "{\"refresh_token\":\"$RT\"}"
{"access_token":"...","refresh_token":"vC2TAvOt...", ...}

# replay the OLD refresh token (as a thief would):
$ curl -i -XPOST localhost:7800/auth/refresh -d "{\"refresh_token\":\"$RT\"}"
HTTP/1.1 401 Unauthorized
{"error":"invalid refresh token"}

# even the NEW one is now dead: the whole session was revoked
$ curl -i -XPOST localhost:7800/auth/refresh -d "{\"refresh_token\":\"vC2TAvOt...\"}"
HTTP/1.1 401 Unauthorized
```

The server log for the replay says: `refresh token reuse detected; all sessions revoked`.

### The uncomfortable part

Everything above works. Now register a **second** user, Bob, and see what a logged-in stranger can do with Ann's data:

```bash
# Bob (a different account, with his own valid token) reads Ann's order...
$ curl -w ' [HTTP %{http_code}]\n' localhost:7800/orders/1 -H "Authorization: Bearer $BOB"
{"id":1,"user_id":1,"item":"book","quantity":2,"created_at":"2026-10-24T02:08:15.059174+05:30"} [HTTP 200]

# ...deletes it...
$ curl -o /dev/null -w '[HTTP %{http_code}]\n' -XDELETE localhost:7800/orders/1 -H "Authorization: Bearer $BOB"
[HTTP 204]

# ...and lists every user in the system.
$ curl localhost:7800/users -H "Authorization: Bearer $BOB"
{"data":[{"id":1,"name":"Ann","email":"ann@example.com", ...},{"id":2,"name":"Bob", ...}]}
```

Authentication answered *who is calling*. Nothing yet decides *what they may touch*. This family of bug has a name, **IDOR** (insecure direct object reference), and it tops the list of API vulnerabilities. We fix it properly in the next post.

## What changed in the API

Every endpoint except `/auth/register`, `/auth/login`, `/auth/refresh` and `/auth/logout` now requires a bearer token (a missing or bad one is `401`), and users gain `email` and `role` fields. Nothing else about the request shapes changed yet: `POST /orders` still carries a `user_id` and `POST /users` still exists. Both are loose ends for Part 12.

## What can still go wrong (be honest)

- **Any logged-in user can read and change anyone's data** (see "The uncomfortable part"). Part 12.
- Tokens are bearer credentials: whoever holds one can use it. **They must travel over TLS** (Part 14).
- No email verification, password reset or MFA.
- For browser apps, keeping tokens in `localStorage` exposes them to XSS; prefer an HttpOnly, SameSite cookie there (which needs CSRF protection). This series targets non-browser API clients.
- Password guessing is only limited by the per-IP limit so far. Part 13 fixes that.

## Recap

- Hash passwords with bcrypt; never serialize the hash; bound input length.
- JWTs are signed, not encrypted; pin the algorithm, require `exp`, check the issuer, keep the secret strong.
- Short-lived access tokens + revocable, rotating, hashed refresh tokens with reuse detection.
- Don't reveal which accounts exist: same message *and* same timing.
- Detect duplicates with the database's unique constraint, not a pre-check.
- No HTTP path to admin.

## Exercises

1. Decode your own token with the [jwt.io](http://jwt.io) debugger. Change `"role":"user"` to `"admin"` and paste it back: what does the server say, and which line of code made that happen?
2. Reduce `ACCESS_TOKEN_TTL` to `10s`, log in, wait, and call `/auth/me`. Then refresh and retry.
3. Time `POST /auth/login` for an existing and a non-existent email with `curl -w '%{time_total}'`. Comment out `BurnTime` and repeat. Do you see the difference?
