jwt

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: Apache-2.0 Imports: 30 Imported by: 0

README

github.com/ubgo/auth/plugin/jwt

Issues short-lived JSON Web Tokens from a signed-in session, so another service can tell who is calling by checking a signature against the app's published public keys, without access to the auth database.

Install

go get github.com/ubgo/auth

The package is part of the core module github.com/ubgo/auth and adds no dependency: signing is Ed25519 (EdDSA, the default), RSA (RS256, RS384, RS512, PS256, PS384, PS512) or ECDSA (ES256, ES384, ES512) from the standard library, key rotation derives keys with crypto/hkdf, and the verifier needs no JWT library.

Wire it

From examples/api-keys-and-jwt:

a, err := auth.New(
	auth.WithDB(db, sqlb.SQLiteDialect{}),
	auth.WithBaseURL(baseURL),
	auth.WithPlugins(
		email.New(email.Login(), email.Recover(), email.WithMailer(m)),
		password.New(),
		jwt.New(jwt.WithSigningKey(key), jwt.Audience("reports"), jwt.Claims(nameClaim)),
	),
)

key is an ed25519.PrivateKey; in production load it from your secret store (the example reads a 32-byte seed from JWT_SIGNING_KEY and calls ed25519.NewKeyFromSeed). The key chooses the algorithm: an *rsa.PrivateKey of at least MinRSABits (2048) signs RS256 (or, bound by RSAKey(key, alg), RS384, RS512, PS256, PS384 or PS512), an *ecdsa.PrivateKey the algorithm of its curve (P-256 ES256, P-384 ES384, P-521 ES512), and any crypto.Signer of those types works, a KMS client included; GenerateKey(alg) makes one. The plugin needs a sign-in method (any plugin such as password) because a token is minted from a session.

auth.New fails when:

Condition Error
No key, or an Ed25519 key that is not ed25519.PrivateKeySize bytes, and no WithSigner ErrNoKey: "jwt: pass jwt.WithSigningKey with an Ed25519, RSA or P-256 private key, or jwt.WithSigner"
A signing key, a PreviousKeys key or a Signer key that is not Ed25519, RSA of at least 2048 bits, or ECDSA on P-256 (a P-256 point off the curve included) ErrUnsupportedKey
A JWKSPath that does not start with /, is / alone, holds .., // or a character outside A-Za-z0-9-._~/, or is /token or /template ErrJWKSPath
A WithTemplate name that is empty, over 64 characters, holds a character outside a-z0-9-_, or is given twice ErrTemplateName
A template HMACKey secret under MinHMACSecret (32) bytes ErrHMACSecret
A template key that cannot sign for it: a PublishedKey naming no key (ErrNoAlgorithmKey, ErrNoSigningKey), a nil or unsupported PrivateKey or one with a key id outside A-Za-z0-9-_., or a key whose algorithm is not in the template's Algs ErrTemplateKey
A RemoteJWKS URL that is not absolute https (http only for a loopback host), or has a fragment or user information ErrRemoteJWKS
Rotating with a seed that is not RotationSeedSize (32) bytes, an interval below MinRotationInterval (an hour), or a grace period below the TTL ErrRotationSeed, ErrRotationInterval, ErrRotationGrace
Neither auth.WithBaseURL nor jwt.Issuer is set ErrNoIssuer: "jwt: needs auth.WithBaseURL or jwt.Issuer"

On the receiving side:

v := jwt.Verifier{Keys: jwt.KeysFromJWKS(set), Issuer: baseURL, Audience: "reports"}
claims, err := v.Verify(token) // every failure is jwt.ErrInvalid

A Verifier accepts the typ JWT only; Types: []string{"at+jwt"} accepts the OAuth provider's access tokens instead (an entry outside letters, digits and +-., or the session-cache type, is ErrBadType).

Options

