Last updated 2026-05-28
Auth Model
notify validates two kinds of credentials, one per service:
NotificationClientService(browser / mobile) —Authorization: Bearer <JWT>, HS256 againstNOTIFY_AUTH_JWT_SECRET.NotificationInternalService(backend producers) —X-Notify-Internal-Token, constant-time compare againstNOTIFY_INTERNAL_TOKEN.
When you'd use which
Anything calling Notify (sending a notification) is a
backend producer and uses the internal token. Anything streaming a
user's own notifications, reading their inbox, marking rows read or
registering a push token is the recipient SPA / mobile app and uses
a JWT issued for that user.
JWT validation (client surface)
The validator is a thin wrapper around github.com/golang-jwt/jwt/v5:
type JWTValidator struct { secret []byte issuer string audience string leeway time.Duration now func() time.Time}Token requirements:
- Algorithm must be HS256. Anything else is rejected (
jwt.WithValidMethods). subclaim must be a non-empty string. It becomesClaims.UserID.tenant(ortenant_id) claim must be a non-empty string. It becomesClaims.TenantID.- If
NOTIFY_AUTH_JWT_ISSUERis set, theissclaim must match. - If
NOTIFY_AUTH_JWT_AUDIENCEis set, theaudclaim must match. expandnbfare validated withNOTIFY_AUTH_JWT_LEEWAY(default 30s) slack.
A valid token results in a typed Claims on the request context:
type Claims struct { UserID string TenantID string Email string ExpiresAt time.Time}
Handlers read it via ClaimsFromContext(ctx). The raw
jwt.MapClaims is intentionally not exposed — handlers
never reach for fields the platform did not promise.
Tenant comes from the JWT, never the body
Why this design. Every client-side RPC scopes data by(tenant, user). If a client could pick the tenant via the request body, a logged-in user could mint requests that read another tenant's data simply by typing a different tenant_id. The platform takesClaims.TenantIDfrom the JWT and ignores any request-body tenant field on the client surface — the producer surface is the only place tenant is supplied via the body, and that surface is internal-token gated.
Internal token (producer surface)
func NewInternalAuthInterceptor(expected string, devMode bool) connect.UnaryInterceptorFunc { return func(next connect.UnaryFunc) connect.UnaryFunc { return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { if expected == "" { if devMode { return next(ctx, req) } return nil, unauthenticated(errors.New("internal token not configured")) } got := req.Header().Get("X-Notify-Internal-Token") if subtle.ConstantTimeCompare([]byte(got), []byte(expected)) != 1 { return nil, unauthenticated(errors.New("internal token mismatch")) } return next(ctx, req) } }} subtle.ConstantTimeCompare avoids the timing side-channel
of ==. The container refuses to boot with
DevMode=false and no NOTIFY_INTERNAL_TOKEN
set — there is no "internal calls go unauthenticated" footgun.
Dev mode
NOTIFY_AUTH_DEV_MODE=true relaxes the boot-time validation
so local development doesn't need a JWT signer:
- The client validator accepts
Bearer dev:<userid>:<tenant>[:<email>]. - The internal interceptor skips the header check when
NOTIFY_INTERNAL_TOKENis also unset.
Sample dev tokens:
# user-alice in tenant acmeAuthorization: Bearer dev:user-alice:acme
# with an email claim for observabilityAuthorization: Bearer dev:user-alice:acme:alice@example.comNever enable dev mode in production. The container rejects boots that try to disable both real auth modes simultaneously.
Authorization vs authentication
Authentication answers "is this user who they say they are". notify answers it via JWT verification.
Authorization — "may this authenticated user read this row?" — is
enforced at the handler boundary by scoping every Store call to
(claims.TenantID, claims.UserID). The standout case is
AckNotification: Store.UpdateStatus takes
only tenantID + id, not userID,
so the handler does a GetNotification(claims.TenantID,
claims.UserID, id) first. A cross-user attempt surfaces as
ErrNotFound, which maps to CodeNotFound on
the wire — never CodePermissionDenied, which would leak
the existence of someone else's row.
Related
- JWT Keys — generating and rotating the HS256 secret
- Configuration — every auth env var
- API Reference — full RPC table with auth requirements