Natnael Alemayehu
← All writing

Password authentication and server-side sessions

26 Oct 202615 min read#go#auth#postgres

After Part 3 the notes API had a tested data layer, migrations that run themselves, and a small production image. It also had a big hole: nothing checked who was calling it. This part adds password login and sessions stored in Postgres.

Sessions instead of JWTs

Chirpy uses JWTs with refresh tokens, and that works fine. It also means two kinds of tokens, a signing key to look after, and access tokens you can’t take back until they expire.

Here I’m using plain server-side sessions instead. The client holds a random token in a cookie, and a row in Postgres says who that token belongs to. Logging someone out is a DELETE. The price is one database query per authenticated request, and with a connection pool sitting next to the app that’s a price I barely notice.

Adding a password to users

The users table from Part 2 has no password column. Add one in a new migration instead of editing the old one, since migrations are a record of what happened:

sql/schema/00003_add_password_hash.sql
-- +goose Up
ALTER TABLE users
    ADD COLUMN password_hash TEXT NOT NULL DEFAULT '';

ALTER TABLE users
    ALTER COLUMN password_hash DROP DEFAULT;

-- +goose Down
ALTER TABLE users DROP COLUMN password_hash;

The add-with-a-default-then-drop-it dance only matters if the table already has rows. Postgres won’t add a NOT NULL column to existing rows without some value to put in them. On an empty dev database you could skip it, but I like migrations that still work against a database with data in it, because that’s the only kind of database a production deploy ever sees.

A sessions table

sql/schema/00004_create_sessions.sql
-- +goose Up
CREATE TABLE sessions (
    id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id    UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,
    token_hash TEXT NOT NULL UNIQUE,
    expires_at TIMESTAMPTZ NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX sessions_user_id_idx ON sessions (user_id);

-- +goose Down
DROP TABLE sessions;

The column is token_hash, not token. Whoever holds a session token is logged in as that user, so if a database backup ever leaks, a table of raw tokens hands out every active login. A table of hashes gives an attacker nothing they can use. It’s the same reason you don’t store passwords in plain text.

The UNIQUE also gives you the index that every authenticated request will use to find its session.

Hashing passwords with Argon2id

terminal
go get golang.org/x/crypto/argon2

Argon2id is what OWASP recommends for passwords. It’s deliberately expensive in memory as well as CPU, which makes it slow to brute-force on GPUs. The settings below use 64 MiB per hash, three passes and two threads. Tune them to your server, because every login pays that cost.

internal/auth/password.go
package auth

import (
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"errors"
	"fmt"
	"strings"

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

type params struct {
	memory      uint32
	iterations  uint32
	parallelism uint8
	saltLength  uint32
	keyLength   uint32
}

var defaultParams = params{
	memory:      64 * 1024, // 64 MiB
	iterations:  3,
	parallelism: 2,
	saltLength:  16,
	keyLength:   32,
}

// HashPassword returns one string holding the algorithm, its parameters, the
// salt and the hash. Keeping the parameters next to the hash means you can
// raise them later without breaking the passwords you already stored.
func HashPassword(password string) (string, error) {
	salt := make([]byte, defaultParams.saltLength)
	if _, err := rand.Read(salt); err != nil {
		return "", fmt.Errorf("generate salt: %w", err)
	}

	hash := argon2.IDKey(
		[]byte(password), salt,
		defaultParams.iterations, defaultParams.memory, defaultParams.parallelism, defaultParams.keyLength,
	)

	return fmt.Sprintf(
		"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, defaultParams.memory, defaultParams.iterations, defaultParams.parallelism,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(hash),
	), nil
}

Checking a password means pulling those parameters back out of the stored string, hashing the attempt the same way and comparing the results:

internal/auth/password.go
var ErrInvalidHash = errors.New("invalid password hash format")

func VerifyPassword(password, encoded string) (bool, error) {
	parts := strings.Split(encoded, "$")
	if len(parts) != 6 || parts[1] != "argon2id" {
		return false, ErrInvalidHash
	}

	var version int
	if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version {
		return false, ErrInvalidHash
	}

	var p params
	if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism); err != nil {
		return false, ErrInvalidHash
	}

	salt, err := base64.RawStdEncoding.DecodeString(parts[4])
	if err != nil {
		return false, ErrInvalidHash
	}
	want, err := base64.RawStdEncoding.DecodeString(parts[5])
	if err != nil {
		return false, ErrInvalidHash
	}

	got := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, uint32(len(want)))

	return subtle.ConstantTimeCompare(got, want) == 1, nil
}

