Authorize verified requests¶
Authentication tells you who the caller is; authorization decides what they may do.
authn keeps these separate: once a Verifier returns an Identity, an
AuthorizeFunc
predicate makes the allow/deny call. The package ships no policy engine — you compose
the built-in combinators or supply your own function.
The predicate¶
An AuthorizeFunc receives the verified identity (and, via the context, the request
metadata) and returns true to allow or false to deny:
var authorize authn.AuthorizeFunc = func(ctx context.Context, id *authn.Identity) bool {
return id != nil && id.Subject == "admin" // true = allow, false = deny
}
The transport maps a false return to a generic 403 / PermissionDenied — the
predicate makes the decision; it does not shape the client-facing response.
Guard against a nil identity, as above. authn never calls your predicate itself, so
whether one can arrive is down to your call site; the built-in combinators guard
defensively and yours should too.
Built-in combinators¶
Require scopes¶
RequireScopes
allows the request only if the identity carries all the named scopes (parsed from
the token's scope/scp claim into Identity.Scopes):
There is no any-of variant — RequireScopes is all-of only, and expressing "either of
these" means writing the predicate yourself.
Called with no arguments, RequireScopes() admits any non-nil identity, so it means
"being authenticated is enough" rather than "deny everything".
Only JWT identities carry scopes. API-key and mTLS identities have nil Scopes, so
any named scope denies them.
Require a claim¶
RequireClaim
allows the request only if a verified JWT claim deep-equals an expected value:
Only JWT identities carry claims, so RequireClaim denies every API-key and mTLS
identity unconditionally.
Match the claim's JSON type, not the Go type you expect¶
Claims are decoded from JSON, so a numeric claim is a float64 and an array claim is a
[]any. Deep equality is strict about that, and a mismatch denies silently:
authn.RequireClaim("tier", 1) // never matches "tier": 1 — the claim is a float64
authn.RequireClaim("tier", float64(1)) // matches
The trap that catches most people is a group or role claim, which is almost always an array:
// "groups": ["platform-team", "oncall"] in the token
authn.RequireClaim("groups", "platform-team") // always false
RequireClaim has no "contains" mode. For membership, range over the claim yourself:
var authorize authn.AuthorizeFunc = func(_ context.Context, id *authn.Identity) bool {
if id == nil {
return false
}
groups, _ := id.Claims["groups"].([]any)
for _, g := range groups {
if s, ok := g.(string); ok && s == "platform-team" {
return true
}
}
return false
}
Both combinators return an AuthorizeFunc, so they drop straight into the same call
site as a hand-written predicate.
Using request metadata¶
An AuthorizeFunc can also see what is being accessed via
RequestMetadata
(the HTTP method + path, or grpc + the full method name). Your transport puts it on
the context; the predicate reads it:
ctx = authn.ContextWithRequestMetadata(ctx, authn.RequestMetadata{Method: "POST", Path: "/deploy"})
var authorize authn.AuthorizeFunc = func(ctx context.Context, id *authn.Identity) bool {
if md, ok := authn.RequestMetadataFromContext(ctx); ok && md.Method == "POST" {
// stricter rule for mutating requests
return authn.RequireScopes("deploy")(ctx, id)
}
return true
}
This keeps route-aware authorization in one predicate without teaching authn about
your routing table.
Nothing in authn populates RequestMetadata — your adapter does, before it runs the
predicate. A predicate that reads it must cope with it being absent, which is what the
ok check above is for. The fallback matters: the example returns true when no
metadata is present, which is the permissive choice. Return false if you would rather
a missing adapter call fail closed.
What authorization here does not cover¶
The predicate is the whole surface. There is no RBAC engine, no policy language, no rule file, and no decision caching — those are deliberately absent, for the reasons in What authn does not do.
The predicate also runs on every authenticated request, so keep it cheap. If it needs
to call an external authorization service, cache the result yourself; authn will not.
Putting it together¶
The end-to-end shape inside a middleware/interceptor is:
- Extract the credential from the transport.
Verifyit →Identity(else 401).authorize(ctx, id)→ allow or deny (else 403).ContextWithIdentity(ctx, id)and call the next handler.
Steps 2 and 3 are the whole of authn; steps 1 and 4 are transport glue you own.