Skip to content

Auth (JWT)

pkg/auth/verifier verifies bearer JWTs against one or more trusted issuers and provides a *verifier.Registry into DI. The pkg/auth/fiber and pkg/auth/grpc adapters turn that registry into guard middleware that stashes a verified *verifier.Principal in the request context.

Add verifier.NewModule() to the runtime. It provides *verifier.Registry at modules.auth.verifier.<instance>; each configured issuer’s JWKS is fetched (or discovered via OIDC) and refreshed in the background.

lakta.NewRuntime(
config.NewModule(config.WithConfigDirs(".", "./config"), config.WithArgs(os.Args[1:])),
tint.NewModule(),
slog.NewModule(),
verifier.NewModule(),
fiberserver.NewModule(fiberserver.WithRouterCtx(registerRoutes)),
)
modules:
auth:
verifier:
default:
roles_claim: "roles" # scope_claim defaults to "scope"
issuers:
- issuer: "https://accounts.example.com"
audience: ["my-api"]
algorithms: ["RS256"]
# jwks_url omitted -> discovered from the issuer's OIDC document
- issuer: "https://auth.internal"
audience: ["my-api"]
jwks_url: "https://auth.internal/.well-known/jwks.json"
algorithms: ["ES256"]
clock_skew: "30s"

Each issuer’s audience and algorithms must be non-empty; alg:none is always rejected and clock_skew is capped at 5 minutes.

static_key is an isolated HS256 path for local development. It only loads when LAKTA_PROFILE is set to one of its profiles (default dev, local, test); otherwise Init fails safe rather than trusting the secret. Never enable it in production.

# under modules.auth.verifier.default:
static_key:
secret: "dev-only-secret"
profiles: ["dev", "local"]

WithRouterCtx gives the router the runtime context, so you can Invoke the registry and build the guard middleware. authfiber.New verifies the bearer token; compose RequireScope, RequireRole, RequireAny, or RequireAll after it. Use Optional to allow anonymous requests through.

func registerRoutes(ctx context.Context, app *fiber.App) {
reg, err := lakta.Invoke[*verifier.Registry](ctx)
if err != nil {
panic(err)
}
authn, err := authfiber.New(reg, config.DefaultInstanceName) // errors only if reg is nil
if err != nil {
panic(err)
}
items := app.Group("/items", authn)
items.Get("/", listItems)
items.Post("/", createItem, authfiber.RequireScope("items:write"))
}

Build an interceptor with authgrpc.NewUnaryServerInterceptor and pass it via grpcserver.WithUnaryInterceptor. Wrap it in selector.UnaryServerInterceptor to skip anonymous methods. Enforce fine-grained access inside handlers with authgrpc.RequireScope / authgrpc.RequireRole.

// reg is the *verifier.Registry resolved from DI, as in the fiber example.
interceptor, err := authgrpc.NewUnaryServerInterceptor(reg, config.DefaultInstanceName)
if err != nil {
return err
}
grpcserver.NewModule(
grpcserver.WithService(&v1.MyService_ServiceDesc, NewServer()),
grpcserver.WithUnaryInterceptor(interceptor),
)
func (s *MyServer) DeleteThing(ctx context.Context, req *pb.DeleteThingRequest) (*pb.DeleteThingResponse, error) {
if err := authgrpc.RequireScope(ctx, "things:delete"); err != nil {
return nil, err
}
// ...
}

After a token verifies, the adapters stash a *verifier.Principal. Retrieve it with verifier.PrincipalFrom(ctx) — the same call works from fiber (c.Context()) and gRPC handlers.

p, ok := verifier.PrincipalFrom(c.Context())
if !ok {
// anonymous request (only reachable behind Optional)
}
// p.Subject, p.Issuer, p.Audience, p.Scopes, p.Roles, p.Claims (raw map), p.Token
if p.HasRole("admin") {
// ...
}

pkg/auth/connect fills the connect module’s auth slot:

connect.WithSlot(connect.SlotAuth, authconnect.New(
authconnect.WithPublicProcedures(func(procedure string) bool { return procedure == "/pkg.Svc/Public" }),
))

A missing token passes only for public procedures; an invalid token is always rejected. Handlers read the *verifier.Principal (with Session from sid and the self-redacting Raw token for forwarding) via verifier.PrincipalFrom(ctx). Every rejection emits a security_event log line with the internal reason (missing, expired, audience, …); the wire error stays an opaque UNAUTHENTICATED. Streams are cancelled when the token expires.

authconnect.WithAuthorizer(a) wires the auth slot, the authz slot (a.Slot()) and a mounted-service hook (a.Mounted) in one option. When a also has IsPublic(procedure string) bool, those procedures accept calls without a token. Add connect.WithRequiredSlots(connect.SlotAuth, connect.SlotAuthz) so a missing authorizer, or a handler mounted outside the slots, fails Init:

connect.NewModule(
authconnect.WithAuthorizer(authorizer),
connect.WithRequiredSlots(connect.SlotAuth, connect.SlotAuthz),
connect.WithService(registrar),
)

Issuer keys are discovered and fetched in the background (or on the first request), so services start while the identity provider is down. Until the first fetch succeeds, requests fail with UNAVAILABLE and the readiness check (registered on *health.Health when present) fails. Later refresh failures keep the cached keys.

Under any profile other than dev or test (including an unset one), the verifier refuses an empty issuer list, static_key, and non-HTTPS issuer or JWKS URLs. token_type: Bearer additionally requires the payload typ claim (Keycloak access tokens).

Config path: modules.auth.verifier.<name>

issuers
issuers.issuerstring

issuer is matched against the token iss exactly (no prefix/substring)

env
issuers.audience[]string

audience MUST be non-empty; the token aud must intersect it

env
issuers.jwks_urlstring

JWKSURL is the JWKS endpoint; empty triggers OIDC discovery from Issuer

env
issuers.algorithms[]string

algorithms is a hard allowlist, e.g. [RS256, ES256]; alg:none is rejected

env
issuers.clock_skewtime.Duration

clockSkew is capped at maxClockSkew

env
issuers.token_typestring

tokenType, when set, requires the payload claim "typ" to equal it

env
static_key
static_key.algorithmstring
envLAKTA_MODULES__AUTH__VERIFIER__<NAME>__STATIC_KEY__ALGORITHM
static_key.secretstring
envLAKTA_MODULES__AUTH__VERIFIER__<NAME>__STATIC_KEY__SECRET
static_key.profiles[]string
envLAKTA_MODULES__AUTH__VERIFIER__<NAME>__STATIC_KEY__PROFILES
scope_claimstringdefault: scope
envLAKTA_MODULES__AUTH__VERIFIER__<NAME>__SCOPE_CLAIM
roles_claimstring
envLAKTA_MODULES__AUTH__VERIFIER__<NAME>__ROLES_CLAIM