Skip to main content
Version: 1.28.0

JWT

pkg/auth exposes a JWTManager for stateless auth. It has two modes, which can coexist in the same process:

  • Single secret, HS256 — one shared secret, no key IDs. Good for getting started.
  • Multi-key keyset — several keys with rotation and a public JWKS endpoint. This is the production path.

Single-secret HS256 (quick start)​

mgr := auth.NewJWTManager(secret, 24*time.Hour, "my-issuer")

token, err := mgr.Generate(userID, username, role)
claims, err := mgr.Validate(token)

The secret comes from the jwt_secret config key. Set it through the NUCLEUS_JWT_SECRET environment variable rather than writing it into nucleus.yml — config files end up committed.

jwt_secret is a non-nullable security key: setting it to null, or exporting an empty NUCLEUS_JWT_SECRET, is a boot error rather than a silent fall-back to no secret.

Tokens in this mode carry no kid header.

Multi-key with rotation (production)​

App.New builds App.JWT automatically when jwt_keys[] is set in nucleus.yml. Operators do not call auth.NewJWTManagerFromKeys themselves for the common case.

# nucleus.yml
jwt_issuer: myapp
jwt_current_kid: 2026-q2-rsa
jwt_keys:
- kid: 2026-q2-rsa
algorithm: RS256
pem_path: /run/secrets/jwt-rsa-q2.pem
- kid: legacy-hs
algorithm: HS256
secret_env: JWT_LEGACY_SECRET

AWS Secrets Manager key references​

For keys stored in AWS Secrets Manager, use the aws-sm: scheme in the secret_env or pem_env field instead of a plain environment variable name.

The resolver is a separate module

Add it to your build once, and the aws-sm: scheme works everywhere a key reference is accepted:

import _ "github.com/jcsvwinston/nucleus/providers/secrets-aws"

It lives outside the framework because the AWS SDK is 57 packages that a deployment keeping its keys in environment variables should not carry. Use an aws-sm: reference without the import and startup fails with the go get and import lines — it does not fall back to reading an environment variable of that name.

jwt_keys:
- kid: 2026-q2-rsa
algorithm: RS256
# Fetch the whole SecretString as the PEM document:
pem_env: aws-sm:myapp/prod/jwt-rsa-q2

- kid: 2026-q2-hs
algorithm: HS256
# Fetch the "signing" JSON key out of a JSON-object secret:
secret_env: aws-sm:myapp/prod/jwt-secrets#signing

Reference forms:

FormResolution
aws-sm:<secret-id>The full SecretString of the named secret.
aws-sm:<secret-id>#<json-key>One string-valued key from a JSON-object SecretString.
env:NAME or bare NAMEThe value of the named environment variable (existing behaviour).

The AWS SDK client is built lazily — only when at least one jwt_keys[] entry uses an aws-sm: reference. Deployments that do not use AWS Secrets Manager never trigger AWS credential resolution. The SDK uses the standard AWS credential chain: environment variables, shared config, IAM role, and so on.

Only text-valued secrets are accepted — UTF-8 HMAC secrets or PEM documents. Binary secrets (those with no SecretString) are not supported for JWT key material, and resolving one fails at startup.

App.New selects the construction path automatically:

  • jwt_keys[] non-empty: multi-key manager; jwt_secret is ignored.
  • jwt_keys[] empty, jwt_secret set: legacy single-secret HS256 manager.
  • Both unset: App.JWT == nil with a startup WARN. Tokens are never signed with an empty HMAC key.

For programmatic / non-config use cases:

mgr, err := auth.NewJWTManagerFromKeys([]auth.SigningKey{
{KID: "2026-q2-rsa", Algorithm: auth.RS256, RSAPrivate: priv},
}, "2026-q2-rsa", 24*time.Hour, "my-issuer")

token, _ := mgr.Generate(userID, username, role)
claims, _ := mgr.Validate(token)

Tokens carry a kid header identifying the signing key. Validate looks the key up in the keyset, rejecting tokens whose kid is unknown.

To rotate signing keys without invalidating outstanding tokens:

// 1. Add a new key, mark it as current. New tokens are signed with it.
err := mgr.RotateKey(auth.SigningKey{
KID: "2026-q3-rsa", Algorithm: auth.RS256, RSAPrivate: nextPriv,
}, true)

