Skip to content

Authentication

This is the page worth reading. Everything else in labapp is scaffolding around two questions: how a password is stored, and what a logged-in session actually is.

Passwords are hashed with argon2id, the memory-hard function that won the Password Hashing Competition and is the current default recommendation. Memory hardness is the property that matters: bcrypt is expensive in time, which GPUs parallelise well, while argon2id is expensive in memory, which they do not.

Each hash is stored as a PHC string that describes how it was made:

$argon2id$v=19$m=65536,t=3,p=2$c29tZXNhbHQ$RdescudvJCsgt3ub+b+dWRWJTmaaJObG
│ │ │ │ │
│ │ │ │ └─ 32-byte derived key
│ │ │ └─ 16 random bytes of salt
│ │ └─ 64 MiB memory, 3 passes, 2 lanes
│ └─ argon2 version 19
└─ variant

Two consequences follow from the parameters living inside the hash rather than in the code.

The salt is per-password and stored in the clear. That is what it is for: salting means two users with the same password get different hashes, so an attacker cannot precompute one table and crack everyone at once. It is not a secret.

Parameters can be raised without a migration. Verification reads the cost from the stored string, so old hashes keep working. On a successful login the service checks whether the hash was made with the current parameters, and if not it silently recomputes and stores a stronger one. Users are upgraded as they appear, and nobody is locked out:

internal/auth/service.go
ok, err := VerifyPassword(password, u.PasswordHash)
if err != nil || !ok || u.Status != "active" {
return "", Session{}, ErrInvalidCredentials
}
s.maybeRehash(ctx, u, password) // best-effort: never blocks a valid login

64 MiB of memory, three passes, two lanes — about 40–60 ms per hash on the cluster’s hardware. The tuning is a straight trade against yourself: high enough to make offline cracking expensive if the database ever leaks, low enough that a burst of logins cannot exhaust a pod’s memory. At 64 MiB per concurrent hash, the container’s memory limit has to account for the login path, which is why it is set well above what an idle Go service needs.

Password policy follows NIST SP 800-63B: a length floor of 8 bytes, a ceiling of 512 to bound the work an attacker can ask for, and no composition rules and no forced rotation. Both of those make passwords worse in practice by pushing people toward predictable mutations.

A session token is 32 bytes from the operating system’s cryptographic random source, base64url-encoded. It carries no structure and means nothing on its own — it is a lookup key.

The database never stores it. It stores the SHA-256 of it:

internal/auth/token.go
raw := make([]byte, 32)
rand.Read(raw)
token = base64.RawURLEncoding.EncodeToString(raw)
return token, HashToken(token), nil // sha256 of the token is what is persisted

So a stolen database dump contains no usable credentials of any kind: the passwords are argon2id hashes, and the sessions are one-way hashes of tokens that only ever existed in transit and in the client. Verification is a single indexed lookup on the hash.

Tokens are delivered two ways from the same login response: a cookie marked HttpOnly, Secure and SameSite=Lax for browsers, and the raw token in the JSON body for API clients to send as Authorization: Bearer. HttpOnly keeps the cookie out of reach of JavaScript, so an XSS bug cannot read the session; SameSite=Lax means it is not attached to cross-site form posts, which frustrates CSRF.

Sessions last seven days and are revoked by deleting the row — logout is immediate and complete, with no window in which a “revoked” credential still verifies. That property is the entire argument for opaque tokens over JWTs, and it is discussed further in design decisions.

Three separate leaks are possible at login, and each needs its own answer.

The response must not reveal whether an account exists. An unknown email, a wrong password, and a disabled account all produce the identical body:

{"error": "invalid_credentials", "message": "email or password is incorrect"}

Neither must the timing. If an unknown email returned early while a real one paid for an argon2 verification, the 50 ms difference would be a reliable account oracle — measurable across a network. So when the email is not found, the service verifies the supplied password against a decoy hash and discards the result. Both paths do the same work:

internal/auth/service.go
if errors.Is(err, ErrUserNotFound) {
_, _ = VerifyPassword(password, decoyHash) // burn the same time
return "", Session{}, ErrInvalidCredentials
}

Signup is the honest exception. Registering an email that already exists returns 409, which does admit the address is taken. Hiding it would mean either accepting a silent no-op or sending a confirmation email, and with no mail path in the lab, the usable option wins. It is a deliberate, bounded concession — and the reason login’s uniformity is defended so carefully.

Login is limited by token bucket on two independent keys: 10 attempts per minute per client address, and 5 per minute per account. The per-account limit is what actually stops password guessing; the per-address limit stops one host from spraying many accounts. Exceeding either yields 429 with a Retry-After header.

The limiter is in-memory, which means the budget multiplies by the replica count. Measured against the live two-replica deployment, refusals begin on the eleventh rapid attempt rather than the sixth — exactly the doubling the design predicts:

Terminal window
for i in $(seq 1 12); do curl -s -o /dev/null -w '%{http_code} ' … ; done
# 401 401 401 401 401 401 429 429 429 429 429 429

Ten guesses a minute against a password with any entropy at all is not a meaningful attack, so the simplification holds at this scale. It stops holding the moment the replica count grows or the service faces the public internet; the fix is a shared limiter in Redis or at the ingress, and it is a known future cost rather than a surprise.

Naming the gaps is part of the design. There is no email verification or password reset, because there is no mail path yet — and a reset flow is a credential-issuing mechanism that deserves its own careful design rather than an afterthought. There is no multi-factor authentication, no federation, and no breached-password screening. Sessions do not slide: seven days after login you log in again, because renewal policy is easier to choose with real usage data than in advance.

The honest summary is that this is a study of the core mechanics done carefully, not a complete identity system. The parts that exist are the parts I wanted to understand from the inside.