Skip to content

Choose & configure a verifier

authn ships three verifiers. Pick by the credential your clients present:

Credential Constructor Interface
Shared API key / token NewAPIKeyVerifier Verifier
JWT (OIDC / OAuth2 bearer) NewJWTVerifier Verifier
Client certificate (mTLS) NewMTLSVerifier CertVerifier

API-key and JWT verifiers satisfy Verifier (Verify(ctx, credential string)). mTLS uses CertVerifier because a certificate is a transport property, not a credential string.

API keys

NewAPIKeyVerifier takes a set of {Key, Subject} entries and matches presented keys in constant time:

verifier, err := authn.NewAPIKeyVerifier(
    authn.KeyEntry{Key: "s3cr3t", Subject: "ci-bot"},
    authn.KeyEntry{Key: "adm1n",  Subject: "admin"},
)

The resulting Identity.Method is "apikey" and Subject is the matched entry's label. Claims and Scopes are always nil, so a claim- or scope-based authorization check will never admit an API-key identity.

Construction fails in exactly two cases: no entries at all, and an entry whose Key is empty. It does not reject duplicate keys — two entries sharing a Key build fine and the last one wins — and it does not reject an empty Subject, which produces an identity with an empty subject. Full behaviour and error strings are in the verifiers reference.

JWT / OIDC

NewJWTVerifier validates a signed JWT: the signature against a cached JWKS, then exp, nbf, iss and aud. The verified Claims and parsed Scopes land on the Identity.

Two of those are mandatory on the token itself: a JWT with no exp claim, or no iss claim, is rejected. nbf is checked only when present. iat is not checked at all, and neither is sub — a token without a subject verifies successfully with an empty Identity.Subject. exp and nbf are both compared with Leeway (default 60s) of tolerance.

Point it at a JWKS endpoint directly:

verifier, err := authn.NewJWTVerifier(ctx, authn.JWTConfig{
    Issuer:    "https://issuer.example.com",
    Audiences: []string{"my-api"},
    JWKSURL:   "https://issuer.example.com/.well-known/jwks.json",
})

…or let OIDC discovery resolve the JWKS URL from the issuer's /.well-known/openid-configuration:

verifier, err := authn.NewJWTVerifier(ctx,
    authn.JWTConfig{Audiences: []string{"my-api"}},
    authn.WithOIDCDiscovery("https://issuer.example.com"),
)

Issuer is omitted from that second example on purpose. Discovery sets both JWKSURL and Issuer from the document, overwriting anything you configured — so setting Issuer alongside WithOIDCDiscovery looks like belt and braces and is actually ignored.

Getting the OIDC issuer URL exactly right

The URL you pass to WithOIDCDiscovery is compared byte for byte against the issuer value in the discovery document. A trailing slash is enough to fail construction:

authn: OIDC document issuer "https://issuer.example.com" does not match "https://issuer.example.com/"

Fetch https://<issuer>/.well-known/openid-configuration yourself and copy the issuer field out of it verbatim.

JWKS caching and algorithm pinning

RefreshInterval (default 15m) is how long a fetched key set is treated as current, not a floor on fetch frequency. While the cache is current, a token whose kid is not in it is rejected without a refresh being attempted — so a signing key added mid-interval is not picked up until the interval elapses. Why a rotated signing key can be rejected for up to fifteen minutes covers what to do about that.

AllowedAlgorithms defaults to the asymmetric RS/ES 256/384/512 set. none and any HS* algorithm are refused at construction rather than at verification, so there is no configuration in which the algorithm-confusion attack is reachable. PS256/PS384/PS512 verify correctly but must be listed explicitly. Spell algorithm names in upper case — the accept list is matched against the token header verbatim, so "rs256" builds fine and then rejects every token.

The JWKS endpoint must be HTTPS, with no exemption for localhost. Testing against a local issuer means an httptest.NewTLSServer and passing its client through JWTConfig.HTTPClient.

mTLS

NewMTLSVerifier derives an identity from the client certificate chain the TLS stack already verified. Because the credential is the connection, it implements CertVerifierVerifyCert(ctx, verifiedChains) — which you call from your transport with tls.ConnectionState().VerifiedChains:

cv := authn.NewMTLSVerifier()

id, err := cv.VerifyCert(ctx, connState.VerifiedChains)

By default Identity.Subject is the leaf certificate's Common Name; if that is empty, the first DNS SAN; if that is empty too, the first URI SAN. Note that this is the CN alone, not the full RDN sequence of the certificate subject. Override how the subject is derived — for example to use a SAN URI or a specific RDN — with WithCertSubject:

cv := authn.NewMTLSVerifier(
    authn.WithCertSubject(func(c *x509.Certificate) string {
        if len(c.URIs) > 0 {
            return c.URIs[0].String() // SPIFFE-style identity
        }
        return c.Subject.CommonName
    }),
)

VerifyCert rejects an empty chain, so a connection without a presented client certificate fails closed. It also rejects a certificate whose subject derivation returns an empty string — including when your own WithCertSubject function returns "", so make sure yours has a fallback.

VerifyCert does no cryptographic work of its own: chain building, trust-root validation and expiry are the TLS stack's job, and the server must be configured with tls.RequireAndVerifyClientCert and a ClientCAs pool for VerifiedChains to be populated at all. Handing it a chain you assembled yourself skips the check entirely.

Accepting more than one credential type

The verifiers are independent values, and there is no built-in helper for trying several in turn. To accept more than one credential type, run the applicable verifier for the credential the transport extracted and treat success as authenticated:

switch {
case len(connState.VerifiedChains) > 0:
    id, err = mtlsVerifier.VerifyCert(ctx, connState.VerifiedChains)
case strings.Count(bearer, ".") == 2:
    id, err = jwtVerifier.Verify(ctx, bearer)
default:
    id, err = apiKeyVerifier.Verify(ctx, bearer)
}

Each returns a uniform Identity, so the rest of your stack — and your authorization check — does not care which one succeeded. Identity.Method tells it if it does care.

Prefer choosing one verifier per request over running them all and taking the first success. Trying every verifier leaves you with several errors and no clean answer to "why was this rejected", and it puts every credential through every code path for no gain.