Option What it does Default
WithSigningKey(k crypto.Signer) The key that signs new tokens, and so the algorithm: ed25519.PrivateKey (EdDSA), *rsa.PrivateKey (RS256), an RSAKey (its algorithm), *ecdsa.PrivateKey on P-256, P-384 or P-521 (ES256, ES384, ES512), or a KMS crypto.Signer of one of those none (one of the two is required)
Payload(fn PayloadFunc) App claims from the user and the session, better-auth's definePayload; added after Claims', and a registered claim fails the call like there none
WithSigner(s Signer) A signer in place of the key: Rotating, or your own for a KMS or HSM; PublicKeys lists the keys to publish (the signing key first) and Sign signs with the key the header named none
Rotating(seed []byte, interval, grace time.Duration) A Signer whose Ed25519 key changes every interval, derived from the seed with HKDF-SHA256 per period, so every instance derives the same key with no storage; the key set publishes the keys of the periods that ended within grace none
AlgorithmKeys(keys ...crypto.Signer) Signing keys of other algorithms beside the signing key (better-auth's keyPairConfigs): Sign with SignRequest.Alg set to one of them signs with the first key of it, SignRequest.KeyID picks one by key id (ErrNoSigningKey otherwise), and each is published in the key set; several may share an algorithm, but one key given twice is ErrAlgorithmKeys. The session token route keeps the signing key none
PreviousKeys(keys ...crypto.PublicKey) Keeps publishing old public keys after a rotation, so tokens signed just before it verify until they expire; a key may be of another algorithm than the signing key, which is how a deployment moves from EdDSA to RS256 none
Issuer(iss string) The iss claim the origin of auth.WithBaseURL
Audience(aud ...string) The aud claim: the service, or services, the tokens are for; one value is a string, several an array (RFC 7519 4.1.3), and Verifier accepts either no aud claim
Subject(fn SubjectFunc) Replaces the sub claim (the user id) with what fn(ctx, user, session) returns; an empty result fails the call with ErrEmptySubject sub is the user id
WithTTL(d time.Duration) Token lifetime; a value that is not positive or exceeds MaxTTL (1 hour) becomes MaxTTL DefaultTTL (5 minutes)
Claims(fn func(ctx context.Context, u auth.User) map[string]any) Adds app claims such as a role or tenant none
ClaimSource(fn ClaimsFunc) App claims whose lookup can fail (a database read, such as orgjwt.Claims): added after Payload's, and an error or a registered claim fails the token call rather than minting a token without the claim none
UserClaim(name, read) A ClaimsFunc writing one claim from a per-user JSON document, such as plugin/metadata's public document: ClaimSource(UserClaim("public_metadata", meta.PublicOf)). A failing read or invalid JSON fails the call. Never pass private metadata: a JWT is signed, not encrypted none
WithTemplate(name string, t Template) Registers a named token template a signed-in client asks for with POST /jwt/template (Clerk's getToken({ template })); see Templates below none
JWKSPath(path string) Serves the key set at /jwt<path> under the mount prefix instead of DefaultJWKSPath; the old path answers 404 (better-auth's jwks.jwksPath) /jwks
RemoteJWKS(url string) The key set is published at url (a KMS's JWKS endpoint, a CDN): the local key-set route is not served (404), and JWKSURL() returns url for the app to configure in its verifiers. No token carries the URL (no jku), because a verifier that followed a URL named by the token would let the token choose its keys (better-auth's jwks.remoteUrl, which it advertises only in OpenID discovery) the plugin serves its keys
IssueOnSession() The session read (GET /session, Auth.Session) also mints a token and sends it in the Set-Auth-JWT header, listed in Access-Control-Expose-Headers (better-auth's set-auth-jwt); the token is the one POST /jwt/token returns at that instant, none when signed out, and a minting failure fails the read. A Go caller reads it with SessionJWT(ctx) off

Verifier fields: Keys (key id to crypto.PublicKey; the token's alg must be the algorithm of the key its kid names, so the header never chooses), Lookup (a function tried for a kid that Keys lacks; RemoteKeys.Lookup fetches the issuer's JWKS), Issuer and Audience (checked when not empty), Leeway (default DefaultLeeway, 30 seconds), Now (default time.Now). Verified carries Subject, SessionID, Actor, ExpiresAt (always UTC) and Extra (every app claim, raw JSON). NewRemoteKeys(url, hc) (a nil client gets one with DefaultRemoteTimeout, 10 seconds) fetches and caches a key set, refetching for an unknown kid at most once per RemoteMinRefresh (a minute) and whenever the set is older than RemoteMaxAge (a day); a failed fetch keeps the keys already held and is reported by LastError.

Routes and Go API

Paths are under the mount prefix (/auth in the example). A Query is served as GET, a Mutation as POST.

Route Go API Requirement Rate limit
POST /jwt/token (*Plugin).Token(ctx) (TokenResult, error) SignedIn (an API key is refused with auth.ErrAPIKeyRefused) none
POST /jwt/template {"template": "<name>"} (*Plugin).TemplateToken(ctx, name) (TokenResult, error); (*Plugin).TemplateNames() lists the names SignedIn (an API key is refused like the token route); an unregistered name is ErrUnknownTemplate none
GET /jwt/jwks (/jwt + the JWKSPath; not served under RemoteJWKS) (*Plugin).PublicKeys() JWKS Public none
Go only (*Plugin).Sign(ctx, SignRequest): a token for a subject the app names, with its own audience, TTL (capped at MaxTTL) and claims, under the plugin's key and issuer; SessionID sets sid and Type the typ header (default JWT; an OAuth access token uses at+jwt, and the session-cache type is refused, ErrBadType), Alg and KeyID pick the signing key, and the Subject and Claims options do not apply. For trusted code only: a service token, a webhook receiver, a user the app looked up itself none (app code) none
Go only jwt.Verifier.Verify(token), jwt.KeysFromJWKS(set), jwt.KeyID(pub), jwt.AlgOf(pub), jwt.HashOf(alg), jwt.GenerateKey(alg), jwt.RSAKey(key, alg), jwt.PublicKeyForAlg(jwk, alg), jwt.NewRemoteKeys(url, hc).Lookup none none
Go only (*Plugin).JWKSURL() (the RemoteJWKS URL or ""), (*Plugin).JWKSRoute() (the key-set route under the mount, "" under RemoteJWKS), jwt.SessionJWT(ctx) (the IssueOnSession token of the request) none none
Go only jwt.NewSessionCache(p): an auth.SessionCache for auth.WithSessionCache whose client copy is a JWT signed with the plugin's key (below) none none

TokenResult is {"token", "expiresAt"}. Claims written: iss, sub (user id), iat, nbf, exp, jti (random, 128 bits), sid (session id), aud when set, and act: {"sub": <admin id>} when the session is an impersonation. The header is {"alg":"EdDSA","typ":"JWT","kid":...} (the key's algorithm for an RSA or ECDSA key; DefaultAlgorithm is EdDSA), where kid is KeyID(pub): the first 16 base64url characters of the SHA-256 of the raw Ed25519 key, or of an RSA or ECDSA key's PKIX encoding (followed by the algorithm for an RSAKey, so one modulus under two algorithms has two ids). The key set uses use KeyUseSignature, alg the key's algorithm, and per key type: kty KeyTypeOKP with crv CurveEd25519 and x; kty RSA with n and e; kty EC with crv (P-256, P-384, P-521), x and y. An ECDSA signature is the fixed r||s JWS requires (64, 96 or 132 bytes), converted from the ASN.1 a crypto.Signer returns.

Templates

Template is a token shape the app registers by name, for a third party that wants its own claims (Clerk's JWT templates). The client sends only the name; the claims, audience, issuer, subject, lifetime and key are server configuration, so a client can never shape a claim. Fields: Claims (a ClaimsFunc; an error fails the call), Subject (else the plugin's Subject, else the user id), Issuer (else the plugin's), Audience (nil writes none), TTL (0 is the plugin's, capped at MaxTTL and at the session's expiry), Key, Algs (the algorithms the receiver accepts; auth.New fails with ErrTemplateKey when Key signs another) and Type (the typ header). The registered claims stay the plugin's: iss, sub, iat, nbf, exp, jti, sid, aud and act are always written by the plugin, and a template's Claims returning one fails the call with ErrReservedClaim. The plugin's Claims, Payload and ClaimSource options do not apply to a template.

Key is one of three, a closed set: nil or PublishedKey(alg, kid) signs with the signing key or an AlgorithmKeys key (chosen like SignRequest), already in the key set, for a receiver that fetches the JWKS (Convex's customJwt, Hasura's jwk_url); HMACKey(secret) signs HS256 with a secret shared with one receiver (Supabase's legacy JWT secret, Hasura's HS256 key), at least 32 bytes, never published, and refused by every Verifier (VerifyHS256(token, secret) checks one for the app's own use, pinning alg to HS256 and comparing in constant time); PrivateKey(signer, kid) signs with a key the receiver issued (a Firebase service account key), never published, kid written as given or left out when "".

Presets live in github.com/ubgo/auth/plugin/jwt/jwtpreset, each an example convention, opt-in, never a default, with the vendor's documentation cited in its comment: Supabase(SupabaseConfig) (aud and role authenticated, session_id, is_anonymous), Hasura(HasuraConfig) (the https://hasura.io/jwt/claims namespace with x-hasura-default-role, x-hasura-allowed-roles, x-hasura-user-id), Convex(ConvexConfig) (aud the application id, Algs RS256 and ES256) and Firebase(FirebaseConfig) (a custom token: RS256 with the service account key, iss and sub the service account, aud the Identity Toolkit URL, uid, claims; not exchanged against a live Firebase project here). github.com/ubgo/auth/plugin/organization/orgjwt adds the active organization (id, slug, role, permissions) as a ClaimsFunc; the permissions come from the organization plugin's roles unless orgjwt.Permissions(fn) supplies them (an app whose organization.WithAuthorizer is Casbin, OPA or similar must, or drop them with orgjwt.WithoutPermissions()).

JWT session cache

auth.WithSessionCache(jwt.NewSessionCache(jw), ttl) with jw in auth.WithPlugins is better-auth's sessionCookieCache: the cache copy the client holds (the cache cookie, or Set-Auth-Session-Cache in bearer mode) is a JWT signed with the plugin's key. Header: typ SessionCacheType (ubgo-auth.session-cache+jwt), alg, kid. Claims: iss (the plugin's issuer), aud SessionCacheAudience (ubgo-auth:session-cache), sub (user id), sid (session id, not the token as better-auth writes), iat, exp (the entry's expiry, whole seconds rounded down), tbh (a SHA-256 of the session token's hash under a fixed label), session and user. It keeps every rule of the sealed auth.NewSnapshotCache, which stays the default: used only with its token (constant-time compare of tbh), only before exp, never past auth.MaxSessionCacheTTL, never renewed without the store, cleared at sign-out. Get verifies with the plugin's own published key named by kid, under that key's algorithm, refuses another typ, issuer or audience and a sub/sid that disagree with the user and session carried; every failure is a miss. The claims are signed, not encrypted: whoever holds the value reads the session's IP, user agent and app data and the user's name and image. A cache whose plugin is not in auth.WithPlugins fails every Put with ErrCacheNotWired. It passes sessiontest.RunCache, as the core caches and contrib/redis do.

Tables and columns

None. The plugin is stateless: tokens are never stored, and the keys come from options; Rotating derives its keys from the seed on every call rather than storing them.

Errors

Variable Code HTTP When
ErrReservedClaim none; answered over HTTP as auth.internal 500 Claims returned a claim the plugin owns (iss, sub, aud, exp, iat, nbf, jti, sid, act); an app bug
ErrInvalid none none Returned by Verifier.Verify for every untrusted token, wrapped with the reason for the log
ErrEmptySubject none; answered over HTTP as auth.internal 500 Subject returned an empty string, or Sign was given none; an app bug
ErrSignerKey none; answered over HTTP as auth.internal 500 A Signer published no key, or Rotating was asked to sign with a key outside its grace
ErrRemoteKeys none none RemoteKeys.LastError: the key set could not be fetched or was not a key set
ErrSignature none; answered over HTTP as auth.internal 500 A Signer's signature did not verify with the key it published (a KMS that used another key or returned ASN.1 for ECDSA); no token is minted
ErrCacheNotWired none 500 through the core's auth: session cache: NewSessionCache's plugin is not in auth.WithPlugins
ErrUnsupportedKey, ErrJWKSPath, ErrRemoteJWKS none none Configuration, returned by auth.New (ErrUnsupportedKey also by GenerateKey for an unknown algorithm)
ErrNoAlgorithmKey none none Sign with a SignRequest.Alg that neither the signing key nor an AlgorithmKeys key signs; the message names the algorithm
ErrNoKey, ErrNoIssuer, ErrRotationSeed, ErrRotationInterval, ErrRotationGrace, ErrAlgorithmKeys none none Configuration, returned by auth.New
ErrUnknownTemplate jwt.unknown_template 404 POST /jwt/template named a template the app did not register
ErrTemplateName, ErrHMACSecret, ErrTemplateKey none none Template configuration, returned by auth.New

A claim with no JSON form (NaN, a channel, a func) also fails Token with "jwt: encode claims: ...". A caller without a session gets the core's auth.ErrSignedOut.

Security notes

Property Pinned by
alg: none, a tampered body, a wrong key, issuer or audience, and an expired token are all ErrInvalid TestSignVerifyAndRejections
Each of the ten algorithms signs, publishes the right kty/crv/alg and fields, and verifies from the JSON key set; ECDSA signatures are `r
RSAKey binds a modulus to one algorithm with its own key id; PublicKeyForAlg reads an alg-less foreign JWK within its family only TestRSAKey_BindsOneModulusToOneAlgorithm, TestPublicKeyForAlg
Payload sees the session, follows Claims, and cannot set a registered claim TestPayload_ClaimsFromUserAndSession
The token's alg must be the algorithm of the key its kid names: none, HS256, PS256, ES384 and every cross-algorithm header are refused even with a valid signature TestVerify_PinsTheAlgorithmToTheKey
typ must be JWT, so a session-cache snapshot is never an access token, and the cache refuses an access token TestVerify_RequiresTheJWTType, TestSessionCache_SnapshotsAndAccessTokensNeverStandInForEachOther
RSA under 2048 bits and curves other than P-256, P-384 and P-521 are refused at auth.New, in key sets and in verification; an off-curve EC point in a key set is dropped TestAlgOf_RefusesUnsupportedKeys, TestValidate_RefusesUnsupportedKeys, TestKeysFromJWKS_RSAAndECKeys
A KMS signature that does not verify with its published key mints nothing TestSignWithKey_RefusesWhatASignerGetsWrong
An Ed25519 kid is unchanged by the multi-algorithm work, so tokens in flight keep verifying TestKeyID_Ed25519IsUnchangedAndOthersHashTheirPKIX
A template is an allowlist: an unknown name is refused, a template's claims cannot set a registered claim, a failing ClaimsFunc mints nothing, the subject and lifetime rules hold, and an impersonation carries act TestTemplate_UnknownAndReservedRefused, TestClaimsFunc_ErrorsFailTheCall, TestTemplate_SubjectTTLAndSession
Each template key signs a token its receiver verifies; an HMAC secret or a PrivateKey is never in the key set, and their tokens never verify through it TestTemplate_KeysMatrix, TestTemplates_EachReceiverVerifies (example)
An HS256 token never passes a Verifier (not with a published kid, not MACed with the public key's bytes), an asymmetric token never passes VerifyHS256, and a short secret verifies nothing TestVerifyHS256_AlgConfusion
A misconfigured template (name, short secret, unknown key, an algorithm the receiver refuses) fails auth.New TestTemplate_ValidateRefusesBadConfiguration, TestConvex_AlgorithmAndAudience, TestFirebase_ClaimShape
JWKSPath moves the route and refuses .., //, /token, /template and escapes; RemoteJWKS turns the route off (404), needs https, and no token carries jku TestJWKSPath_MovesTheRoute, TestJWKSPath_RefusesWhatIsNotARoute, TestRemoteJWKS_TurnsTheRouteOffAndPublishesTheURL, TestRemoteJWKS_NeedsAnHTTPSURL
IssueOnSession puts the token route's token in Set-Auth-JWT, exposed through CORS; none when signed out or off; on all three engines TestIssueOnSession_HookMintsTheTokenRouteToken, TestJWT_IssueOnSessionHeader (integration)
The JWT session cache passes the cache contract with every algorithm and Rotating, refuses a snapshot that disagrees with its issuer, audience, user or session, one under a key no longer published, and never outlives MaxSessionCacheTTL TestSessionCache_PassesTheCacheContract, TestSessionCache_RefusesASignedSnapshotThatDisagrees, TestSessionCache_OnlyThePluginsPublishedKeysVerify, TestSessionCache_NeverPastMaxSessionCacheTTL, TestJWT_SessionCacheEndToEnd (integration)
Malformed segments, a non-object payload, a claim of the wrong type, a missing sub or exp, and a future nbf beyond the leeway are refused TestVerify_RefusesMalformedTokens
KeysFromJWKS keeps only Ed25519 keys of the right length, and RSA and ECDSA keys whose alg matches TestKeysFromJWKS_KeepsOnlySupportedSigningKeys, TestKeysFromJWKS_RSAAndECKeys
A token signed before a rotation still verifies through PreviousKeys TestRotation_PreviousKeysStillVerify
Sign picks the AlgorithmKeys key of the asked algorithm, every one is published and verifies by kid, and an algorithm with no key is refused TestAlgorithmKeys, TestAlgorithmKeys_Validate
SignRequest.KeyID picks one of several keys of an algorithm, and refuses an unknown key id or one of another algorithm TestSign_KeyIDPicksAmongKeysOfOneAlgorithm
A token never outlives the session it came from; app claims cannot overwrite registered ones TestTokensJWT_ClaimsLifetimeAndKeySet
An impersonation token names the admin in act TestJWT_IssueFromSessionAndVerifyElsewhere
The TTL is capped at MaxTTL TestNew_OptionsAndTTLClamp
A Sign token verifies with the plugin's key set, carries no sid, takes its own audience over the plugin's, is capped at MaxTTL, and refuses a reserved claim or an empty subject TestSign_MintsATokenWithoutASession
Verified.ExpiresAt is UTC whatever the verifying machine's time zone TestVerify_ExpiresAtIsUTC
A custom signer's token carries the published key's id and verifies against the key set; a signer with no key fails the call TestSigner_CustomSignerSignsAndIsChecked
Two instances derive the same rotating key; the previous key is published within grace and dropped after; a token from a retired key is refused TestRotating_DerivesTheSameKeysEverywhereAndPublishesWithinGrace
A remote key set is refetched for an unknown kid at most once a minute, and a failed fetch keeps the old keys TestRemoteKeys_FetchesCachesAndBoundsRefresh

Limits, honestly: a signed token cannot be revoked; its short life is its revocation, so a revoked session stays usable through its JWTs until they expire (at most the TTL). Verify does not track jti, so it does not stop replay within a token's lifetime. Issuer and Audience are checked only when set on the Verifier. A verifying service must refetch the key set when it meets an unknown kid after a rotation, which RemoteKeys does. Rotating protects against nothing a leaked seed allows: whoever holds the seed derives every key, past and future, as with a stored private key; it buys a bounded lifetime per key and a schedule nobody has to run. Rotating derives Ed25519 keys only; RSA and ECDSA keys rotate by hand (WithSigningKey with the new key, PreviousKeys with the old). No key set and no Verifier has an HMAC (HS*) algorithm: a key set is public, and a shared secret in one is a forgery waiting to happen; HS256 exists only for a template's HMACKey, whose secret is shared with that one receiver, who can therefore mint tokens too. With RemoteJWKS, keeping the remote key set in step with the Signer is the app's job; the plugin cannot check it. IssueOnSession hands a bearer token to every page that reads the session, which is why it is off by default. The JWT session cache's claims are readable by whoever holds the cookie.

Example

Documentation

Overview

Package jwt issues short-lived JSON Web Tokens from a session, for other services that must know who is calling without sharing the auth database.

The session stays an opaque, revocable token; a JWT is a copy of "who" for a few minutes, signed with Ed25519 (alg EdDSA) and verifiable with the public keys this plugin publishes as a JWKS. Its short life is its revocation: a signed token cannot be withdrawn, so it expires before a revoked session would matter.

Index

Constants

View Source
const (
	// KeyTypeOKP is the JWK "kty" of an Edwards-curve key.
	//ds:def id=keytypeokp-cx2p5h5q owner=@khanakia stability=stable
	KeyTypeOKP = "OKP"
	// KeyTypeRSA is the JWK "kty" of an RSA key.
	KeyTypeRSA = "RSA"
	// KeyTypeEC is the JWK "kty" of an elliptic-curve (ECDSA) key.
	KeyTypeEC = "EC"
	// CurveEd25519 is the JWK "crv" of an EdDSA key.
	//ds:def id=curveed25519-w6kd4wru owner=@khanakia stability=stable
	CurveEd25519 = "Ed25519"
	// CurveP256 is the JWK "crv" of an ES256 key.
	CurveP256 = "P-256"
	// CurveP384 is the JWK "crv" of an ES384 key.
	CurveP384 = "P-384"
	// CurveP521 is the JWK "crv" of an ES512 key.
	CurveP521 = "P-521"
	// KeyUseSignature is the JWK "use" of a key that only signs.
	//ds:def id=keyusesignature-azygjgrb owner=@khanakia stability=stable
	KeyUseSignature = "sig"
)

JWK and JWS header values for each key type (RFC 7517, RFC 7518, RFC 8037).

View Source
const (
	// SessionCacheType is the JWS "typ" header of a session-cache snapshot
	// (better-auth's "better-auth.session-cache+jwt"). Verify refuses it and
	// the cache refuses every other typ, so an access token can never stand
	// in for a cached session, nor a snapshot for an access token.
	//ds:def id=sessioncachetype-w8d3kq6n owner=@khanakia stability=stable
	SessionCacheType = "ubgo-auth.session-cache+jwt"
	// SessionCacheAudience is the "aud" claim of a snapshot (better-auth's
	// "better-auth:session-cache"): a service whose Verifier checks its own
	// audience refuses a snapshot on that ground too.
	//ds:def id=sessioncacheaudience-v4m7zt2h owner=@khanakia stability=stable
	SessionCacheAudience = "ubgo-auth:session-cache"
)

Session-cache constants.

View Source
const (
	EndpointToken auth.EndpointID = "jwt.token"
	EndpointJWKS  auth.EndpointID = "jwt.jwks"
)

Operation ids.

View Source
const (
	//ds:def id=defaultttl-kqd34f84 owner=@khanakia stability=stable
	DefaultTTL = 5 * time.Minute
	// MaxTTL caps a token: past an hour, a revoked session would stay usable
	// through its JWTs for longer than anyone expects.
	//ds:def id=maxttl-n53tvy7p owner=@khanakia stability=stable
	MaxTTL = time.Hour
)

Lifetimes.

View Source
const (
	// RemoteMinRefresh bounds how often an unknown kid may trigger a fetch,
	// so a flood of forged tokens cannot make a verifying service hammer the
	// issuer.
	//ds:def id=remoteminrefresh-c6vw2kzp owner=@khanakia stability=stable
	RemoteMinRefresh = time.Minute
	// RemoteMaxAge is how long a fetched set is trusted before the next
	// lookup refetches it, so a retired key disappears from the verifier
	// within the day even when no unknown kid forces a fetch.
	//ds:def id=remotemaxage-t9hr4nxb owner=@khanakia stability=stable
	RemoteMaxAge = 24 * time.Hour

	// DefaultRemoteTimeout bounds one key-set fetch when the caller passes
	// no client.
	//ds:def id=defaultremotetimeout-w3jq8mdf owner=@khanakia stability=stable
	DefaultRemoteTimeout = 10 * time.Second
)

Remote key-set constants.

View Source
const (
	// DefaultJWKSPath is where the key set is served under the plugin's
	// prefix (GET <mount>/jwt/jwks) unless JWKSPath moves it.
	//ds:def id=defaultjwkspath-t3vk9q2w owner=@khanakia stability=stable
	DefaultJWKSPath = "/jwks"

	// SetJWTHeader is the response header IssueOnSession puts a fresh token
	// in on the session read, better-auth's set-auth-jwt. The transport also
	// lists it in Access-Control-Expose-Headers, so a script on another
	// origin can read it.
	//ds:def id=setjwtheader-p6xr2m4c owner=@khanakia stability=stable
	SetJWTHeader = "Set-Auth-JWT"
)

Route and header constants.

View Source
const (
	// RotationSeedSize is the length of the seed Rotating derives keys from:
	// 256 bits, so the seed is as strong as the keys it makes.
	//ds:def id=rotationseedsize-f4wq9bkm owner=@khanakia stability=stable
	RotationSeedSize = 32
	// MinRotationInterval bounds how often keys change: below an hour the
	// key set churns faster than verifiers refetch it, and every unknown kid
	// becomes a fetch.
	//ds:def id=minrotationinterval-h7zt2vcp owner=@khanakia stability=stable
	MinRotationInterval = time.Hour
)

Rotation constants.

View Source
const (

	// HS256 is HMAC with SHA-256 (RFC 7518 section 3.2). It is not an Alg:
	// a Verifier never accepts it and no key set ever lists its key, because
	// the key is a secret shared with one receiver. Only a template with
	// HMACKey signs it, for a vendor that verifies with a shared secret
	// (Supabase's legacy JWT secret, Hasura's HS256 key).
	//ds:def id=hs256-t5mq8wzk owner=@khanakia stability=stable
	HS256 = "HS256"
	// MinHMACSecret is the shortest HMACKey secret: RFC 7518 section 3.2
	// requires a key at least as long as the hash output, 32 bytes for
	// SHA-256, because a shorter one is easier to guess than to forge.
	//ds:def id=minhmacsecret-r8kw2vnd owner=@khanakia stability=stable
	MinHMACSecret = 32
)

Template limits and wire values.

View Source
const DefaultAlgorithm = EdDSA

DefaultAlgorithm is the algorithm of the key GenerateKey makes when given "", and of every token signed with an Ed25519 key, which is what the plugin was built on.

View Source
const DefaultLeeway = 30 * time.Second

DefaultLeeway absorbs clock differences between servers.

View Source
const EndpointTemplate auth.EndpointID = "jwt.template"

EndpointTemplate is the operation that mints a token from a named template, Clerk's getToken({ template }).

View Source
const MaxAudienceValues = 64

MaxAudienceValues is the most aud entries a token may carry.

View Source
const (
	// MinRSABits is the smallest RSA modulus signed or accepted. NIST SP
	// 800-131A has disallowed anything smaller since 2013, and a verifier
	// that took a 1024-bit key would trust a modulus that can be factored.
	//ds:def id=minrsabits-q8w2nd5k owner=@khanakia stability=stable
	MinRSABits = 2048
)

Key sizes.

View Source
const PluginName = "jwt"

PluginName is the plugin's permanent identity.

Variables

View Source
var (
	// ErrNoAlgorithmKey is a Sign whose SignRequest.Alg names an algorithm
	// that neither the signing key nor an AlgorithmKeys key signs, better-
	// auth's descriptive error for an algorithm absent from keyPairConfig and
	// keyPairConfigs.
	ErrNoAlgorithmKey = errors.New("jwt: no signing key for this algorithm; add one with jwt.AlgorithmKeys")
	// ErrAlgorithmKeys is a configuration error: an AlgorithmKeys key that is
	// nil or a short Ed25519 key, or one key given twice (counting the
	// signing key), which would publish one key id twice.
	ErrAlgorithmKeys = errors.New("jwt: AlgorithmKeys holds valid keys, each once and none the signing key")
	// ErrNoSigningKey is a Sign whose SignRequest.KeyID names no key the
	// plugin signs with (the signing key or an AlgorithmKeys key), or one
	// of another algorithm than SignRequest.Alg.
	ErrNoSigningKey = errors.New("jwt: no signing key with this key id")
)

Errors of AlgorithmKeys.

View Source
var (
	ErrNoKey         = errors.New("jwt: pass jwt.WithSigningKey with an Ed25519, RSA or P-256 private key, or jwt.WithSigner")
	ErrReservedClaim = errors.New("jwt: Claims may not set a registered claim")
	ErrNoIssuer      = errors.New("jwt: needs auth.WithBaseURL or jwt.Issuer")
)

Errors.

View Source
var (
	// ErrJWKSPath is a JWKSPath that does not start with "/", is "/" alone,
	// contains "..", "//" or a character outside RFC 3986's unreserved set,
	// or is the token or template route's path.
	ErrJWKSPath = errors.New(`jwt: JWKSPath must start with "/", hold only letters, digits and "-._~/", contain no ".." or "//", and not be "/token" or "/template"`)
	// ErrRemoteJWKS is a RemoteJWKS URL that is not an absolute https URL
	// (http is allowed for a loopback host), or carries a fragment or user
	// information.
	ErrRemoteJWKS = errors.New("jwt: RemoteJWKS needs an absolute https URL (http only for a loopback host)")
)

Route and remote key-set errors, returned by auth.New.

View Source
var (
	// ErrRotationSeed is a seed that is not RotationSeedSize bytes.
	ErrRotationSeed = errors.New("jwt: Rotating needs a 32-byte seed")
	// ErrRotationInterval is an interval below MinRotationInterval.
	ErrRotationInterval = errors.New("jwt: Rotating interval is at least an hour")
	// ErrRotationGrace is a grace period shorter than the token TTL: a
	// token could then outlive its key's publication and fail to verify
	// while still valid.
	ErrRotationGrace = errors.New("jwt: Rotating grace period is at least the token TTL")
)

Rotation errors, returned by auth.New.

View Source
var (
	// ErrUnknownTemplate is a request for a template name the app did not
	// register. Templates are an allowlist kept on the server: a client
	// picks one by name and can never shape the claims.
	ErrUnknownTemplate = auth.NewError("jwt.unknown_template", http.StatusNotFound, "This token template does not exist.")
	// ErrTemplateName is a template name that is empty, longer than 64
	// characters, or holds a character outside a-z, 0-9, "-" and "_", or a
	// name given twice.
	ErrTemplateName = errors.New(`jwt: a template name is 1-64 characters of a-z, 0-9, "-" and "_", each registered once`)
	// ErrHMACSecret is an HMACKey secret shorter than MinHMACSecret bytes.
	ErrHMACSecret = errors.New("jwt: an HMAC template secret is at least 32 bytes")
	// ErrTemplateKey is a template key that cannot sign: a PrivateKey of an
	// unsupported type, a key id with a character outside the base64url
	// alphabet, or a key whose algorithm is not in the template's Algs.
	ErrTemplateKey = errors.New("jwt: the template's key cannot sign for it")
)

Template errors. ErrUnknownTemplate is what a client sees; the others are configuration errors, returned by auth.New.

AlgValues is every algorithm the plugin signs and verifies, in the order the documentation lists them: the three original ones first, so a list built from it keeps its old prefix.

View Source
var ErrBadType = errors.New(`jwt: a token type holds only letters, digits and "+-." and is not the session-cache type`)

ErrBadType is a SignRequest.Type or Verifier.Types entry that holds a character outside letters, digits and "+-.", or is SessionCacheType.

View Source
var ErrCacheNotWired = errors.New("jwt: NewSessionCache's plugin is not in auth.WithPlugins")

ErrCacheNotWired is a session cache whose plugin was not passed to auth.WithPlugins, so it has no issuer and no clock: every Put fails, and the first request says why instead of caching nothing silently.

View Source
var ErrEmptySubject = errors.New("jwt: Subject returned an empty subject")

ErrEmptySubject is a SubjectFunc that returned "": a token without a subject names nobody, so the call fails rather than mint it.

View Source
var ErrInvalid = errors.New("jwt: token is not valid")

ErrInvalid covers every token that must not be trusted: malformed, a typ other than JWT (a session-cache snapshot), an alg other than the one of the key its kid names (including "none"), an unknown key, a bad signature, expired, not yet valid, or for another issuer or audience.

View Source
var ErrRemoteKeys = errors.New("jwt: remote key set could not be read")

ErrRemoteKeys is a key-set fetch that failed or returned something that is not a key set; Lookup then reports the kid unknown, and the error is kept for LastError.

View Source
var ErrSignature = errors.New("jwt: the Signer's signature does not verify with its published key")

ErrSignature is a Signer whose signature does not verify against the key it published: a KMS that answered with the wrong key or the wrong encoding (an ASN.1 ECDSA signature where JWS wants r||s). The token is not minted, because no verifier could accept it.

View Source
var ErrSignerKey = errors.New("jwt: Signer has no published key to sign with")

ErrSignerKey is a Signer with no key to sign with, or a Rotating signer asked to sign with a key outside its grace: the token would carry a kid no verifier could find, so the call fails instead of minting it.

View Source
var ErrUnsupportedKey = errors.New("jwt: the key is not Ed25519, RSA of at least 2048 bits, or ECDSA on P-256, P-384 or P-521")

ErrUnsupportedKey is a key that is not Ed25519, RSA of at least MinRSABits or ECDSA on P-256, P-384 or P-521: WithSigningKey, PreviousKeys or a Signer's PublicKeys gave one, and auth.New refuses it, or GenerateKey or RSAKey was asked for an algorithm it does not know.

Functions

func GenerateKey

func GenerateKey(alg Alg) (crypto.Signer, error)

GenerateKey makes a new private key for alg ("" means DefaultAlgorithm): Ed25519, RSA of MinRSABits (bound to alg by RSAKey for an algorithm other than RS256), or ECDSA on the algorithm's curve. Pass it to WithSigningKey or AlgorithmKeys, and keep it in the app's secret store: a key generated at every start would sign out every verifier holding the old key set.

func HashOf

func HashOf(alg Alg) crypto.Hash

HashOf is the hash an algorithm signs over, and the one OpenID Connect's at_hash and c_hash use (Core section 3.1.3.6): SHA-256, SHA-384 or SHA-512 by the algorithm's suffix, and SHA-512 for EdDSA (Ed25519 hashes with it internally). 0 for an unknown algorithm.

func KeyID

func KeyID(pub crypto.PublicKey) string

KeyID names a public key: the first 16 base64url characters of a SHA-256. For Ed25519 the hash is of the raw 32-byte key, as before RSA and ECDSA existed, so the ids of keys already published never change; for RSA and ECDSA it is of the key's PKIX (SubjectPublicKeyInfo) encoding, followed, for an RSAPublicKey, by its algorithm, so one modulus bound to two algorithms has two ids. An unsupported key has no id, "".

func KeysFromJWKS

func KeysFromJWKS(set JWKS) map[string]crypto.PublicKey

KeysFromJWKS turns a published key set into Verifier.Keys, keeping the Ed25519, RSA (at least MinRSABits) and P-256 keys whose alg, when given, is the one of their type, and skipping every other key.

func NewSessionCache

func NewSessionCache(p *Plugin) auth.SessionCache

NewSessionCache returns an auth.SessionCache whose client copy is a JWT signed with p's key, better-auth's sessionCookieCache: a value with typ SessionCacheType and kid in the header, and iss, aud (SessionCacheAudience), sub (the user id), sid (the session id), iat, exp (when the entry stops being used), the session, the user, and a binding to the session token among its claims. Pass it to auth.WithSessionCache, with p in auth.WithPlugins.

It keeps every rule of auth.NewSnapshotCache, which stays the default: an entry is used only with the token it was made for (compared in constant time), only until its expiry, never past MaxSessionCacheTTL, and the core never renews one without reading the store. Get verifies the signature with the published key its kid names, under that key's algorithm and never the header's, and refuses every other typ; anything that does not verify is a miss, so the store is asked.

The difference that matters: the claims are signed, not encrypted. Anyone holding the value (the browser, a bearer client, a log line) can read the session's IP, user agent and app data and the user's name and image; nobody can change them. Use NewSnapshotCache when that is not acceptable. With Rotating, a grace shorter than the cache TTL only turns a snapshot signed with a retired key into a miss.

func PublicKeyForAlg

func PublicKeyForAlg(k JWK, alg Alg) (crypto.PublicKey, bool)

PublicKeyForAlg parses another party's JWK for a signature made under alg, the header of a token that key signed: a DPoP proof's embedded key, or a private_key_jwt client's registered key. RFC 7517 section 4.4 makes a JWK's alg optional, and such keys often omit it; then the key's type decides the family (an RSA key verifies any RSA algorithm, an EC key only its curve's, an Ed25519 key only EdDSA), and the result is bound to alg so VerifySignature still pins the algorithm to the key. A JWK that names an alg must name this one. false for anything PublicKeyOfJWK refuses, and for an alg outside the key's family, so no header can turn a public key into an HMAC secret.

func PublicKeyOfJWK

func PublicKeyOfJWK(k JWK) (crypto.PublicKey, bool)

PublicKeyOfJWK parses a JWK into a public key with the rules KeysFromJWKS applies: Ed25519, RSA of at least MinRSABits, P-256 on the curve, and an alg, when given, that is the key's. false for anything else.

func RSAKey

func RSAKey(k crypto.Signer, alg Alg) (crypto.Signer, error)

RSAKey binds an RSA signer (an *rsa.PrivateKey, or a KMS key behind crypto.Signer) to one RSA algorithm, better-auth's keyPairConfig { alg: "PS256" }: the result signs alg, and its public key (an RSAPublicKey) is published with alg. RS256 returns the signer unchanged, since a bare RSA key already signs it. Anything other than an RSA key of at least MinRSABits and an RSA algorithm is ErrUnsupportedKey.

func SessionJWT

func SessionJWT(ctx context.Context) (string, bool)

SessionJWT returns the token IssueOnSession handed out during the request in ctx, for a Go caller of Auth.Session, which has no response headers to read; false when there is none (the option is off, the caller is signed out, or the session was not read).

func Thumbprint

func Thumbprint(k JWK) (string, error)

Thumbprint is the RFC 7638 JWK thumbprint, base64url of SHA-256 over the key's required members in lexicographic order with no whitespace: what a DPoP-bound token's cnf.jkt names. It refuses a key PublicKeyOfJWK would refuse, so a thumbprint always names a usable key.

func VerifyHS256

func VerifyHS256(token string, secret []byte, types ...string) ([]byte, error)

VerifyHS256 checks an HS256 compact JWS against secret, for an app that verifies its own HMACKey template tokens (a test, or a service that shares the secret): the header's alg must be HS256, typ one of types ("JWT" when none is given), and the MAC equal in constant time. It returns the decoded payload JSON and checks no claim; exp, iss and aud are the caller's. A token signed with a public-key algorithm never passes here, and an HS256 token never passes a Verifier, so neither path can be confused for the other.

func VerifySignature

func VerifySignature(token string, pub crypto.PublicKey, types ...string) (header, payload []byte, err error)

VerifySignature checks a compact JWS against one known key, for a token that is not one of this plugin's (a DPoP proof carries its key in its header and has no sub or exp, so Verifier does not fit). The typ must be one of types, the alg the key's, and the signature valid; it returns the decoded header and payload JSON for the caller's own claim checks, and checks no claim itself.

Types

type Alg

type Alg string

Alg is a JWS signature algorithm (RFC 7518 section 3, RFC 8037) the plugin signs and verifies with. The algorithm is a property of the key, never of the token: an Ed25519 key signs EdDSA, an RSA key RS256 (or the algorithm RSAKey names), and an ECDSA key the algorithm of its curve (P-256 ES256, P-384 ES384, P-521 ES512); a verifier accepts a token only when its alg header names the algorithm of the key its kid points at. So a token cannot pick its own algorithm, which is how "alg: none" and the RS256-to-HS256 confusion work.

const (
	// EdDSA is Ed25519 (RFC 8037): small keys, fast, deterministic
	// signatures, and the default.
	EdDSA Alg = "EdDSA"
	// RS256 is RSASSA-PKCS1-v1_5 with SHA-256, for a consumer that accepts
	// RSA only, such as the JWT authorizer of AWS API Gateway.
	RS256 Alg = "RS256"
	// RS384 and RS512 are RSASSA-PKCS1-v1_5 with SHA-384 and SHA-512; an
	// RSA key signs them only through RSAKey.
	RS384 Alg = "RS384"
	RS512 Alg = "RS512"
	// PS256, PS384 and PS512 are RSASSA-PSS with SHA-256, SHA-384 and
	// SHA-512, the salt as long as the hash (RFC 7518 section 3.5); an RSA
	// key signs them only through RSAKey.
	PS256 Alg = "PS256"
	PS384 Alg = "PS384"
	PS512 Alg = "PS512"
	// ES256 is ECDSA on P-256 with SHA-256, for a consumer or a KMS that
	// offers ECDSA and not Ed25519.
	ES256 Alg = "ES256"
	// ES384 is ECDSA on P-384 with SHA-384.
	ES384 Alg = "ES384"
	// ES512 is ECDSA on P-521 with SHA-512 (the name is the hash's, the
	// curve is 521 bits).
	ES512 Alg = "ES512"
)

The algorithms. Symmetric (HS*) algorithms are deliberately absent: a key set is public, and a shared secret in one is a forgery waiting to happen.

func AlgOf

func AlgOf(pub crypto.PublicKey) (Alg, error)

AlgOf returns the algorithm a public key signs with, or ErrUnsupportedKey. The parameter is crypto.PublicKey, the standard library's own type for "a public key of some algorithm", narrowed here in the one place every other function goes through.

type ClaimsFunc

type ClaimsFunc func(ctx context.Context, u auth.User, s auth.Session) (map[string]any, error)

ClaimsFunc builds app claims from the user and the session, and can fail: a claim read from the database (the active organization, a plan) has an error to report, and a token missing a claim its receiver relies on must not be minted, so the error fails the token call instead.

func UserClaim

func UserClaim(name string, read func(ctx context.Context, userID string) (json.RawMessage, error)) ClaimsFunc

UserClaim is a ClaimsFunc that writes one claim, name, from a JSON document read per user: for the public metadata plugin/metadata keeps, jwt.ClaimSource(jwt.UserClaim("public_metadata", meta.PublicOf)), or the same in a template's Claims. It takes a func rather than the metadata plugin, so any per-user JSON source fits and this package imports no other feature. Only a document meant for every receiver of the token belongs here: a JWT is signed, not encrypted, so private metadata must never be a claim. A failing read, or a document that is not valid JSON, fails the token call.

type JWK

type JWK struct {
	KeyType string `json:"kty"`
	Curve   string `json:"crv,omitempty"`
	X       string `json:"x,omitempty"`
	Y       string `json:"y,omitempty"`
	N       string `json:"n,omitempty"`
	E       string `json:"e,omitempty"`
	KeyID   string `json:"kid"`
	Use     string `json:"use"`
	Alg     string `json:"alg"`
}

JWK is one public key in JSON Web Key form. Which fields are set depends on KeyType: OKP (Ed25519, RFC 8037) has Curve and X; RSA (RFC 7518 section 6.3) has N and E; EC (P-256, RFC 7518 section 6.2) has Curve, X and Y. The unused ones are omitted from the JSON, so an Ed25519 key's JSON is what it was before RSA and ECDSA existed.

type JWKS

type JWKS struct {
	Keys []JWK `json:"keys"`
}

JWKS is the public key set: the current key first, then the AlgorithmKeys keys, then PreviousKeys.

type Option

type Option func(*Plugin)

Option configures the plugin; pass options to New.

func AlgorithmKeys

func AlgorithmKeys(keys ...crypto.Signer) Option

AlgorithmKeys adds signing keys beside the signing key, better-auth's jwks.keyPairConfigs: a token signed by Sign with SignRequest.Alg set to an algorithm uses the first key of it (the signing key before these), SignRequest.KeyID picks one by key id (better-auth's per-resource signingKeyId), and every one is published in the key set (after the signer's keys, before PreviousKeys), so a verifier finds it by kid. The session token route and Token keep signing with the main key. Several keys may share an algorithm; one key given twice (the signing key included) fails auth.New with ErrAlgorithmKeys. better-auth makes such a key the first time it is asked for and stores it; this plugin stores no keys, so the app passes them (GenerateKey makes one) from its secret store.

func Audience

func Audience(aud ...string) Option

Audience sets the aud claim: the service, or services, the tokens are for. One value is written as a string, several as an array (RFC 7519 section 4.1.3); Verifier accepts either form.

func ClaimSource

func ClaimSource(fn ClaimsFunc) Option

ClaimSource is Payload for claims whose lookup can fail, such as orgjwt's organization claim: its claims are added after Claims' and Payload's, replacing any of the same name, a registered claim fails the call like there, and so does an error.

func Claims

func Claims(fn func(ctx context.Context, u auth.User) map[string]any) Option

Claims adds app claims, such as a role or a tenant. A registered claim returned here fails the call rather than overwriting the plugin's. The values are any because a claim is any JSON value; one with no JSON form fails the call.

func IssueOnSession

func IssueOnSession() Option

IssueOnSession makes the session read (GET <mount>/session, Auth.Session, every transport) also mint a token and hand it to the client in the SetJWTHeader response header: better-auth's set-auth-jwt, which it sends unless disableSettingJwtHeader. Off by default here, because a token in every session read is a bearer credential handed out on every page load whether the client wants one or not. The token is exactly the one POST <mount>/jwt/token returns at that instant (the same claims, TTL, signer and audience), there is none when the caller is signed out (the session read answers 401), and a failure to mint fails the session read, as a failing after-hook does in better-auth. A Go caller reads it with SessionJWT(ctx) after calling Auth.Session.

func Issuer

func Issuer(iss string) Option

Issuer overrides the iss claim, which defaults to auth.WithBaseURL.

func JWKSPath

func JWKSPath(path string) Option

JWKSPath moves the key-set route from DefaultJWKSPath to path, under the plugin's prefix: JWKSPath("/.well-known/jwks.json") serves GET <mount>/jwt/.well-known/jwks.json, and the old path answers 404. better-auth's jwks.jwksPath; the same rules apply (it starts with "/" and has no ".."), plus the characters a route may hold here (ErrJWKSPath).

func Payload

func Payload(fn PayloadFunc) Option

Payload is Claims with the session too, better-auth's definePayload: a claim from the session (its organization, its device) can ride in the token. Its claims are added after Claims', replacing any of the same name; a registered claim (sub, sid, iss, aud, exp, iat, jti) still fails the call, because a receiver trusts those. better-auth's default payload is the whole user; this plugin's is the user id only, so no personal data rides in a bearer token unless the app returns it here.

func PreviousKeys

func PreviousKeys(keys ...crypto.PublicKey) Option

PreviousKeys keeps publishing old public keys after a rotation, so tokens signed just before it still verify until they expire. A key may be of another algorithm than the signing key, which is how a deployment moves from EdDSA to RS256 without failing tokens in flight.

func RemoteJWKS

func RemoteJWKS(rawURL string) Option

RemoteJWKS says the key set is published somewhere else, such as a KMS's own JWKS endpoint or a CDN: the local key-set route is not served (404), and JWKSURL returns rawURL for the app to hand to its verifiers (jwt.NewRemoteKeys(url, nil) in Go, createRemoteJWKSet in jose, the "JWKS URI" field of an API gateway). better-auth's jwks.remoteUrl, which it advertises in its OpenID discovery document; this library has no discovery document, and no token carries the URL (no "jku" header): a verifier that followed a URL named by the token it is verifying would let the token choose its own keys. The keys at rawURL must include every key the plugin's Signer publishes, or the tokens will not verify; the session cache keeps verifying against the plugin's own keys either way.

func Subject

func Subject(fn SubjectFunc) Option

Subject replaces the default sub claim, the user id, for a service that keys users differently (an external id stored on the user, say). The function must return a non-empty value, or the token call fails with ErrEmptySubject.

func WithSigner

func WithSigner(s Signer) Option

WithSigner replaces WithSigningKey with a custom Signer: a KMS, an HSM, or Rotating. One of the two is required.

func WithSigningKey

func WithSigningKey(k crypto.Signer) Option

WithSigningKey sets the key that signs new tokens, and so the algorithm: an ed25519.PrivateKey signs EdDSA, an *rsa.PrivateKey (at least MinRSABits) RS256, an *ecdsa.PrivateKey on P-256 ES256 (GenerateKey makes any of them). Any crypto.Signer works, so a KMS client that implements it keeps its private key in the KMS. Required, unless WithSigner names a Signer instead. An Ed25519 key of the wrong length is ignored, so auth.New fails with ErrNoKey; another unsupported key fails it with ErrUnsupportedKey, and nil with auth.ErrNilOption.

func WithTTL

func WithTTL(d time.Duration) Option

WithTTL overrides DefaultTTL. A value that is not positive or exceeds MaxTTL becomes MaxTTL, so a misconfiguration never issues an unbounded token.

func WithTemplate

func WithTemplate(name string, t Template) Option

WithTemplate registers a template under name. Names are 1-64 characters of a-z, 0-9, "-" and "_", each registered once (ErrTemplateName).

type PayloadFunc

type PayloadFunc func(ctx context.Context, u auth.User, s auth.Session) map[string]any

PayloadFunc builds a token's app claims from the user and the session it is minted from, better-auth's jwt.definePayload({ user, session }).

type Plugin

type Plugin struct {
	// contains filtered or unexported fields
}

Plugin issues JWTs from sessions and publishes the public key set. Build it with New; WithSigningKey is required.

func New

func New(opts ...Option) *Plugin

New builds the plugin and defines its token and key-set operations. The TTL is clamped here, so every later reader sees a value within MaxTTL.

func (*Plugin) Init

func (p *Plugin) Init(a *auth.Auth) error

Init captures the core and fills the issuer from the base URL's origin when Issuer was not set. A Rotating signer takes the core's clock, so its periods and the tokens' timestamps come from one source.

func (*Plugin) JWKSRoute

func (p *Plugin) JWKSRoute() string

JWKSRoute is the key-set route under the app's mount prefix ("/jwt/jwks" by default, the JWKSPath otherwise), or "" when RemoteJWKS publishes the keys elsewhere and the route is not served.

func (*Plugin) JWKSURL

func (p *Plugin) JWKSURL() string

JWKSURL is where verifiers fetch the key set when it is published elsewhere: the RemoteJWKS URL, or "" when the plugin serves it itself, at JWKSRoute under the app's mount prefix.

func (*Plugin) Name

func (p *Plugin) Name() string

Name is PluginName; it never changes.

func (*Plugin) OperationHooks

func (p *Plugin) OperationHooks() []auth.OperationHook

OperationHooks adds, under IssueOnSession, the after-hook on the session read that mints the header token; nothing otherwise.

func (*Plugin) Operations

func (p *Plugin) Operations() []auth.Operation

Operations lists token and template (signed in) and jwks (public), for the transports to mount; jwks is left out under RemoteJWKS, so its route answers 404 as better-auth's does. The template route is served with no template registered too, answering ErrUnknownTemplate, so a client's method exists before the app adds its first template.

func (*Plugin) OptionErr

func (p *Plugin) OptionErr() error

OptionErr is every error the options recorded, such as auth.ErrNilOption naming an option given nil; auth.New refuses to start while it is not nil (auth.OptionChecker).

func (*Plugin) PublicKeys

func (p *Plugin) PublicKeys() JWKS

PublicKeys returns the key set, for a service in the same process: the signer's keys (the signing key first), then the AlgorithmKeys keys, then PreviousKeys.

func (*Plugin) Sign

func (p *Plugin) Sign(ctx context.Context, in SignRequest) (TokenResult, error)

Sign mints a token that is not tied to a session: a service token for a job, a token for a user the app looked up itself, a token to hand a webhook receiver. It carries sub, iss, aud, iat, nbf, exp and jti, but no sid, so Verified.SessionID is "" for a receiver; the plugin's Subject and Claims options do not apply, since the caller names both. Go only, for trusted code: anything minted here is as good as a session token to the receiving service, which is why no operation offers it.

func (*Plugin) TemplateNames

func (p *Plugin) TemplateNames() []string

TemplateNames lists the registered template names, sorted, for a settings page or a generated client.

func (*Plugin) TemplateToken

func (p *Plugin) TemplateToken(ctx context.Context, name string) (TokenResult, error)

TemplateToken mints a token from the named template for the signed-in user: ErrUnknownTemplate for a name the app did not register, and an API key is refused like Token.

func (*Plugin) Token

func (p *Plugin) Token(ctx context.Context) (TokenResult, error)

Token issues a JWT for the signed-in user. An API key cannot mint one: it is an auth operation, refused with auth.ErrAPIKeyRefused.

func (*Plugin) Validate

func (p *Plugin) Validate(c *auth.Config) error

Validate needs a signer and an issuer: without a key nothing can be signed, and a token without an issuer cannot be told apart from another app's. A Rotating signer's seed, interval and grace are checked here too, every published key must be of a supported algorithm (ErrUnsupportedKey), and JWKSPath and RemoteJWKS must be well formed.

type RSAPublicKey

type RSAPublicKey struct {
	Key *rsa.PublicKey
	Alg Alg
}

RSAPublicKey is the public half of an RSA key that signs an algorithm other than RS256 (RS384, RS512, PS256, PS384, PS512): the public key of an RSAKey signer, and what PublicKeyOfJWK answers for an RSA JWK whose alg is one of them. Keeping the algorithm on the key keeps the rule that a token never chooses how its own signature is checked: the same modulus published as RS256 and as PS256 is two keys with two key ids.

type RemoteKeys

type RemoteKeys struct {

	// Now is the clock; nil means time.Now.
	Now func() time.Time
	// contains filtered or unexported fields
}

RemoteKeys fetches an issuer's JWKS over HTTP and caches it, refetching when a token names a key it does not know (at most once per RemoteMinRefresh) or when the set is older than RemoteMaxAge. Give it to a Verifier as Lookup, so a service that trusts an app's keys picks up a rotation without a deploy. It is safe for concurrent use.

func NewRemoteKeys

func NewRemoteKeys(url string, hc *http.Client) *RemoteKeys

NewRemoteKeys builds a RemoteKeys for the JWKS at url. A nil client gets one with DefaultRemoteTimeout. Nothing is fetched until the first Lookup.

func (*RemoteKeys) LastError

func (r *RemoteKeys) LastError() error

LastError is the error of the most recent fetch, nil when it succeeded, for a health check or a log line.

func (*RemoteKeys) Lookup

func (r *RemoteKeys) Lookup(kid string) (crypto.PublicKey, bool)

Lookup returns the key named kid, fetching the set when it is empty, stale, or lacks kid and the last fetch is older than RemoteMinRefresh. It matches Verifier.Lookup.

type SignRequest

type SignRequest struct {
	// Subject is the sub claim; required.
	Subject string
	// Audience is the aud claim; nil takes the plugin's Audience.
	Audience []string
	// TTL is the token's lifetime; 0 takes the plugin's, and more than MaxTTL
	// becomes MaxTTL, like WithTTL.
	TTL time.Duration
	// Claims are app claims; a registered claim here is ErrReservedClaim.
	Claims map[string]any
	// Type is the JWS "typ" header; "" is "JWT". An OAuth provider's access
	// token uses "at+jwt" (RFC 9068). It is a media-type name: letters,
	// digits and "+-." only (ErrBadType otherwise), and never
	// SessionCacheType, so no Sign call can mint a session-cache snapshot.
	Type string
	// SessionID is the sid claim, for an ID token that a logout request can
	// later bind to the browser's session; "" leaves it out.
	SessionID string
	// Alg picks the signing algorithm, better-auth's signingAlgorithm: ""
	// or the signing key's algorithm signs with it, another one with the
	// AlgorithmKeys key of that algorithm, and an algorithm with no key is
	// ErrNoAlgorithmKey.
	Alg Alg
	// KeyID picks the signing key by its key id among the signing key and
	// the AlgorithmKeys keys, better-auth's signingKeyId; a key id the
	// plugin does not sign with, or one of another algorithm than Alg, is
	// ErrNoSigningKey.
	KeyID string
}

SignRequest is what Sign mints a token from: a subject the app chose and the claims it wants, under the plugin's key, issuer and lifetime cap.

type Signer

type Signer interface {
	// PublicKeys returns every key a token in flight may carry, the key
	// that signs now first: its KeyID becomes the token's kid header, and
	// the JWKS publishes the list in this order. An empty list means the
	// signer cannot sign, and Token fails with ErrSignerKey.
	PublicKeys() []crypto.PublicKey
	// Sign returns the JWS signature of the signing input (header dot
	// payload) with the private half of key, the first key PublicKeys
	// returned when the token's header was built: for EdDSA the Ed25519
	// signature of the input, for RS256 the PKCS #1 v1.5 signature of its
	// SHA-256, for ES256 the 64-byte r||s of its SHA-256 (RFC 7518 section
	// 3.4, not the ASN.1 form crypto.Signer returns). The key is passed
	// back, rather than assumed current, so a signer whose key changes
	// between the two calls, at the instant of a rotation, still signs with
	// the key the header names.
	Sign(ctx context.Context, key crypto.PublicKey, signingInput []byte) ([]byte, error)
}

Signer signs tokens. The plugin ships two: the key passed to WithSigningKey, and Rotating, which derives a fresh Ed25519 key every interval. An app whose private key must never leave a KMS implements it (or, simpler, passes a KMS crypto.Signer to WithSigningKey): Sign asks the service for the signature and PublicKeys returns what the JWKS must list. The algorithm follows the first key: ed25519.PublicKey is EdDSA, *rsa.PublicKey RS256, *ecdsa.PublicKey on P-256 ES256; any other key is ErrUnsupportedKey. The signature the plugin receives is checked against that key before a token is minted (ErrSignature).

func Rotating

func Rotating(seed []byte, interval, grace time.Duration) Signer

Rotating is a Signer that changes Ed25519 (EdDSA) keys automatically. RS256 and ES256 keys rotate by hand (WithSigningKey with the new key, PreviousKeys with the old), because the standard library derives no RSA key from a seed and only Go 1.25 parses a raw ECDSA scalar. The key of each period (interval-wide, counted from the Unix epoch) is derived from the seed with HKDF-SHA256, so every instance of the app derives the same key for the same period with no storage and no coordination, and a restart changes nothing. The JWKS publishes the current key first, then the keys of the periods that ended within grace, so a token signed just before a rotation verifies until it expires; grace must be at least the TTL.

The seed is the one secret: whoever holds it can derive every key, past and future, which is also true of a stored private key. Rotating buys a bounded lifetime per key and an automatic schedule, not protection against a leaked seed; rotate the seed itself for that.

type SubjectFunc

type SubjectFunc func(ctx context.Context, u auth.User, s auth.Session) string

SubjectFunc chooses the sub claim for a token. It receives the user and the session the token is minted from.

type Template

type Template struct {
	// Claims builds the template's app claims from the user and the
	// session; nil adds none, and an error fails the call.
	Claims ClaimsFunc
	// Subject chooses sub; nil takes the plugin's Subject option, else the
	// user id. An empty result fails the call with ErrEmptySubject.
	Subject SubjectFunc
	// Issuer is iss; "" takes the plugin's issuer.
	Issuer string
	// Audience is aud; nil writes none. A template meant for one receiver
	// should name it, so the receiver's audience check keeps a token from
	// another template out.
	Audience []string
	// TTL is the lifetime; 0 takes the plugin's, and more than MaxTTL
	// becomes MaxTTL. A token never outlives its session either way.
	TTL time.Duration
	// Key signs the tokens; nil is the plugin's signing key. See
	// PublishedKey, HMACKey and PrivateKey.
	Key TemplateKey
	// Algs, when not empty, are the algorithms the receiver accepts: Convex
	// verifies RS256 and ES256 only, Firebase RS256 only. auth.New fails
	// with ErrTemplateKey when Key signs another one, rather than minting
	// tokens the receiver refuses.
	Algs []string
	// Type is the typ header; "" is "JWT". Same rules as SignRequest.Type.
	Type string
}

Template is a named token shape the app registers with WithTemplate and a signed-in client asks for by name (POST <mount>/jwt/template), Clerk's JWT templates: a token for a third party (a database, a GraphQL engine, a backend-as-a-service) that wants claims in its own shape. Everything here is server configuration; the client sends the name only.

The registered claims stay the plugin's: iss, sub, iat, nbf, exp and jti are always written, sid names the session, act an impersonating admin, and aud is Audience. Claims returning one of them fails the call (ErrReservedClaim), so a template can rename nothing a receiver trusts; Issuer and Subject are where a vendor's iss and sub are set. The plugin's Claims, Payload and Subject options do not apply to a template.

type TemplateKey

type TemplateKey interface {
	// contains filtered or unexported methods
}

TemplateKey is what signs a template's tokens: PublishedKey, HMACKey or PrivateKey. The set is closed, so a template key is always one whose publication rule is known: published keys are in the JWKS, the other two never are.

func HMACKey

func HMACKey(secret []byte) TemplateKey

HMACKey signs a template with HS256 and a secret shared with its one receiver, for a vendor that verifies with a shared secret (Supabase's legacy JWT secret, Hasura's HS256 key). The secret is never published: the JWKS lists public keys only, a Verifier refuses HS256 outright, and the secret must be at least MinHMACSecret bytes (ErrHMACSecret). Prefer PublishedKey wherever the receiver can fetch a key set: whoever holds a shared secret can mint tokens, the receiver included.

func PrivateKey

func PrivateKey(k crypto.Signer, kid string) TemplateKey

PrivateKey signs a template with a key the receiver issued, such as a Firebase service account's RSA key, whose public half the receiver already holds. It is never added to the plugin's key set. kid is the header's key id, the receiver's name for the key ("" writes no kid).

func PublishedKey

func PublishedKey(alg Alg, kid string) TemplateKey

PublishedKey signs a template with one of the plugin's published keys, chosen like SignRequest: alg picks the signing key or an AlgorithmKeys key of that algorithm, kid one key by id; "" for both is the signing key. The receiver verifies through the plugin's JWKS route, which is how Convex's customJwt provider and Hasura's jwk_url work.

type TemplateRequest

type TemplateRequest struct {
	Template string `json:"template"`
}

TemplateRequest names the template a token is minted from.

type TokenResult

type TokenResult struct {
	Token     string    `json:"token"`
	ExpiresAt time.Time `json:"expiresAt"`
}

TokenResult is a signed token and its expiry, which is the earlier of the TTL and the session's own expiry.

type Verified

type Verified struct {
	Subject   string
	SessionID string
	// Actor is the admin behind an impersonation, or "".
	Actor     string
	ExpiresAt time.Time
	// Extra holds every claim the app added with Claims, raw.
	Extra map[string]json.RawMessage
}

Verified is a verified token's claims. Only a Verify that returned no error produces one, so every field is trustworthy. ExpiresAt is in UTC.

type Verifier

type Verifier struct {
	// Keys maps key ids to public keys; build it with KeysFromJWKS or add
	// keys with KeyID.
	Keys map[string]crypto.PublicKey
	// Lookup finds a key Keys does not hold, such as one published after a
	// rotation; RemoteKeys.Lookup fetches the issuer's JWKS. Nil means Keys
	// is the whole set.
	Lookup   func(kid string) (crypto.PublicKey, bool)
	Issuer   string
	Audience string
	// Types are the JWS "typ" headers accepted; nil accepts "JWT" only, as
	// before. An OAuth resource server verifying access tokens sets
	// []string{"at+jwt"}. The session-cache type is never accepted.
	Types  []string
	Leeway time.Duration
	// Now is the clock; nil means time.Now.
	Now func() time.Time
}

Verifier checks tokens this plugin issued, for a Go service that receives them. It needs no database and no JWT library.

func (Verifier) Verify

func (v Verifier) Verify(token string) (Verified, error)

Verify checks token and returns its claims. Every failure is ErrInvalid, wrapped with the reason for the log.

Directories

Path Synopsis
Package jwtpreset holds example jwt.Template conventions for third-party services that accept a token from your app: Supabase, Hasura, Convex and Firebase custom tokens.
Package jwtpreset holds example jwt.Template conventions for third-party services that accept a token from your app: Supabase, Hasura, Convex and Firebase custom tokens.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL