Last updated 2026-05-28

Auth Model

notify validates two kinds of credentials, one per service:

  • NotificationClientService (browser / mobile) — Authorization: Bearer <JWT>, HS256 against NOTIFY_AUTH_JWT_SECRET.
  • NotificationInternalService (backend producers) — X-Notify-Internal-Token, constant-time compare against NOTIFY_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).
  • sub claim must be a non-empty string. It becomes Claims.UserID.
  • tenant (or tenant_id) claim must be a non-empty string. It becomes Claims.TenantID.
  • If NOTIFY_AUTH_JWT_ISSUER is set, the iss claim must match.
  • If NOTIFY_AUTH_JWT_AUDIENCE is set, the aud claim must match.
  • exp and nbf are validated with NOTIFY_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 takes Claims.TenantID from 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_TOKEN is also unset.

Sample dev tokens:

# user-alice in tenant acme
Authorization: Bearer dev:user-alice:acme
# with an email claim for observability
Authorization: Bearer dev:user-alice:acme:alice@example.com

Never 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