Skip to content

Identity, authorization and context reference

What a successful verification gives you, and what you can do with it.

Identity

type Identity struct {
    Subject string
    Method  string
    Claims  map[string]any
    Scopes  []string
}

The verified outcome of authentication. What each field holds depends on which verifier produced it:

Field API key JWT mTLS
Subject the entry's Subject label the sub claim the derived certificate subject
Method "apikey" "jwt" "mtls"
Claims nil every verified claim nil
Scopes nil parsed from scope or scp nil

Subject can be empty on a successful verification

Subject is not guaranteed non-empty. Two cases produce "" with no error:

  • an API-key entry configured without a Subject;
  • a JWT with no sub claim — sub is not required, and a token missing it verifies successfully with an empty subject.

The mTLS verifier is the exception: it rejects a certificate whose subject derivation returns "". If your authorization depends on Subject, check it for emptiness rather than assuming a verified identity is a named one.

Claims holds JSON types, not Go types

Claims comes straight from JSON decoding, so every number is a float64, every array is a []any, and every object is a map[string]any. This matters for RequireClaim, which compares by deep equality.

Scopes only parses a space-delimited string

Scopes is filled from the scope claim if it is a non-empty string, otherwise from scp if that is a non-empty string, otherwise it is nil. scope is checked first, so a token carrying both uses scope and ignores scp.

A scope claim that is a JSON array — which some issuers emit — yields no scopes at all and no error. RequireScopes will then deny every request, and nothing tells you why. Read Identity.Claims["scope"] yourself in that case.

Verifier

type Verifier interface {
    Verify(ctx context.Context, credential string) (*Identity, error)
}

Implemented by the API-key and JWT verifiers. The credential is the raw bearer token or key, already extracted from the transport — authn never parses a header.

CertVerifier

type CertVerifier interface {
    VerifyCert(ctx context.Context, verifiedChains [][]*x509.Certificate) (*Identity, error)
}

Implemented by the mTLS verifier only. It is a separate interface because a certificate is a property of the connection rather than a string a caller supplies, so there is no credential to pass.

CertVerifier and Verifier are unrelated types. A helper that takes a Verifier will not accept an mTLS verifier, and there is no adapter between them in the package.

ErrUnauthenticated

var ErrUnauthenticated = errors.New("authn: unauthenticated")

The sentinel every credential rejection wraps. Test for it with errors.Is:

switch {
case err == nil:
    // authenticated
case errors.Is(err, authn.ErrUnauthenticated):
    // a rejected credential — expected, log quietly
default:
    // an operational failure (network, JWKS unreachable) — log loudly
}

The distinction is for your logs. The client sees the same generic 401 either way.

AuthorizeFunc

type AuthorizeFunc func(ctx context.Context, id *Identity) bool

The entire authorization surface. It runs after successful verification; false maps to 403 / PermissionDenied. There is no policy engine, no RBAC model and no rule DSL — the predicate is Go code you write, and it can call out to whatever external authorization you have.

It runs on every authenticated request, so keep it fast and side-effect free.

authn never calls an AuthorizeFunc itself — your middleware or interceptor does, so whether a nil Identity can reach it is up to your call site. The built-in combinators handle nil defensively; a hand-written predicate should too.

RequireScopes

func RequireScopes(scopes ...string) AuthorizeFunc

Admits an identity only if Identity.Scopes contains all of the named scopes.

  • A nil identity is denied.
  • With no scopes named, it admits any non-nil identity — including one with no scopes at all. RequireScopes() is therefore "authenticated is enough", not "deny everything".
  • API-key and mTLS identities have nil scopes, so any named scope denies them.

RequireClaim

func RequireClaim(name string, value any) AuthorizeFunc

Admits an identity only if Identity.Claims[name] deep-equals value (reflect.DeepEqual, which avoids a panic on a non-comparable claim value).

  • A nil identity, or one with nil Claims, is denied. API-key and mTLS identities always have nil claims, so RequireClaim denies them unconditionally.
  • Types must match exactly. Claims are decoded from JSON, so a numeric claim is a float64: RequireClaim("tier", 1) never matches a token carrying "tier": 1, while RequireClaim("tier", float64(1)) does.
  • Array claims need an array value. A groups claim of ["platform-team"] decodes to []any{"platform-team"}, so RequireClaim("groups", "platform-team") is always false. Either compare against []any{"platform-team"} — which requires the whole array to match, not just membership — or write a predicate that ranges over the claim.

There is no built-in "claim contains" or "any-of" combinator. Membership tests are a hand-written predicate.

RequestMetadata

type RequestMetadata struct {
    Method string
    Path   string
}

The minimal transport facts a predicate may want. Method is the HTTP method (GET, POST, …) or "grpc" for an RPC; Path is the HTTP request path or the gRPC full method name.

Nothing in authn populates it. Your adapter calls ContextWithRequestMetadata before running authorization, and a predicate that reads it must handle the metadata being absent.

Context helpers

func ContextWithIdentity(ctx context.Context, id *Identity) context.Context
func IdentityFromContext(ctx context.Context) (*Identity, bool)

func ContextWithRequestMetadata(ctx context.Context, m RequestMetadata) context.Context
func RequestMetadataFromContext(ctx context.Context) (RequestMetadata, bool)

Both pairs use unexported context keys, so nothing outside the package can collide with them or overwrite a stored value by accident. The HTTP and gRPC adapters share the same keys, which is what lets a handler read the identity the same way regardless of transport.

The bool is false when nothing was stored. IdentityFromContext also returns false if a different type was stored under the key, which cannot happen through these functions.

A stored nil identity reports ok == true

ContextWithIdentity(ctx, nil) stores a typed nil, and IdentityFromContext then returns (nil, true) — the ok flag says "something was stored", not "something usable was stored". A handler written as if id, ok := IdentityFromContext(ctx); ok { use(id.Subject) } panics on that context. Only store an identity you actually got back from a verifier, and check for nil if the value could have come from elsewhere.