// 2. Existing tokens (signed with the previous key) keep validating
// until they expire on their own.

// 3. After the access-token lifetime has passed, drop the old key.
err = mgr.RemoveKey("2026-q2-rsa")

HS256 keys are also supported in the keyset (use SigningKey.HMACSecret instead of RSAPrivate); the same rotation primitives apply.

Module access via Runtime​

If your module mints or verifies tokens, use the manager the framework already built from jwt_secret / jwt_keys[]. Do not construct a second auth.JWTManager from a duplicated secret. Capture it once in OnStart:

var jwtMgr *auth.JWTManager

var tokenModule = nucleus.Module[struct{}]{
Name: "tokens",
Prefix: "/tokens",
OnStart: func(ctx context.Context, rt nucleus.Runtime, _ struct{}) error {
jwtMgr = rt.JWT() // *auth.JWTManager; nil when no signing material is configured
return nil
},
Routes: func(r nucleus.Router, _ struct{}) {
r.Post("/issue", issueToken)
},
}

func issueToken(c *nucleus.Context) error {
if jwtMgr == nil {
return errors.Unauthorized("JWT not configured")
}
token, err := jwtMgr.Generate(userID, username, role)
if err != nil {
return err
}
return c.JSON(http.StatusOK, map[string]string{"token": token})
}

rt.JWT() returns nil on an unbacked runtime AND when no signing material is configured (jwt_secret unset and jwt_keys[] empty). Always guard before use.

RotateKey and RemoveKey are operator-level key-lifecycle operations — they mutate shared state and are not safe to call from per-request module code. Use them only in admin or startup paths, exactly as with rt.Authorizer()'s in-memory policy mutations.

JWKS endpoint​

Relying parties consuming RS256 tokens (other services, API gateways, identity proxies) fetch the public key set from a well-known URL.

When at least one RS256 key is present in jwt_keys[], App.New auto-mounts the handler at /.well-known/jwks.json. The bootstrap allow-list already permits anonymous access to that path. No application code is needed.

For non-default paths or a programmatic manager, mount manually:

a.Router.Get("/.well-known/jwks.json", router.FromHTTP(mgr.JWKSHandler()))

The handler emits the standard RFC 7517 / RFC 7518 shape:

{
"keys": [
{
"kid": "2026-q2-rsa",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "<base64url(modulus)>",
"e": "<base64url(exponent)>"
}
]
}

HS256 keys are intentionally excluded from the JWKS response — the endpoint is public and HMAC keys are shared secrets. Callers using HS256-only managers will see an empty keys array.

More than one role​

An identity provider answers with a list — three group memberships, say — and a single role claim has one slot for them. GenerateWithRoles mints a token that carries all of them:

token, err := jwtMgr.GenerateWithRoles(user.ID, user.Username, user.Role, user.Roles)

Role stays the primary one and stays what every existing reader looks at (the policy subject resolver, the rate limiter, your own handlers), so nothing that ignores Roles changes behaviour. claims.HasRole("billing") answers over the whole set, case-insensitively, and claims.AllRoles() returns the primary one first.

Revoking a token​

A bearer token is valid until it expires, and that is what makes it cheap: validating one touches nothing shared. Revocation buys back the case that property cannot cover — a token that leaked, a device that was lost, a sign-out that has to mean something before the expiry — and it costs one lookup per request. Both halves are deliberate, so it is off by default:

jwt_revocation: true
session_store: redis # so every replica shares the list

With it on, every token carries a jti and:

if err := jwtMgr.Revoke(ctx, token); err != nil { ... }
// later, from any replica reading the same session store:
_, err := jwtMgr.ValidateContext(ctx, token) // errors.Is(err, auth.ErrTokenRevoked)

Three properties worth knowing:

  • Revocation is per token, not per user. Revoking the token on a lost phone leaves the laptop signed in.
  • The list lives in the session store, so a deployment that already shares Redis or SQL sessions gets revocation every replica honours. With sessions in memory the list is per process and startup warns: an operator who revoked a token and believes it is dead everywhere is worse off than one who knows it is not.
  • A store that cannot answer denies the token. An outage must not read as "not revoked", which would turn it into an authentication bypass.

Entries are held with the token's own expiry, so the list never grows past the tokens that are still live. Revoke validates the token first: otherwise anyone could deny service by posting a forged id.