Skip to main content

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

Updated 49 min readMarkdown
Part of seriesBuild a production-grade REST API in Go

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

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.
go get golang.org/x/crypto

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


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

// 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

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

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:

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

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

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)

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

internal/storage/tokens.go (excerpt)

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

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

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)

// 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

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)

// 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
}
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

-- +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

// 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

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

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

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

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

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

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

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

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

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

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

# 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

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

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

#!/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

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

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

# 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 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?

No comments yet. Be the first to comment.

More from this blog