Guides
Authentication
Auth modes on novad, the nova scope grammar, api-key credentials and the exchange, minting with nova
Nova authenticates every caller on its one listener under one mode, and in jwt mode authorizes each operation by the token's nova: scope grants. Long-lived api-key credentials live in the registry and are exchanged at runtime for short-lived, narrowly scoped tokens; the SDK does that exchange for you.
Auth modes on novad
--auth (NOVAD_AUTH) applies to every caller, clients and peer stars (the other novad processes) alike, on the gRPC port and on the data bridge.
| Mode | Credential | Who the caller is | Needs |
|---|---|---|---|
off (default) | none | trusted; every check passes | nothing; the dev posture |
mtls | a client certificate | trusted | --tls-cert/--tls-key and --tls-client-ca |
jwt | Authorization: Bearer <token> | exactly what the token's grants cover | one JWT source; TLS or --insecure-auth |
basic | Authorization: Basic … | the operator: the admin marker, so every check passes | --admin-username + NOVAD_ADMIN_PASSWORD; TLS or --insecure-auth |
Startup fails closed: credential-bearing modes over plaintext refuse to boot unless you pass --insecure-auth (TLS terminated elsewhere), mtls without a client CA refuses, and jwt with zero or several sources refuses. Trust-domain modes (off, mtls, basic) carry no per-tenant identity; only jwt does.
Scope tokens
Grants ride the standard scope claim as space-separated nova: tokens. There are two shapes:
nova:<action>:<bucket-match>:<key-match> a resource grant
nova:admin | nova:node a marker- Action is one of
append,read,list,create,delete,trim. - Bucket match is a bucket name, optionally ending in
*for a prefix (acme-*). - Key match is the key percent-encoded (every byte outside
A-Z a-z 0-9 - . _ ~as uppercase hex, soorders/euisorders%2Feuand a literal*is%2A), optionally ending in an unencoded*for a prefix. Without*the match is exact.
Grants are a union, allow-only: there are no deny statements; default-deny inside jwt mode does the work. An unknown or malformed nova: token is skipped and logged, never fatal; more than 64 nova: tokens rejects the whole credential. Non-nova: scope entries are ignored.
"scope": "nova:append:acme-prod:orders%2F* nova:read:acme-*:*"What each action covers
| Action | Covers |
|---|---|
append | Append, AppendSession, Fence |
read | Read, Subscribe, CheckTail, GetStream, GetBucket, ReadPlan |
list | ListStreams, ListBuckets; the page's scope must sit inside a granted matcher, and outside is denied, never filtered |
create | CreateStream, CreateBucket, UpdateBucket |
delete | DeleteStream, DeleteBucket |
trim | Trim: destructive, never bundled with anything |
Two rules follow. Bucket-level operations need whole-bucket authority: a verb with no key (CreateBucket, GetBucket, UpdateBucket, DeleteBucket, a bucket-scoped list) matches only a grant whose key match is a lone *; ListBuckets, the one fleet-wide page, needs the full wildcard *:*. Autocreate rides the triggering action: an append to a missing stream in a bucket with create_stream_on_append needs only append; explicit CreateStream needs create.
Strict deny per plane
In jwt mode there is no unscoped caller:
| Plane | Requires |
|---|---|
| data | a covering resource grant, or nova:admin |
| admin | nova:admin (the one star-initiated verb, SettleStar, also accepts nova:node) |
nova:node never grants the data plane. Denial is PermissionDenied (HTTP 403), checked before an address resolves so it is never an existence oracle.
The auxiliary claim
nova_aux carries what is not a grant: fencing_token pins the fencing token an open under this token must present, and limits (rps, connections, read_Bps, write_Bps) are ceilings the Gateway enforces per instance. Unknown fields are ignored.
Token lifetime is session lifetime
exp is mandatory. The verifier stamps it as the request's deadline, so no producer session, subscription, or SSE stream outlives its credential; a reconnect with a fresh token resumes (the SDK's resend buffer, Last-Event-ID). sub is the caller's audit identity and carries no authorization.
JWT sources
jwt mode takes exactly one verification source:
| Flag / env | Algorithms | Notes |
|---|---|---|
--jwt-hmac-secret-file | HS256 | one shared secret in a file |
NOVAD_JWT_HMAC_SECRET | HS256 | the secret inline; prefer the env over --jwt-hmac-secret |
NOVAD_JWT_HMAC_KEYS | HS256 | a named key set, id:secret,id:secret: the first signs, all verify, minted tokens stamp their kid; rotate by prepending and rolling, revoke by removing |
--jwt-jwks-url | RS256/384/512, ES256/384/512, EdDSA | the key set is fetched at start (a dead endpoint fails boot) and refreshed hourly |
--jwt-oidc-issuer | asymmetric, iss pinned | discovers the JWKS from {issuer}/.well-known/openid-configuration |
--jwt-audience enforces aud when set. Symmetric and asymmetric families never mix on one verifier, so a key-confusion token is refused structurally. Under a symmetric source novad can also sign: the exchange and peer self-minting work; under JWKS or OIDC novad is verify-only and minting belongs to your issuer (the exchange answers Unimplemented, 501 over HTTP).
Api-key credentials and the exchange
A credential is a registry row: a key id (ck_ plus 16 hex digits when generated by nova), the SHA-256 of its secret, and its grants in the scope grammar. Credentials never expire and are store-checked, so revocation is immediate. The pair authenticates exactly one verb: ExchangeToken. Everything else speaks the tokens it mints.
The ask names exactly one narrowing: bucket (the credential's grants rewritten to that bucket) or scope (an explicit subset, which must sit whole inside the held grants). The TTL defaults to 15 minutes and is clamped to one hour. An unknown key id and a wrong secret are indistinguishable (Unauthenticated); a revoked credential answers PermissionDenied while its already-minted tokens live out their TTL.
Over HTTP the same verb is POST /v1/token/exchange with {"key_id","secret","bucket"|"scope","ttl_seconds"}: the one route the auth middleware exempts.
The Go SDK does the whole flow when you set Options.APIKey: it mints on first use, caches one token per target bucket (64 asks, oldest evicted), and re-mints 30 seconds before expiry. APIKeyCredential.ExchangeURL points minting at any external endpoint speaking the same JSON envelope, such as a control plane that mints under a JWKS deployment.
Bootstrapping the root credential
A fresh cluster has an empty registry. Generate the root pair offline and hand novad the seed, which holds only the hash:
nova credential generate --scope nova:admin --seed-out root.json
# prints key_id and the secret exactly once; root.json holds {key_id, secret_hash, scope}Then start every star with one of:
| Flag / env | Form |
|---|---|
--root-credential-file root.json | the seed file |
NOVAD_ROOT_CREDENTIAL='{…}' | the seed inline (nova credential generate --env prints this line) |
--root-key-id ck_… + NOVAD_ROOT_SECRET | the plain pair; novad hashes it at boot, scope fixed at nova:admin |
The seed converges into the registry at every start (created when absent, hash and scope overwritten when drifted), so regenerating the seed is the rotation.
Managing credentials
nova reaches the admin plane with an admin credential: --token (NOVA_TOKEN) for a bearer, --admin-user/--admin-password under basic, --tls-ca/--tls-cert/--tls-key for TLS and mTLS.
export NOVA_ADDR=localhost:7781
export NOVA_TOKEN=$(NOVA_EXCHANGE_SECRET=... nova token exchange --key-id ck_root --scope nova:admin)
nova credential create --scope 'nova:append:apps:orders%2F* nova:read:apps:*'
# ck_1f3a… secret: … (shown once — only the hash is stored)
nova credential list --limit 100 # key ids, state, created, scope — never secrets
nova token exchange --key-id ck_1f3a… --bucket apps --ttl 15m
nova credential revoke ck_1f3a…token exchange reads the secret from NOVA_EXCHANGE_SECRET (keep it out of shell history) and prints the token alone, pipeable into NOVA_TOKEN. credential list pages with --cursor.
Minting offline
nova token mint --subject <sub> --hmac-secret-file <file> [--admin] [--ttl 24h] signs an HS256 token against the same secret novad verifies: HMAC mode only, since nobody can sign for an issuer they do not control. --admin adds scope: nova:admin; without it the token carries no grants. Use it for operator tokens in dev; data-plane tokens come from credentials and the exchange.
The Gateway and the bridge
The Gateway forwards the caller's bearer verbatim on every hop into the fleet, so the leader authorizes the true caller; the Gateway never mints and never holds a cluster credential. When edge verification is armed it verifies the same token early (a cheap 401/403 before bytes move) and novad verifies it again, authoritatively; verification is mandatory whenever the Gateway enforces limits, which consume claims. The data bridge behaves the same way inside the cluster: its middleware runs the star's authenticator and forwards the originating credential on the one-hop proxy to a foreign leader.
Peer service tokens
Stars call each other on the same port under the same auth mode, presenting their own outbound credential: under mtls the serving certificate; under jwt with a symmetric source a self-minted short-lived token carrying nova:node; under jwt with JWKS or OIDC a token you provision with --peer-service-token-file or NOVAD_PEER_SERVICE_TOKEN (required; boot refuses without it); under basic the operator login. nova:node reaches nothing on the data plane.
Source documents
docs/adr/0035-scoped-tokens.mddocs/adr/0014-single-port-uniform-auth.mddocs/adr/0012-client-authentication.mdinternal/transport/auth/cmd/novactl/token.go,cmd/novactl/credential.go