subtle.ConstantTimeCompare takes the same time no matter where the two values differ. A plain bytes.Equal stops at the first wrong byte, which in theory leaks information through timing. Exploiting that over a network is very hard, but using the constant-time version costs nothing.

The session token

internal/auth/session.go
package auth

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"fmt"
)

// NewToken returns 32 random bytes, URL-safe encoded.
func NewToken() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("generate token: %w", err)
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// Hash is what we store in sessions.token_hash.
func Hash(token string) string {
	sum := sha256.Sum256([]byte(token))
	return hex.EncodeToString(sum[:])
}

You might wonder why the token gets plain SHA-256 when the password got Argon2id. Passwords need a slow hash because people pick guessable ones, and the slowness is what makes guessing expensive. A session token is 256 random bits. Nobody is guessing that, so a fast hash is the right tool, and a slow one would just add latency to every request.

Registering and logging in

CreateUser from Part 2 only took an email and a name. Add the hash to the query and run sqlc generate again:

sql/queries/users.sql
-- name: CreateUser :one
INSERT INTO users (email, name, password_hash)
VALUES ($1, $2, $3)
RETURNING *;

That changes the generated CreateUserParams, so the createUser handler from Part 2 no longer does what we want. Replace it with register, which hashes the password before it goes anywhere near the database:

