Last updated 2026-05-28

JWT Keys

notify validates client-facing requests with an HS256 JWT signature. The verification key is a shared secret you generate, hand to the JWT issuer (identity), and hand to notify via NOTIFY_AUTH_JWT_SECRET.

When you'd care

You are setting up a production deployment, rotating a leaked secret, or wiring an existing identity-issued JWT into notify.

Generating the secret

Use a CSPRNG. Anything 32 bytes or larger is fine for HS256 (256 bits of entropy matches the HMAC-SHA-256 output size).

# 32 random bytes as a hex string (64 hex chars)
openssl rand -hex 32
# → 4f7b9b1c8e0a8e3a... (sample)
# Or via /dev/urandom
head -c 32 /dev/urandom | xxd -p -c 64

Pass it to notify

docker run --rm \
-p 8080:8080 -p 8081:8081 -p 9090:9090 \
-e NOTIFY_AUTH_JWT_SECRET=$(openssl rand -hex 32) \
-e NOTIFY_INTERNAL_TOKEN=$(openssl rand -hex 32) \
-e NOTIFY_STORE_DRIVER=memory \
ghcr.io/elloloop/notify:0.1.0

In production prefer a secrets manager (Kubernetes Secret, AWS Secrets Manager, Azure Key Vault) and project the value into the container as an env var.

Pinning issuer and audience

When the JWT issuer is identity, set the matching expected claims so notify rejects tokens minted by anyone else:

-e NOTIFY_AUTH_JWT_ISSUER=https://identity.example.com \
-e NOTIFY_AUTH_JWT_AUDIENCE=notify

If your identity tenant uses different values, copy them verbatim from the issuer config. A missing or mismatched claim returns CodeUnauthenticated.

Token shape

notify expects the standard claims plus a tenant claim:

decoded JWT
{
"alg": "HS256",
"typ": "JWT"
}
.
{
"sub": "user-alice",
"tenant": "acme",
"email": "alice@example.com",
"iss": "https://identity.example.com",
"aud": "notify",
"exp": 1748390400,
"iat": 1748386800
}

sub populates Claims.UserID; tenant (or the alternative tenant_id) populates Claims.TenantID; email is optional and surfaces in observability.

Minting a test token

Tiny one-off generator in Python — useful for manual cURLs against a locally-deployed notify with a real JWT secret:

# requires: pip install pyjwt
import time, jwt
SECRET = "your-shared-hex-secret"
token = jwt.encode(
{
"sub": "user-alice",
"tenant": "acme",
"email": "alice@example.com",
"iat": int(time.time()),
"exp": int(time.time()) + 900,
},
SECRET,
algorithm="HS256",
)
print(token)
# requires: go install github.com/golang-jwt/jwt/v5/cmd/jwt@latest (or hand-roll)
jwt encode --secret "your-shared-hex-secret" --alg HS256 \
'{"sub":"user-alice","tenant":"acme","exp":1748390400,"iat":1748386800}'

Rotation

notify v0.1 validates against a single secret — there is no key ring on the verifier. To rotate without a flap, the standard procedure is:

  1. Issue a new secret S2.
  2. Configure the JWT issuer (identity) to dual-issue: every new token signed with S2, every legacy token still signed with S1 tracked by kid.
  3. Run two notify replicas in parallel: one with NOTIFY_AUTH_JWT_SECRET=S1, one with NOTIFY_AUTH_JWT_SECRET=S2. Route traffic by inspecting the kid in the JWT header at the gateway.
  4. After all S1-signed tokens have expired (one access-token TTL window), retire the S1 replica and the issuer's S1 signing key.

The simpler alternative — rolling everyone out, swapping the secret, rolling back in — is acceptable for short outage windows but is what rotation is supposed to avoid. A future wave will add a multi-key verifier so notify can hold both keys natively.

What if my JWT is RS256?

v0.1 only validates HS256. If your identity provider signs RS256 (any standard OIDC IdP exposing a JWKS endpoint), the recommended shape today is:

  • Run a thin reverse proxy / gateway in front of notify that verifies the RS256 token against JWKS, then re-mints an HS256 token for notify's downstream consumption.
  • Or run notify in library mode with your own validator wired in via server.Dependencies.AuthValidator.

Native RS256 + JWKS verification is on the roadmap; track notify issues.

Related