API keys
The credential a program uses to call your API: issued once, shown once, revocable, scoped, and recognisable in a log.
A framework with only passwords and browser sessions pushes every machine-to-machine caller into one of two bad places — a shared password in a config file, or a JWT nobody can revoke. This is the third option.
The shape of a key
nk_a1b2c3d4_zm9vymfyymf6cxv4...
│ │ └ the secret: 256 bits, never stored
│ └ the key id: what a listing shows and a log line names
└ a fixed prefix, so a leaked key is recognisable in a scan
The prefix is not decoration: secret scanners match on prefixes, and a key that looks like any other base64 string is one nobody can find in the repository they just leaked. The alphabet is base32 — letters and digits only — so a key survives being read aloud, typed from a screenshot, and pasted into a shell.
Issuing
store, err := apikeys.NewSQLStore(ctx, db, apikeys.SQLStoreConfig{
Flavor: apikeys.FlavorPostgres,
})
key, secret, err := apikeys.Issue(ctx, store, apikeys.Key{
Name: "ci",
OwnerID: account.ID,
Scopes: []string{"billing:read"},
})
// `secret` is the only time the caller can copy it.
Only a SHA-256 of the secret is stored — not bcrypt, deliberately: the secret already carries 256 bits of entropy, so there is nothing to brute force, and a per-request bcrypt on an API call would be a denial of service its caller controls.
Or from the command line, which is where keys are actually issued:
nucleus apikey create --name ci --owner acct_123 --scopes "billing:read"
nucleus apikey list
nucleus apikey rotate --id a1b2c3d4 --grace 24h
nucleus apikey revoke --id a1b2c3d4
Authenticating a request
r.Use(apikeys.Middleware(store))
r.With(apikeys.Require("billing:read")).Get("/invoices", listInvoices)
Middleware accepts X-API-Key and Authorization: Bearer nk_…, and
leaves any other bearer token alone, so JWT and API keys share the
header without fighting. A request with no key passes through untouched:
authentication is what the middleware does, and Require is what refuses.
It also puts the key's owner in the observability context — which is not bookkeeping. The rate limiter keys on the authenticated user id with the tenant as a prefix, so a key that lands there is throttled as an identity instead of sharing a bucket with everyone behind the same address.
Require("scope") answers 403 for a key without the scope, not 401:
the caller is authenticated, and presenting the same key again will not
help.
A key with no scopes carries none. An empty list never means "every scope" — that default is how an unscoped key ends up more powerful than a scoped one.
Rotation
replacement, secret, err := apikeys.Rotate(ctx, store, oldID, 24*time.Hour)
Both keys work during the grace period, and only the new one after it. A rotation that breaks the caller at the moment it happens is a rotation nobody performs — so the old key is given an expiry rather than a revocation, and there stays exactly one rule about when a key stops working. Pass a zero grace to cut it off immediately.
Key.RotatedFrom records what a key replaced, so an audit can follow the
chain.