cmd/api/auth.go
func (a *api) register(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Email    string `json:"email"`
		Name     string `json:"name"`
		Password string `json:"password"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}
	if len(in.Password) < 8 {
		http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
		return
	}

	hash, err := auth.HashPassword(in.Password)
	if err != nil {
		http.Error(w, "could not process password", http.StatusInternalServerError)
		return
	}

	user, err := a.q.CreateUser(r.Context(), database.CreateUserParams{
		Email:        in.Email,
		Name:         in.Name,
		PasswordHash: hash,
	})

	var pgErr *pgconn.PgError
	switch {
	case errors.As(err, &pgErr) && pgErr.Code == "23505":
		http.Error(w, "email is already registered", http.StatusConflict)
		return
	case err != nil:
		http.Error(w, "could not create user", http.StatusInternalServerError)
		return
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(struct {
		ID    uuid.UUID `json:"id"`
		Email string    `json:"email"`
		Name  string    `json:"name"`
	}{user.ID, user.Email, user.Name})
}

The response is a small struct I wrote by hand, not the generated User. That type now has a PasswordHash field, and json.Marshal will happily send it to the client. I’d rather make it impossible than rely on remembering.

The same field makes getUser from Part 2 dangerous. It encodes the generated User directly, so GET /users/{email} would now hand any user’s password hash to anyone who asks, without logging in. It also answers 404 for an email with no account, which is exactly the leak the login handler below goes out of its way to avoid. Nothing in the rest of the series needs it, so delete the getUser handler and its route.

Logging in checks the password and starts a session:

cmd/api/auth.go
const sessionTTL = 24 * time.Hour

// A real hash of a throwaway password, computed once. Login verifies against
// it when the email doesn't exist, so an unknown email takes as long to
// reject as a wrong password does.
var dummyHash = func() string {
	hash, err := auth.HashPassword("not-anybody's-password")
	if err != nil {
		panic(err)
	}
	return hash
}()

func (a *api) login(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Email    string `json:"email"`
		Password string `json:"password"`
	}
	if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}

	user, err := a.q.GetUserByEmail(r.Context(), in.Email)
	found := err == nil
	if err != nil && !errors.Is(err, pgx.ErrNoRows) {
		http.Error(w, "could not log in", http.StatusInternalServerError)
		return
	}

	hash := dummyHash
	if found {
		hash = user.PasswordHash
	}
	ok, err := auth.VerifyPassword(in.Password, hash)
	if err != nil {
		http.Error(w, "could not log in", http.StatusInternalServerError)
		return
	}
	if !ok || !found {
		http.Error(w, "invalid email or password", http.StatusUnauthorized)
		return
	}

	token, err := auth.NewToken()
	if err != nil {
		http.Error(w, "could not start session", http.StatusInternalServerError)
		return
	}
	expiresAt := time.Now().Add(sessionTTL)

	if _, err := a.q.CreateSession(r.Context(), database.CreateSessionParams{
		UserID:    user.ID,
		TokenHash: auth.Hash(token),
		ExpiresAt: expiresAt,
	}); err != nil {
		http.Error(w, "could not start session", http.StatusInternalServerError)
		return
	}

	http.SetCookie(w, &http.Cookie{
		Name:     "session",
		Value:    token,
		Path:     "/",
		Expires:  expiresAt,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	})
	w.WriteHeader(http.StatusNoContent)
}

A wrong password and an unknown email get the same 401 and the same message. If they didn’t, anyone could find out which emails have accounts by trying them one at a time. The dummy hash handles the timing side of the same leak: without it, an unknown email would come back instantly while a real one spent a few hundred milliseconds in Argon2.

The three cookie flags each cover something specific. HttpOnly keeps JavaScript away from the token, so an XSS bug can’t steal it. Secure stops the browser sending it over plain HTTP. SameSite=Lax keeps it off most cross-site requests, which blunts CSRF. One catch for local development: Chrome and Firefox treat localhost as secure and keep Secure cookies on plain HTTP. Other clients may not, so if a test never sends the cookie back, that’s the first thing to check.

Requiring a session

sql/queries/sessions.sql
-- name: CreateSession :one
INSERT INTO sessions (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
RETURNING *;

-- name: GetSessionByTokenHash :one
SELECT * FROM sessions
WHERE token_hash = $1 AND expires_at > now();

-- name: DeleteSession :exec
DELETE FROM sessions WHERE token_hash = $1;

The expiry check lives in the query instead of in Go. An expired session then looks exactly like a missing one (pgx.ErrNoRows), so the middleware only has one failure to handle.

cmd/api/middleware.go
type ctxKey int

const userIDKey ctxKey = iota

func (a *api) requireAuth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		cookie, err := r.Cookie("session")
		if err != nil {
			http.Error(w, "not authenticated", http.StatusUnauthorized)
			return
		}

		session, err := a.q.GetSessionByTokenHash(r.Context(), auth.Hash(cookie.Value))
		if errors.Is(err, pgx.ErrNoRows) {
			http.Error(w, "session expired or invalid", http.StatusUnauthorized)
			return
		}
		if err != nil {
			http.Error(w, "could not verify session", http.StatusInternalServerError)
			return
		}

		ctx := context.WithValue(r.Context(), userIDKey, session.UserID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

Wrap individual handlers rather than the whole mux, so register and login stay reachable without a session:

cmd/api/main.go
mux.HandleFunc("POST /register", a.register)
mux.HandleFunc("POST /login", a.login)
mux.HandleFunc("POST /logout", a.logout)

mux.Handle("GET /notes", a.requireAuth(http.HandlerFunc(a.listNotes)))
mux.Handle("POST /notes", a.requireAuth(http.HandlerFunc(a.createNote)))

Logging out

cmd/api/auth.go
func (a *api) logout(w http.ResponseWriter, r *http.Request) {
	if cookie, err := r.Cookie("session"); err == nil {
		// If the session is already gone there's nothing useful to tell the
		// client, so the error is ignored.
		_ = a.q.DeleteSession(r.Context(), auth.Hash(cookie.Value))
	}

	http.SetCookie(w, &http.Cookie{
		Name:     "session",
		Value:    "",
		Path:     "/",
		Expires:  time.Unix(0, 0),
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	})
	w.WriteHeader(http.StatusNoContent)
}

Deleting the row matters more than clearing the cookie. Clearing the cookie only logs out this browser. Deleting the row kills the token everywhere, including any copy an attacker might be holding.

Next up

Every request now knows who it’s from, but requireAuth only proves that somebody is logged in. It doesn’t check that they’re allowed to see the note they asked for. Part 5 adds that check.