Production hardening checklist
Verification is offline, so a misconfigured verifier does not fail loudly at startup: it runs, accepts traffic, and enforces exactly what you set, no more. This is the list of things to have in place, and to assert, before a verifier takes production traffic. Each item names what the library does for you and what it leaves to you, because the gap between the two is where deployments get hurt.
Work through it in order. The early items are trust configuration you cannot retrofit safely; the later ones are surfaces that are off until you enable them.
1. Pin and verify the trust anchor
NewVerifier(operatorPub, allowlist) is the whole trust configuration: one
pinned operator public key, no network call. Whoever substitutes that key owns
the trust domain, because every credential verifies against it and nothing else.
Confirm the pinned value through a second channel before it ships (a published
fingerprint, a signed release, a second operator), not just by copying whatever
a build handed you.
The corresponding operator seed is the ultimate secret in the system, and it never belongs on a production host. Issuing credentials happens off-box, with the seed held by the issuer; a verifier holds only the public key. If your provisioning puts the operator seed anywhere near a serving process, stop and fix that first.
2. Load a real allowlist, and assert it
The allowlist fails closed: an account token is accepted only if its id is in
the set. The development-only AllowAll accepts every id and turns revocation
off entirely, and it does so silently. The library emits no warning that you are
running wide open; AllowAll.Allowed simply returns true for everything.
So the assertion is yours. At startup, refuse to serve unless the allowlist is a
real StaticAllowlist (never AllowAll) and is non-empty. An empty allowlist is
not a safe default that denies everything useful by accident: it is a sign the
load failed, and it should abort startup rather than run.
3. Wire allowlist reload yourself
Warning
The library ships no allowlist watcher. LoadAllowlistFile reads the file
once. Wiring reload (a signal, a timer, an inotify hook) and validating each
new set before Set is your responsibility: an empty set silently revokes
every account.
There is no file watcher in the library. LoadAllowlistFile reads a
newline-delimited file once, and StaticAllowlist.Set atomically replaces the
accepted set under a lock. Reload is the two composed by you: retain the
*StaticAllowlist you passed to NewVerifier, and on your own trigger (a signal,
a timer, an inotify hook) load the new file and call Set.
Two rules make that safe. Validate the freshly loaded set before you swap it:
Set will install whatever you hand it, including an empty slice, which would
silently revoke every account. Reject an empty or failed load and keep the
previous set. And monitor staleness: a reload that quietly stops (a broken
distribution, a file that never changes) leaves the verifier enforcing an old
list with no error, so track the age of the last successful load as a signal in
its own right. See Monitoring.
4. Treat the clock as part of the trusted computing base
Freshness has no external authority; it anchors entirely on the verifier’s local
clock. The request timestamp is accepted only within a symmetric skew window
around now (DefaultSkew, two minutes); token exp and nbf, the operator
token’s own validity window, and the replay cache’s retention are all evaluated
against that same clock. A verifier whose clock is wrong will accept stale
requests or reject fresh ones, and neither is visible from inside the library.
Run authenticated NTP on every verifier, and alert on drift. And understand what
WithSkew costs: widening it does not only loosen timestamp acceptance. It
widens the window in which a captured request stays replayable, and it lengthens
the grace past a token’s stated expiry (expiry is checked with the same skew
slack), so a two-minute widening is a two-minute-longer life for every revoked-
by-expiry credential. Keep it as tight as your real clock spread allows.
5. Watch the operator token expiry: it closes the whole domain
When you enforce operator policy (WithOperatorToken, or a keyring, where every
entry carries policy), the operator token’s own validity window gates every
request. Once it expires, VerifyRequest fails every credential with “the trust
domain is closed”: not a subset, everything. This is the one setting whose lapse
is a full outage rather than a partial denial.
Monitor its exp with a lead time longer than a rotation takes, and re-issue
before it lands. The keyring is what makes the changeover gap-free: it holds
several epochs for one operator key at once, so you register the new-epoch token
alongside the old one and let producers re-issue at their own pace, dropping the
old entry only after the grace overlap. Never let the only trusted operator
token expire with no successor already trusted.
6. Decide replay explicitly; the built-in cache has sharp edges
Replay suppression is off by default. Within the skew window the identical
signed request replays verbatim, signature valid and timestamp fresh, unless you
enable it. Turn it on with WithReplayCache plus a per-request nonce folded into
the signed context; the verifier then rejects a nonce it has already seen and
requires a nonce on every signed request.
The built-in MemoryReplayCache has limits you must design around:
- It is process-local. Each instance sees only its own nonces, so a replay
landing on a different instance is not caught. Exactly-once across a fleet
requires backing
WithReplayCachewith shared storage. - Its footprint is bounded by retention, not by a cap. Each nonce is held for twice the skew window, and there is no size limit; memory grows with request rate times twice the skew. Every insert also scans the whole map to prune expired entries, under a single mutex, so cost per request is O(n) in the live set. On a busy instance this is a real hot spot; size skew and rate accordingly, or use a backend built for it.
- The interface has no error channel.
ReplayCache.SeenBeforereturns a barebool. A shared backend that times out or errors cannot report that through the interface, so your implementation must decide, explicitly, whether a backend failure fails open (serve, risking a missed replay) or fails closed (reject, risking an outage). Make that choice explicitly; there is no default to inherit. - It never covers bearer tokens. The replay check lives on the signed path. A bearer request carries no signature and no nonce, so the cache does nothing for it. Bearer replay is bounded only by the token’s TTL and the allowlist.
7. Constrain bearer tokens
A bearer user token is accepted with no per-request signature: possession is the proof. That is a deliberate weakening for clients that cannot hold a key, and it comes with obligations.
- TLS is mandatory, not advisory. A bearer token is a bearer secret; anyone who captures it in transit can use it. valiss does not encrypt.
- Use the shortest workable TTL. A captured bearer token is usable until it expires or its account leaves the allowlist, so its lifetime is your exposure window.
- Revocation is account-level only. The allowlist keys accounts, not users; there is no per-user allowlist entry. You cannot revoke one bearer user without removing its whole account. Plan for that: keep the TTL short enough that lapse, not revocation, is the normal cutoff.
- Client-side exfiltration is outside the model. Bearer tokens are for browsers and similar clients; theft from the client (XSS, a compromised page) is not something the verifier can defend against. Scope and expire them on that assumption.
8. Keep seeds off the serving path and out of everything that captures text
The security of the whole model rests on seeds staying secret, and seed custody is outside the library. A credentials file carries the subject’s token together with the seed that signs its requests, in one file.
- Store credentials files with owner-only permissions (
0600) and never commit one to version control. - Prefer a secrets manager to environment variables. Env vars leak into process listings, crash dumps, and child processes more readily than a file read on demand.
- Treat anything that captures text as a seed sink. A log line, a stack dump, or an error report that echoes a credentials file or an environment block is seed exposure, not a cosmetic bug.
Issuing and holding seeds is the issuer side, not the verifier. The valiss CLI (early development) is the tool being built to own that custody in an encrypted per-operator store; until it lands, the working path is the library’s issuing functions run in your own controlled tooling, off the serving host, with the seed material handled as above. See Custody.
9. If you set AllowMissingExtension, prove your external gate runs
Caution
AllowMissingExtension accepts tokens that carry no transport extension,
moving the entire authorization decision to a gate you run outside the
transport. If that gate is not wired on every path, the deployment is
authenticating requests and authorizing nothing.
The HTTP and gRPC integrations authorize through signed extensions and fail
closed: without AllowMissingExtension, every token in the chain must carry the
transport extension or the request is denied. Passing AllowMissingExtension
accepts tokens that carry no extension and imposes no constraint on them, which
means it moves the entire authorization decision to whatever gate you run
outside the transport.
That is legitimate, but only if the gate actually executes on every path. Test
it: send a request that the extension would have denied and confirm your external
check denies it. A deployment that sets AllowMissingExtension and then forgets
to wire (or accidentally bypasses) the external gate is authenticating requests
and authorizing nothing.
10. Handle message-token replay and binding on the receiver
Message tokens (IssueMessage / VerifyMessage) are proofs of origin, and their
verification carries no replay cache at all. There is no same-destination replay
suppression within a token’s TTL: the same valid message token can be presented
repeatedly until it expires.
- Deduplicate non-idempotent messages yourself. A verified
MessageClaimscarries the token’sjti(its content-hash id). A receiver acting on non-idempotent messages must record and reject ids it has already processed; the library will not. - Audience and checksum are opt-in receiver checks. A token is bound to a
destination only if the receiver calls
ExpectAudience, and to its payload bytes only if the receiver callsWithPayload(or requires one withRequireChecksum). A receiver that knows its own identity should set the audience check; one that requires payload binding should set the checksum check. Neither is enforced unless you ask for it.