Skip to content

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):

authorize := authn.RequireScopes("deploy", "read:logs")

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:

authorize := authn.RequireClaim("tenant", "acme")

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:

  1. Extract the credential from the transport.
  2. Verify it → Identity (else 401).
  3. authorize(ctx, id) → allow or deny (else 403).
  4. 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.