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
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-256is 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.) Verifyis constant-time: bcrypt's comparison doesn't leak how many characters matched.BurnTimelooks 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
Usertype 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.comisann@x.com). The index is partial (WHERE email IS NOT NULL) because rows created before this migration have no email.rolewith aCHECKconstraint: onlyuseroradmin.refresh_tokensstorestoken_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.Useris 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 separateCredentialstype that is never serialized. - Duplicate emails are detected by the error, not by a pre-check. Doing
SELECTthenINSERThas a race: two simultaneous registrations could both pass the check. Instead weINSERTand translate PostgreSQL error23505(unique violation) intoErrEmailTaken. 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.
- 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.
- If the attacker refreshes first, the real user's next refresh trips the same wire.
Implementation details that matter:
SELECT ... FOR UPDATElocks 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.ParseAddressplus thea.Address != scomparison accepts only a bare address (ann@example.com), notEve <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, andregisterInputhas 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:
- Different messages ("no such user" vs "wrong password"): we use one message,
invalid credentials, for both. - 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.
BurnTimemakes 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 aPrincipal(user id + role) in the request context. Handlers read it withauth.FromContext. The reason for a rejection goes to the debug log; the client only learns "invalid or expired token". WWW-Authenticate: Beareris 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
localStorageexposes 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
- 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? - Reduce
ACCESS_TOKEN_TTLto10s, log in, wait, and call/auth/me. Then refresh and retry. - Time
POST /auth/loginfor an existing and a non-existent email withcurl -w '%{time_total}'. Comment outBurnTimeand repeat. Do you see the difference?
Comments
Join the discussionNo comments yet. Be the first to comment.
Build a production-grade REST API in Go
A hands-on series: start with under 200 lines of in-memory CRUD and end with Postgres, JWT auth, rate limiting, TLS and a full observability stack.