Skip to content

Design decisions

The repository keeps short architecture decision records. This is the digest — each entry with the condition that would overturn it, because a decision without a reversal condition is just a preference.

DecisionChosenRejectedReverses if
AuthenticationBuilt in-houseKeycloak, Ory, AuthentikReal users appear, or another service needs single sign-on
SessionsOpaque server-side tokensJWTMultiple services must verify without a shared database
DatabasePostgreSQL 17SQLite, external instanceNever, realistically — the ceiling is far away
MigrationsEmbedded, applied at startupSeparate job or CLI stepMigrations grow long enough to delay startup unacceptably
Service shapeOne Go binary, standard libraryFramework, microservicesA second deployable genuinely earns its own lifecycle

This is the decision people are most likely to disagree with, so it deserves the argument in full.

A JWT is verified by checking a signature, which means any service holding the public key can authenticate a request without asking a database. That is a real benefit — and it buys nothing here, because there is exactly one service and it already has a database connection open.

What it costs is the part that gets underplayed. A signed token is valid until it expires, so logout cannot actually revoke anything without a server-side denylist — which reintroduces the database lookup the JWT was supposed to avoid, plus a new distributed-state problem. There are signing keys to store, rotate, and eventually leak. And the format has a long history of implementation footguns, from alg: none acceptance to confusing symmetric and asymmetric verification.

Opaque tokens cost one indexed lookup per request. In exchange, logout is a DELETE, there are no keys in the system at all, and the amount of cryptography that can be got wrong is one call to sha256. At this scale that is a good trade; at a different scale it stops being one, and the interface the domain exposes is small enough that a JWT-issuing façade could be added without touching a handler.

In-house authentication instead of an identity provider

Section titled “In-house authentication instead of an identity provider”

The learning argument is in the overview, and it is the honest reason. What is worth adding here is the cost, stated plainly: every security decision in this system is mine, unreviewed, and defended by the notes in authentication rather than by an implementation many people have attacked. For anything with real users, an identity provider is the right answer and this would be the wrong one.

The reversal condition is therefore specific rather than aspirational — the moment another service in the lab wants single sign-on, the sane move is either to deploy a real provider or to make labapp speak OIDC, and the second is attractive mostly because it would be the next interesting thing to build.

The alternative is a separate migration step — a Kubernetes job, or a manual command before rollout. That is the more conventional choice at scale, and it decouples schema changes from deploys.

Startup migrations were chosen because they make “deploy the new binary” a single, atomic-feeling operation with no ordering for me to remember, and because the safety condition is cheap to satisfy: each replica takes a PostgreSQL advisory lock before migrating, so one applies the change and the others wait. The condition that reverses it is duration — the moment a migration takes long enough that pods are killed by their startup probe while waiting, it belongs in its own job.