novadocs
Guides

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.

ModeCredentialWho the caller isNeeds
off (default)nonetrusted; every check passesnothing; the dev posture
mtlsa client certificatetrusted--tls-cert/--tls-key and --tls-client-ca
jwtAuthorization: Bearer <token>exactly what the token's grants coverone JWT source; TLS or --insecure-auth
basicAuthorization: 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, so orders/eu is orders%2Feu and 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.

A token that writes one prefix and reads a bucket family
"scope": "nova:append:acme-prod:orders%2F* nova:read:acme-*:*"

What each action covers

ActionCovers
appendAppend, AppendSession, Fence
readRead, Subscribe, CheckTail, GetStream, GetBucket, ReadPlan
listListStreams, ListBuckets; the page's scope must sit inside a granted matcher, and outside is denied, never filtered
createCreateStream, CreateBucket, UpdateBucket
deleteDeleteStream, DeleteBucket
trimTrim: 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:

PlaneRequires
dataa covering resource grant, or nova:admin
adminnova: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 / envAlgorithmsNotes
--jwt-hmac-secret-fileHS256one shared secret in a file
NOVAD_JWT_HMAC_SECRETHS256the secret inline; prefer the env over --jwt-hmac-secret
NOVAD_JWT_HMAC_KEYSHS256a 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-urlRS256/384/512, ES256/384/512, EdDSAthe key set is fetched at start (a dead endpoint fails boot) and refreshed hourly
--jwt-oidc-issuerasymmetric, iss pinneddiscovers 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:

Generate the root credential
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 / envForm
--root-credential-file root.jsonthe seed file
NOVAD_ROOT_CREDENTIAL='{…}'the seed inline (nova credential generate --env prints this line)
--root-key-id ck_… + NOVAD_ROOT_SECRETthe 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.

Create, list, exchange, revoke
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.md
  • docs/adr/0014-single-port-uniform-auth.md
  • docs/adr/0012-client-authentication.md
  • internal/transport/auth/
  • cmd/novactl/token.go, cmd/novactl/credential.go

On this page