Security model
valiss verifies credentials offline against a single pinned public key. That is its strength and it is also the shape of its threat model: there is no issuer to consult at verification time, so what a server can and cannot defend against follows entirely from the keys it holds, the tokens it is shown, and the state it keeps locally. This page is honest about both. It says what the library protects, and it says plainly where the protection ends.
Nothing here is a configuration secret, and every claim is a property of the public source. Where a guarantee depends on an option you must set, that is called out, because an unset lever protects nothing.
The trust anchor
A server pins one value: the operator public key. Everything else verifies
against it with no network call. NewVerifier(operatorPub, allowlist) is the
whole trust configuration. The verifier walks each credential from the pinned
key down (operator signed the account token, the account signed the user token)
and checks the signature and the key role at every hop.
Because the anchor is a public key, it is not a secret. The corresponding operator seed is the ultimate secret in the system. Whoever holds it can issue account tokens for the entire trust domain, and through them any user beneath. The seed never belongs on a server; it lives with the issuer, in a secrets manager, and touches production only to sign new credentials.
Important
The pinned operator public key is the trust domain’s single anchor, and the operator seed behind it is the ultimate secret. Re-pinning a new operator public key everywhere is the only remedy for a compromised seed: neither the allowlist nor epoch rotation contains it.
A stolen operator seed is therefore the worst case, and it is worth being precise about what it buys and what still contains it:
- For request authentication it is partly contained. A forged account token has a fresh content-hash id (see Tokens) that is not on any server’s allowlist, and an allowlist accepts only ids you deposited. An attacker cannot add to your allowlist. So a stolen operator seed does not, by itself, admit a new tenant to an allowlist-enforcing server.
- For offline message verification it is not contained.
VerifyMessageroots trust in the operator key alone and holds no allowlist, so a stolen operator seed forges provenance that offline receivers accept. - Epoch rotation does not contain it either: the holder of the seed can re-issue at whatever epoch you advance to.
The only full remedy for operator-seed compromise is pinning a new operator public key everywhere. Rotation and the allowlist are for the credentials beneath the anchor, not for the anchor itself.
What a stolen credential buys
valiss separates two things a naive scheme conflates: the token, a public
signed artifact that names public keys, and the seed, the private key that
signs. By default a token authorizes nothing on its own. A request is authentic
only if it is signed, per request, by the subject’s seed (SignRequest on the
client, VerifySignature on the server). This is proof of possession: capturing
a token off the wire does not let you act as its subject, because you cannot
produce the signature.
The consequences differ sharply by what an attacker steals and at which level:
| stolen | what it grants |
|---|---|
| operator token | nothing secret. It is a self-signed public policy statement (epoch, validity window). |
| operator seed | full domain compromise. Issue any account, and any user beneath it. See the anchor section above. |
| account token | nothing on a signing server. Account-level requests must always sign, so the token alone cannot act. |
| account seed | full tenant takeover: sign as the tenant and issue user tokens, bounded by the account’s own scope. |
| user token | nothing, unless it is a bearer token. A signed request still needs the user seed. |
| user seed | act as that user, within its bounds. |
| bearer token | direct use as that user until the token expires or its account leaves the allowlist. |
Two properties bound the damage even when a seed is stolen. Delegation cannot widen: an account seed can issue users, but never more scope than the account itself holds, and an account can never issue another account. And revoking the account cuts off every user beneath it in one edit. A stolen account seed is a tenant-level breach, not a domain-level one.
Bearer tokens versus signed requests
The default is proof of possession, and it is what makes a captured token inert.
For clients that cannot hold a key at all, typically browsers, a user token may
be marked bearer with WithBearer. The server then accepts it without a
per-request signature: possession of the token is the proof.
That is a real and deliberate weakening, scoped as narrowly as the design allows:
- Only user tokens may be bearer. Account-level requests always sign.
- A bearer token is a bearer secret. Anyone who captures it can use it until it expires or its account is removed from the allowlist. Pair bearer tokens with TLS so they are not exposed in transit, and give them a short validity window so a captured one ages out quickly.
Caution
A bearer token is a replayable secret: anyone who captures it can act as its subject until it expires or its account leaves the allowlist. Only user tokens may be bearer, and only ever under TLS with a short validity window.
There is a second reason to prefer signed requests. The possession check runs before any consumer-supplied extension check or validator, so a party that merely captured a token but cannot sign never triggers that work. A bearer token waives the signature by design, and with it that ordering guarantee.
Replay
The request signature covers a timestamp bound to a SHA-256 of the request context, the transport’s canonical description of the operation (method and path, with a nonce folded in when one is used). Two things follow directly:
- Binding the context stops substitution. A signature captured for one operation cannot authorize a different one, because the bytes it signed include that operation’s context.
- The timestamp bounds the window. The verifier accepts a timestamp only
within a symmetric skew window around now (
DefaultSkew, two minutes, overridable withWithSkew).
Be clear about the gap this leaves. Within that window, the identical signed request can be replayed verbatim: the signature is still valid and the timestamp is still fresh. The library does not deduplicate requests by default.
Suppressing replay is opt-in. WithReplayCache, together with a per-request
nonce (128 random bits) folded into the signed context, makes the verifier
reject a nonce it has already seen, for a retention of twice the skew window (the
longest a replay could still land inside a valid timestamp). The limit to be
honest about: the built-in MemoryReplayCache is process-local. Across several
server instances a replay could land on an instance that has not seen the nonce.
Exactly-once across instances requires backing the cache with shared storage.
Tip
For exactly-once across several instances, enable WithReplayCache with a
per-request nonce and back it with shared storage. The built-in
MemoryReplayCache is process-local, so each instance sees only its own
nonces.
Message tokens have their own replay surface and their own levers, both
receiver-side and both off unless set. Cross-destination replay is closed by
binding a token to its destination (WithAudience, checked with
ExpectAudience); payload tampering is closed by binding it to the payload bytes
(WithChecksum, checked with WithPayload). A receiver that knows its own
identity should set the audience check; a receiver that requires payload binding
should set the checksum check.
Fail-closed enforcement
Two enforcement points default to denial rather than permission.
The allowlist. An account token is accepted only if its id is present in the
server’s allowlist. An unlisted token is rejected even with a flawless signature
and a far-future expiry. The list is an explicit set of ids you deposited, not a
denylist of revoked ones, so nothing is trusted by default. The development-only
AllowAll accepts every id and turns revocation off entirely; it is not for
production. See Allowlist.
Transport extensions. The HTTP and gRPC integrations authorize through signed
extensions, and they fail closed. Every token in
the chain must carry the transport extension, the zero-value extension grants
nothing, and allow-all is the explicit wildcard, never an unset field. One
subtlety is worth internalizing: an empty dimension imposes no restriction, so
Ext{Paths: ["/admin/*"]} permits those paths under any method. To bound a
surface you must name every dimension you mean to bound. A deployment that
authorizes entirely outside the transport can opt out with
AllowMissingExtension, but that opt-out is explicit, never a default.
Enforcement also compounds down the chain, and does so at verify time: issuance
runs no subset check, so a user token may name broader bounds than its account,
but the verifier authorizes only when every level permits the request (an AND
across the chain), which caps every user at the account’s bounds.
The verifier applies these in a fixed order and reports a precise reason: chain signatures, then the operator policy (its validity window and epoch match) when an operator token or keyring is configured, then account and user validity windows, then the allowlist, then proof of possession, then extension checks, then custom validators.
Epoch enforcement on the request path is conditional. A bare
NewVerifier(operatorPub, allowlist) pins the anchor and enforces the allowlist
but never compares epochs, so epochs bind requests only once you configure an
operator token (WithOperatorToken) or a keyring. Message verification does not
share that gap: VerifyMessage always requires the chain’s tokens to agree on
their epoch, and additionally binds them to the domain epoch when you pass an
operator policy.
Each gate fails closed to a denial:
flowchart TD
S["Account token signature<br/>vs pinned operator key"] --> P["Operator policy (when configured):<br/>token window + epoch match"]
P --> AV["Account token<br/>expiry and not-before"]
AV --> AL["Allowlist:<br/>account jti present"]
AL --> UT["User token, if present:<br/>signature vs account key,<br/>epoch, expiry"]
UT --> POP["Proof of possession:<br/>request signature vs subject<br/>(bearer user token waives)"]
POP --> EX["Extension checks"]
EX --> V["Custom validators"]
V --> ID(["Identity"])
S -.->|fail| D(["Denied"])
P -.->|fail| D
AV -.->|fail| D
AL -.->|fail| D
UT -.->|fail| D
POP -.->|fail| D
EX -.->|fail| D
V -.->|fail| D
Revocation reach, and its limits
Revocation without an issuer to call is bounded, and the boundaries matter.
- Selective revocation is an allowlist edit. Remove an account’s id and the next request carrying that account token fails, along with every user beneath it, because a user token is only valid under a listed account. No token needs to have expired.
- Reach is local to each verifier’s copy of the list. There is no central
push. A revocation takes effect on a given server only once that server’s
allowlist is updated and reloaded.
LoadAllowlistFilereads the file once and ships no watcher, so the caller wires the reload (a file watch, SIGHUP, or a poll interval) and applies it withSeton the retained allowlist; see the Go guide. Revocation is as prompt as your distribution of the list, no more. - The allowlist keys accounts, not users. There is no per-user allowlist entry, so you cannot revoke a single user through the allowlist without revoking its account. To cut off one user, let its (short) TTL lapse, or revoke the whole account and re-issue the rest.
- Domain-wide revocation is epochs. Bump the operator epoch and re-issue; every token from an earlier epoch is rejected on next use, and the operator token’s own expiry bounds the whole domain. See Rotation.
- None of it revokes the operator seed. As above, a leaked operator seed is outside the reach of both levers; only re-pinning the anchor cures it.
Because expiry is checked with the same skew slack as the request timestamp, a token is honored up to the skew past its stated expiry. Size validity windows with that two-minute grace in mind.
Scope and pitfalls
- valiss authenticates; it does not encrypt. It verifies identity and carries signed authorization claims. It provides no transport confidentiality. Use TLS for that, and to protect bearer tokens in transit.
- Key custody is yours. The model’s security rests on seeds staying secret.
Storing and distributing them is the issuer’s responsibility and outside the
library. The valiss CLI (early development) is the issuer-side tool being built
for that custody: it will hold keys and tokens in an encrypted per-operator
store, though its commands are designed and not yet runnable (every one is a
stub today). For now that custody is arranged from the parts valiss ships,
keeping seeds in a secrets manager and issuing through the library or
examples/minter; see Custody. - Never authorize on names. A token’s
nameis an issuer-asserted label, not checked for uniqueness and free to collide across operators. Key authorization decisions on public keys and extensions, not on names. See Entities. - The verifier’s per-hop role check prevents cross-level confusion. Any
public key re-encodes under any role prefix with a valid checksum, so the nkey
encoding alone does not stop a user key from being written as an account nkey.
What stops it is the verifier: at every hop it requires the
issandsubto carry the role that position demands, and the signature chain must hold. A user key offered in an account slot is rejected there, not made unrepresentable.
Related
- Allowlist: revocation semantics in depth.
- Rotation: epochs and the keyring for domain-wide rotation and grace periods.
- Extensions: the authorization mechanism the transports enforce.
- Verifying credentials offline: the verification algorithm every implementation follows.