Skip to content

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/valiss

The 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’s name). 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() (only IssueUser) 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 (enable WithNonce on the client transport). valiss.NewMemoryReplayCache() is a built-in cache. It is process-local; ReplayCache is 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 extension T, moving the failure to auth time. Retrieval with ExtOf never 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.