the part of the blog you never see

June 22, 2026 meta devloggo

the part nobody looks at

Everyone who's ever looked at this blog has seen the header, the pixel mountain, the birds. Nobody has ever looked at the login page and thought "nice." It's a username field, a password field, and a button. That's fine, it's supposed to be boring. What's not boring, at least to me, is everything sitting behind that button.

I built the accounts system as its own separate Go service, auth, that knows nothing about posts or comments, just users, passwords, and tokens. The blog service asks it "is this person allowed to do this" and that's the entire relationship between the two. Splitting it out felt like overkill for a one-person blog with basically no users, but I wanted to actually understand how a real auth system gets put together instead of importing some package and trusting it blindly.

request path into the auth service

passwords: what hashing actually buys you

First real decision: how do you store a password. My first instinct, the embarrassing one, was something like this:

// what I almost did, first instinct
func hash(password string) string {
	sum := sha256.Sum256([]byte(password))
	return hex.EncodeToString(sum[:])
}

Looks reasonable if nobody's ever explained this to you. SHA-256 is a real hash function, fast, fixed-size output. The problem is exactly that it's fast. A plain SHA-256 hash gets brute-forced at billions of guesses a second on a decent GPU, because nothing slows the attacker down, and if two users pick the same password they get the exact same hash, which leaks that fact to anyone who ever sees the database.

What you actually want is something deliberately slow and memory-hungry, slow enough that legitimate logins barely notice but brute force gets crushed. That's Argon2. Here's what auth actually does:

const (
	argonMemory      = 64 * 1024
	argonIterations  = 3
	argonParallelism = 2
	saltLength       = 16
	keyLength        = 32
)

func Hash(plain string) (string, error) {
	salt := make([]byte, saltLength)
	if _, err := rand.Read(salt); err != nil {
		return "", err
	}
	key := argon2.IDKey([]byte(plain), salt, argonIterations, argonMemory, argonParallelism, keyLength)
	encoded := fmt.Sprintf(
		"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
		argon2.Version, argonMemory, argonIterations, argonParallelism,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(key),
	)
	return encoded, nil
}

Every password gets its own random salt so identical passwords never produce identical hashes, and the memory/iteration numbers tune how expensive one guess is, on purpose. I didn't write the Argon2 implementation myself, that comes straight from golang.org/x/crypto, and that's deliberate too. Crypto primitives are exactly the kind of thing you don't hand-roll even once you understand the theory, because the theory was never where the bugs hide, the implementation is.

The part that actually surprised me was the dummy hash trick in login:

user, err := s.Queries.GetUserByUsername(ctx, req.Username)
if err != nil {
	if errors.Is(err, pgx.ErrNoRows) {
		// Verify password against dummy hash to prevent timing-based username enumeration.
		_, _ = password.Verify(req.Password, dummyHash)
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}
	...
}

You'd think a missing username can just bail out immediately. But hashing takes a measurable amount of time, and if a wrong username returns instantly while a wrong password takes 80ms because it ran the real Argon2 check, an attacker can time your responses and figure out which usernames actually exist on the site. So even a login for a username that isn't real still burns the CPU checking against a fake hash, just so the timing looks identical either way. Never would have thought of that on my own.

tokens and cookies, which confused me for a while

This was the part where I kept mixing things up. I knew JWTs were "a token with stuff encoded in it" but I didn't get why you'd need two different kinds, or why one lives in a cookie handled differently than the other.

What auth actually issues on login is two things: a short-lived access token (15 minutes, signed with an EdDSA key so any service can verify it without calling auth back over the network) and a long-lived refresh token (30 days) that only auth itself ever checks against the database. Both ride home as httponly cookies, meaning JavaScript in the browser can't read either one, only the browser sends them back automatically.

func (s *Server) newRefreshCookie(rawToken string, maxAge time.Duration) *http.Cookie {
	return &http.Cookie{
		Name:     "refresh_token",
		Value:    rawToken,
		Path:     "/auth",
		HttpOnly: true,
		Secure:   s.CookieSecure,
		SameSite: http.SameSiteStrictMode,
		MaxAge:   int(maxAge.Seconds()),
	}
}

Two tokens instead of one is entirely about blast radius. The access token gets sent on every single request, so it has to be something any service can check cheaply: verify a signature against a public key, no database round trip needed. But if that same long-lived credential leaked, it would matter a lot more. So the thing that actually lasts a month never leaves auth's own database check, and the thing that flies around on every request only lives 15 minutes.

refresh tokens rotate, and reuse means something's wrong

Every time the frontend calls /auth/refresh, the old refresh token gets marked used and a brand new one gets issued in its place, a chain. This is the part that made me actually understand why rotation matters instead of just knowing the word for it: if somebody steals a refresh token cookie and tries to use it after the real owner already rotated past it, that's not just an expired token, that's evidence the token got stolen.

if row.RevokedAt.Valid {
	if row.ReplacedBy.Valid {
		// This token was rotated away by a normal refresh, and someone
		// is now replaying that old link in the chain. Assume the
		// chain is compromised and kill every active refresh token for
		// this user.
		if err := s.Queries.RevokeAllUserRefreshTokens(ctx, row.UserID); err != nil {
			...
		}
		http.SetCookie(w, s.clearRefreshCookie())
		writeError(w, r, http.StatusUnauthorized, "invalid_refresh_token", "refresh token reuse detected")
		return
	}
	...
}

If a revoked token still has a ReplacedBy, someone is presenting a link further back in the chain than the one the real session already moved past, and that can't happen for a legitimate single user. So the response isn't "log in again," it's "kill every session this account has," logging the real owner out too as collateral damage. Felt harsh writing that the first time, then I actually thought about it and that's exactly the point.

rate limiting, the boring but necessary part

Last piece: login and register both sit behind a simple in-memory token bucket, 10 requests a minute per IP address.

func New(perMinute int) *Limiter {
	return &Limiter{
		buckets: make(map[string]*bucket),
		rate:    float64(perMinute) / 60.0,
		burst:   float64(perMinute),
	}
}

func (l *Limiter) Allow(key string) bool {
	l.mu.Lock()
	defer l.mu.Unlock()

	now := time.Now()
	b, ok := l.buckets[key]
	if !ok {
		l.buckets[key] = &bucket{tokens: l.burst - 1, lastSeen: now}
		return true
	}

	elapsed := now.Sub(b.lastSeen).Seconds()
	b.tokens += elapsed * l.rate
	if b.tokens > l.burst {
		b.tokens = l.burst
	}
	b.lastSeen = now

	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

Nothing fancy, a map in memory that resets if the process restarts, which is completely fine for what it actually needs to stop: someone hammering the login endpoint trying passwords. The bucket key is the caller's IP, which only works correctly because the frontend resolves the real client IP and passes it through cleanly instead of every visitor showing up as the frontend container's own address. Took me an annoyingly long time to even notice that bug existed, everyone sharing one bucket because they all looked like the same IP to auth, but that's really a frontend proxy problem and belongs in a different post.

That's the whole login button. Boring on purpose.

0 comments

Log in to comment.

Log in

No account?