Go
The Go reference implementation is the module valiss.dev/valiss. It covers
the whole scheme: issuing operator, account, user, and message tokens;
packaging credentials; and verifying per-request credentials server-side.
Transport and framework adapters live under contrib/.
This guide assumes the key model. An operator key is the trust anchor a
server pins. The operator signs account (tenant) tokens; an account
signs user tokens; the subject signs every request with its own key.
Keys are NATS nkeys, and levels are strict: operator (SO... seed, O...
public), account (SA.../A...), user (SU.../U...).
Installing
go get valiss.dev/valissThe module requires Go 1.26. Key generation and signing use
github.com/nats-io/nkeys, which comes in as a dependency.
Issuing tokens
Tokens are issued from key pairs. A signer’s seed never leaves the issuing side; the server only ever sees public keys and tokens.
import (
"time"
"github.com/nats-io/nkeys"
"valiss.dev/valiss"
)
// One-time: the operator key pair is the trust anchor. Keep the seed
// offline; publish only the public key.
operator, _ := nkeys.CreateOperator()
operatorPub, _ := operator.PublicKey()
// Per tenant: an account key pair. The tenant holds the seed.
account, _ := nkeys.CreateAccount()
accountPub, _ := account.PublicKey()
// The operator signs an account token binding the tenant's public key.
accountToken, err := valiss.IssueAccount(operator, accountPub,
valiss.WithName("acme"),
valiss.WithTTL(720*time.Hour),
)IssueAccount(operator, tenantPubKey, opts...) returns the compact token
string. The parallel issuers are IssueOperator(operator, opts...) for the
self-signed operator token (see Rotation),
IssueUser(account, userPubKey, opts...) for a tenant delegating to an end
user, and IssueMessage(user, opts...) for per-message proofs (see
Message tokens). Each checks that the signing key is the
right nkey type and that the subject key matches the level.
A tenant delegates to a user with its own account seed:
user, _ := nkeys.CreateUser()
userPub, _ := user.PublicKey()
userToken, err := valiss.IssueUser(account, userPub,
valiss.WithName("alice"),
valiss.WithTTL(24*time.Hour),
)Issue options
IssueOption values customize an issued token. The identity issuers
(IssueOperator, IssueAccount, IssueUser) share these:
WithName(string)labels the entity (the token’sname). An unnamed entity is represented by its public key. Names are issuer-asserted and not checked for uniqueness at issuance.WithTTL(d)/WithExpiry(t)set the expiry (exp). Without either, the token never expires.WithNotBefore(t)sets the activation time (nbf).WithEpoch(uint64)stamps the trust-domain epoch (see Rotation).WithExtension(Extension)embeds a named authorization claim (see Extensions).WithBearer()(onlyIssueUser) marks a token the server accepts without per-request signatures. Bearer tokens are replayable until they expire, so pair them with TLS and a short window.
WithAudience, WithChecksum, and WithChain apply only to message
tokens; passing them to an identity issuer is an error.
Packaging credentials
The valiss.dev/valiss/creds package renders a client’s tokens and signing
seed into one marker-delimited file, everything a client needs to
authenticate:
import "valiss.dev/valiss/creds"
seed, _ := account.Seed()
text := creds.Format(creds.Creds{AccountToken: accountToken, Seed: seed})
// write text to acme.creds
// Client side:
c, err := creds.Parse(text) // or creds.Load(path)Creds carries AccountToken, an optional UserToken, and the Seed that
signs requests (nil for bearer creds). User-level creds may omit the account
token when the server resolves it (see
Account token resolution); a bundle embeds it.
The examples/minter command in the repository is a complete,
manifest-driven credential minter built on these APIs. For issuer-side
credential management as a standalone tool rather than library code, the valiss
CLI (early development) will keep operator keys and tokens in an encrypted
per-operator store and run issuance and creds export from there; its commands
are designed but every one is currently a stub, so today issuance is the
Issue* calls above or examples/minter.
Server-side verification
A Verifier checks the full per-request credential: the account token
against the pinned operator key, expiry and activation, allowlist
membership, the optional user-token chain, extensions, and the request
signature within the skew window.
verifier := valiss.NewVerifier(operatorPub, allowlist, opts...)The server needs only the operator public key and an allowlist. It never
holds any seed. Transport adapters (below) wrap the verifier with header
extraction and error mapping; you rarely call VerifyRequest directly.
The allowlist
The allowlist decides whether an issued account token (by its jti) is
still accepted, so a token can be revoked before expiry by removing it. The
jti a server accepts is the account token’s id; the minter prints it as
token metadata.
// In-memory set, reloadable with (*StaticAllowlist).Set.
allowlist := valiss.NewStaticAllowlist(jti1, jti2)
// Newline-delimited file of jtis ('#' comments and blank lines ignored).
allowlist, err := valiss.LoadAllowlistFile("/etc/valiss/allowlist")
// Accept every token; for local development. Signature and expiry still gate.
allowlist := valiss.AllowAll{}These three are bundled implementations. Allowlist is a one-method
interface, so a custom implementation can source the accepted ids from a
database or a per-request policy; see Allowlist
for the contract.
Reloading without a restart
LoadAllowlistFile reads the file once and returns a fresh allowlist; it
installs no watcher and handles no signal, so nothing reloads on its own. Live
revocation is the caller’s job: retain the allowlist, re-read the file when you
decide to, and swap the set with Set. Validate the new set first, because an
empty file (or one parsed down to nothing) revokes every tenant.
// LoadAllowlistFile reads the file once and returns a fresh allowlist; it
// installs no watcher. For live revocation, retain the allowlist, re-read the
// file yourself, and swap the set with Set.
allowlist, err := valiss.LoadAllowlistFile("/etc/valiss/allowlist")
verifier := valiss.NewVerifier(operatorPub, allowlist)
// Reload on SIGHUP. Set replaces the whole set under a lock, so a concurrent
// request sees either the old set or the new one, never a partial one.
func reloadOnHUP(path string, allowlist *valiss.StaticAllowlist) {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGHUP)
for range sig {
ids, err := readAllowlistIDs(path)
if err != nil {
log.Printf("allowlist reload failed, keeping current set: %v", err)
continue
}
// An empty list revokes every tenant. Validate before Set so a
// truncated or empty file cannot fail-close the deployment.
if len(ids) == 0 {
log.Printf("allowlist reload skipped: %s parsed to zero ids", path)
continue
}
allowlist.Set(ids)
}
}
// readAllowlistIDs mirrors LoadAllowlistFile's parsing: one jti per line,
// blank lines and '#' comments ignored.
func readAllowlistIDs(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var ids []string
s := bufio.NewScanner(f)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
ids = append(ids, line)
}
return ids, s.Err()
}The trigger is yours to pick: the SIGHUP handler above, a file watcher
(fsnotify), or a poll interval that calls Set only when the file’s contents
change. Whichever it is, the fail-closed check before Set is what keeps a
truncated or empty file from cutting off the whole deployment.
Verifier options
WithSkew(d)overrides the default 2-minute window for timestamp drift and expiry slack.WithReplayCache(cache)rejects a signed request whose nonce was already seen. Signed clients must then send a nonce (enableWithNonceon the client transport).valiss.NewMemoryReplayCache()is a built-in cache. It is process-local;ReplayCacheis an interface, so backing it with shared storage gives exactly-once across several server instances.WithExtensionType[T]()eagerly rejects a request carrying a malformed instance of extensionT, moving the failure to auth time. Retrieval withExtOfnever requires it.WithClaimsValidator(fn)injects custom validation that runs after the chain is verified and possession of the subject key is proven, so an expensive check never fires for a party that merely captured a token.ExtValidator[T]adapts a typed extension validator to this shape.WithOperatorToken(token)enforces a trust-domain policy (see Rotation).WithAccountTokenResolver(fn)accepts user-only credentials (see below).
The verified identity
Verification yields a *valiss.Identity. Handlers read it from the request
context and use it to segment data:
id, ok := valiss.IdentityFromContext(ctx)
// id.Account *AccountClaims - the tenant; always present.
// id.User *UserClaims - the delegated user; nil for account-level requests.
// id.Operator *OperatorClaims - the trust domain; set under a keyring or operator token.id.Account.Name is the tenant label to key storage on; id.Account.ID is
the allowlist jti.
User-only credentials
A request may carry only a user token, leaving the server to supply the account token. Configure a resolver:
resolver, err := valiss.StaticAccountTokens(accountToken1, accountToken2)
verifier := valiss.NewVerifier(operatorPub, allowlist,
valiss.WithAccountTokenResolver(resolver))Without a resolver, a credential missing its account token is rejected.
AccountTokenResolver is a plain function, so beyond the static set it can
resolve the account token dynamically per call, from a store or an issuing
service.
Trusting several operators
A server serving more than one trust domain verifies against a Keyring of
self-signed operator tokens. The credential names its domain (the account
token’s issuer plus its epoch), so the right entry is selected without
trial, and handlers tell domains apart by id.Operator.Name.
keyring, err := valiss.NewKeyring(operatorTokenA, operatorTokenB)
verifier := valiss.NewKeyringVerifier(keyring, allowlist)Keyring entries carry their own policy (validity window and exact epoch), so
WithOperatorToken does not combine with a keyring. The allowlist is shared
across domains; account jtis are content hashes and cannot collide between
producers.
Rotation and epochs
The operator can publish a self-signed operator token: a policy statement over the trust domain carrying an epoch counter and an optional validity window. A single-anchor verifier configured with it accepts only account and user tokens that echo the current epoch.
opToken, _ := valiss.IssueOperator(operator, valiss.WithName("prod-us"), valiss.WithEpoch(3))
acctToken, _ := valiss.IssueAccount(operator, accountPub,
valiss.WithName("acme"), valiss.WithEpoch(3), valiss.WithTTL(720*time.Hour))
verifier := valiss.NewVerifier(operatorPub, allowlist,
valiss.WithOperatorToken(opToken))Bumping the epoch and re-issuing rotates the whole domain at once: every
token from an earlier epoch is rejected cryptographically, with no allowlist
edits. The operator token’s own exp bounds the entire domain. The pinned
public key remains the trust anchor; the operator token only carries policy
signed by it. Selective revocation stays with the allowlist. Unstamped
tokens are epoch 0.
Authorization extensions
Authorization rides named extension claims: typed payloads the scheme signs and transports but assigns no meaning. The HTTP and gRPC transports each define and enforce their own; your application adds domain extensions the same way.
An extension is a value struct whose method reports its name:
type queryFilters struct {
Regions []string `json:"regions"`
}
func (queryFilters) ExtensionName() string { return "example.filters" }Embed it at issue time with valiss.WithExtension(queryFilters{...}) and read
it back in a handler with valiss.ExtOf[queryFilters](id.Account.Ext), which
returns the value, an ok bool, and a decode error. Extensions on both
chain levels are enforced together, so an account-level grant bounds all of
its users.
The HTTP grant
contrib/httpauth defines Ext, binding a token to hosts, methods, and
paths:
import "valiss.dev/valiss/contrib/httpauth"
valiss.WithExtension(httpauth.Ext{
Methods: []string{"GET"},
Paths: []string{"/v1/*"}, // trailing "*" is a prefix wildcard
})The three dimensions are independent AND-filters, each constraining only
when populated: an empty dimension imposes no restriction on that dimension.
Naming only Paths therefore leaves every method and host open. Scope a
read-only surface by naming every dimension you care about. Allow-all within
a dimension is the explicit wildcard []string{"*"}; the zero-value Ext{}
grants nothing.
The gRPC grant
contrib/grpcauth defines a single-dimension Ext over full method names:
import "valiss.dev/valiss/contrib/grpcauth"
valiss.WithExtension(grpcauth.Ext{
Methods: []string{"/example.v1.WidgetService/*"}, // or a specific method, or "*"
})An empty Methods list grants nothing; allow-all is the explicit "*".
Enforcement in both transports fails closed: every token in the chain must
carry the extension, or the request is denied. Pass AllowMissingExtension()
to the middleware only when authorization is handled entirely outside the
transport.
Wiring the transports
net/http
httpauth.NewMiddleware returns a standard func(http.Handler) http.Handler
that authenticates every request and enforces the HTTP extension.
Unauthenticated requests get 401, extension denials get 403.
mw := httpauth.NewMiddleware(valiss.NewVerifier(operatorPub, allowlist))
srv := &http.Server{Handler: mw(appHandler)}
// In a handler:
id, _ := valiss.IdentityFromContext(r.Context())Because it is standard middleware, any router that routes plain
net/http handlers is covered without a dedicated adapter: chi,
gorilla/mux, httprouter, negroni, and the like. Wire it at each router’s
middleware point.
The client attaches the credential and signs each request with an
http.RoundTripper:
transport, err := httpauth.NewTransport(c, nil) // c is creds.Creds; nil base = http.DefaultTransport
client := &http.Client{Transport: transport}Enable httpauth.WithNonce() on the transport whenever the server has a
replay cache. Bearer creds (no seed) attach the token but no signature.
gRPC
contrib/grpcauth provides server interceptors and client per-RPC
credentials:
auth := grpcauth.NewAuthenticator(valiss.NewVerifier(operatorPub, allowlist))
srv := grpc.NewServer(
grpc.ChainUnaryInterceptor(auth.UnaryInterceptor()),
grpc.StreamInterceptor(auth.StreamInterceptor()),
)
// Client:
rpcCreds, err := grpcauth.NewCredentials(c)
conn, err := grpc.NewClient(target,
grpc.WithTransportCredentials(tlsCreds),
grpc.WithPerRPCCredentials(rpcCreds),
)Rejections map to codes.Unauthenticated (auth) and codes.PermissionDenied
(extension). gRPC refuses to send per-RPC credentials over an insecure
connection; rpcCreds.AllowInsecure() relaxes that for an already-encrypted
local tunnel.
Framework integrations
Frameworks with their own handler and context types get dedicated adapters
that expose the verified identity through the native context and use native
abort semantics. Per ADR 0014,
targets are selected by GitHub stars and shipped effort-class first; the
first wave is Gin and Echo. Every adapter wraps httpauth’s verification
core (httpauth.Authenticate) rather than reimplementing it, so the HTTP
extension and all verifier options behave identically. The client side stays
framework-agnostic: use httpauth.NewTransport.
Adapters follow a naming split (ADR 0015):
<framework>auth packages carry credential authentication and grant an
identity (IdentityFrom); <framework>sig packages carry message-token
verification and grant no identity (MessageFrom). Protection on a route is
visible in its imports.
Gin
import "valiss.dev/valiss/contrib/ginauth"
engine := gin.New()
engine.Use(ginauth.NewMiddleware(valiss.NewVerifier(operatorPub, allowlist)))
engine.GET("/v1/whoami", func(c *gin.Context) {
id, _ := ginauth.IdentityFrom(c)
c.String(http.StatusOK, "tenant %q", id.Account.Name)
})NewMiddleware returns a gin.HandlerFunc; rejections abort with the right
status. ginauth.IdentityFrom(c) reads the verified identity.
Echo
import "valiss.dev/valiss/contrib/echoauth"
app := echo.New()
app.Use(echoauth.NewMiddleware(valiss.NewVerifier(operatorPub, allowlist)))
app.GET("/v1/whoami", func(c echo.Context) error {
id, _ := echoauth.IdentityFrom(c)
return c.String(http.StatusOK, "tenant "+id.Account.Name)
})NewMiddleware returns an echo.MiddlewareFunc; rejections surface as
*echo.HTTPError (401 or 403), so the app’s error handler renders them.
Runnable end-to-end programs for both are in examples/ginauth and
examples/echoauth in the repository.
Message tokens
A service that emits artifacts to third parties (webhooks, queue messages,
exported documents) can extend the chain one level further with message
tokens: a short-lived token issued with a user key, one per emitted message,
that any receiver verifies offline knowing only the operator public key. A
message token binds the destination (aud), the exact payload bytes (a
SHA-256 checksum), a short window, and the epoch.
payload := renderWebhook(event)
tok, err := valiss.IssueMessage(user,
valiss.WithAudience("https://receiver.example/hook"),
valiss.WithChecksum(valiss.Checksum(payload)),
valiss.WithTTL(30*time.Second),
valiss.WithChain(accountToken, userToken),
)
// Receiver, holding only the operator public key:
claims, err := valiss.VerifyMessage(tok, operatorPub,
valiss.ExpectAudience("https://receiver.example/hook"),
valiss.WithPayload(receivedBody),
)
// claims.Account.Name / claims.User.Name identify the emitter.Message tokens must carry an expiry. WithChain embeds the provenance chain
so the token is self-contained; WithChainTokens on the verify side instead
takes the chain out of band. ExpectAudience closes cross-destination
replay and WithPayload closes payload tampering; receivers should set both.
Stored messages verify after expiry with valiss.At(receivedAt). Transport
adapters contrib/httpsig, contrib/grpcsig, contrib/ginsig, and
contrib/echosig wire this up end to end.
A message token is a proof, not a credential. Possession grants nothing:
Verifier.VerifyRequest never accepts one, and receivers must not treat one
as a bearer credential. The detailed verification algorithm, for reference or
for porting to another language, is in
Verifying tokens.