Skip to content

JWT Bearer authentication ​

This page explains how to use auth/jwt so the host API accepts access tokens of the form Authorization: Bearer <JWT>. The tokens can come from Wicket itself or from another issuer.

It isn't a login flow: there are no redirects and no cookies; it validates the token once per request and yields an identity.

Runnable example ​

The wiring below has been compiled and tested on v0.1.4: a valid token returns 200; a token taken from a custom header returns 200; a missing business claim or no token at all returns 401.

go
import (
	"fmt"
	"log/slog"
	"net/http"

	"github.com/gonewx/godi"
	wicket "github.com/gonewx/wicket"
	"github.com/gonewx/wicket/auth"
	"github.com/gonewx/wicket/auth/jwt"
	"github.com/gonewx/wicket/claims"
	"github.com/gonewx/wicket/di"
	optspkg "github.com/gonewx/wicket/di/options"
	"github.com/gonewx/wicket/hosting"
	"github.com/gonewx/wicket/services"
	"github.com/lestrrat-go/jwx/v3/jwk"
)

type fixedMonitor[T optspkg.Entity[T]] struct{ opts T }

func (m fixedMonitor[T]) CurrentValue() (T, error) { return m.opts, nil }
func (m fixedMonitor[T]) Get(string) (T, error)    { return m.opts, nil }

// keys is the issuer's public key set (jwk.Set), e.g. fetched from its JWKS URL with jwk.Fetch.
func wireBearer(root *di.Root, logger *slog.Logger, keys jwk.Set) error {
	cfg := root.ServerConfig()
	cfg.SetAuthenticationHandlerProvider(wicket.JwtBearerAuthenticationScheme, func(p godi.Provider) auth.Handler {
		o := jwt.NewOptions()
		o.TokenValidationParameters.ValidIssuer = "https://issuer.example.com"
		o.TokenValidationParameters.ValidAudience = "orders-api"
		o.TokenValidationParameters.Keys = keys
		o.SaveToken = true

		ev := jwt.NewEvents() // start from NewEvents so hooks you don't override stay no-ops
		ev.OnMessageReceived = func(ctx *jwt.MessageReceivedContext) error {
			ctx.Token = ctx.Request.Header.Get("X-Api-Token") // if empty, falls back to the Authorization header
			return nil
		}
		ev.OnTokenValidated = func(ctx *jwt.TokenValidatedContext) error {
			if ctx.Principal.FindFirst("tenant") == nil {
				ctx.Fail(fmt.Errorf("missing tenant claim"))
			}
			return nil
		}
		o.SetEvents(ev)
		return jwt.NewHandler(fixedMonitor[*jwt.Options]{o}, logger,
			godi.GetRequired[services.SystemClock](p))
	})
	return root.Ptr_SchemeProvider().AddScheme(auth.NewScheme(wicket.JwtBearerAuthenticationScheme, "jwt"))
}

// Authenticate on demand in the host's route
func ordersHandler(w http.ResponseWriter, r *http.Request) {
	svc := godi.GetRequired[auth.Service](hosting.ScopeFrom(r.Context()))
	result, err := svc.Authenticate(r, w, wicket.JwtBearerAuthenticationScheme)
	if err != nil || !result.Succeeded() {
		w.Header().Set("WWW-Authenticate", "Bearer")
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}
	sub := result.Principal.FindFirst(claims.NameIdentifier)
	fmt.Fprintf(w, "hello %s\n", sub.Value)
}

Key points:

  • Call wireBearer before cfg.Freeze(). Host handlers must be mounted inside hosting.ScopeMiddleware(root)(srv.UseWicket()(app)) to get the scope from the request context.
  • The scheme name is Bearer (wicket.JwtBearerAuthenticationScheme). The container's default scheme is fixed to wicket, so Bearer authentication must be invoked explicitly via Authenticate as above; it isn't applied automatically to every request.
  • The container's built-in Bearer handler has no validation keys at all; you must replace it with a handler you construct yourself via SetAuthenticationHandlerProvider.

Where the token comes from ​

  1. OnMessageReceived fires first. The hook can assign ctx.Token (taking the token from another header, a cookie, or a query parameter), or set ctx.Result to end processing directly.
  2. If the hook doesn't supply a token, the Authorization header is read; the bearer prefix is case-insensitive.
  3. If neither yields a token, the result is "no result" (anonymous), not a failure.

Validation parameters (TokenValidationParameters) ​

What actually governs validation is Options.TokenValidationParameters. jwt.NewOptions() gives it a set of safe defaults:

FieldDefaultDescription
RequireSignedTokenstrueRequire signature validation; when true, Keys must be provided
KeysnilPublic key set for signature validation (jwk.Set), selected by kid; every key must carry alg, see the note below
ValidIssuerEmptyWhen non-empty, iss must match exactly
ValidAudienceEmptyWhen non-empty, aud must contain this value
ValidateLifetimetrueValidate exp / nbf / iat; when turned off, issuer and audience are still validated
ClockSkew5 minutes (jwt.DefaultClockSkew)Tolerance for time comparisons; 0 means exact comparison
ClocknilWhen empty, the handler injects the container's SystemClock
ValidateOptionsEmptyAdditional jwx validation options (e.g. custom claim validation)

Options fields: which take effect in v0.1.4 ​

jwt.Options declares many fields, but the v0.1.4 handler reads only some of them. Setting a field that doesn't take effect does nothing at all, so don't rely on it:

FieldTakes effect?Description
TokenValidationParametersYesSee the table above
SecurityTokenValidatorsYesValidator chain; by default only jwt.NewSecurityTokenHandler(). Tried in order; any one succeeding is enough
SaveTokenYesWhen true, stores the raw token in the authentication properties under the name access_token
Events (SetEvents)PartiallySee the next section
Authority, MetadataAddress, RequireHttpsMetadataNoThe handler doesn't do metadata discovery and doesn't fetch JWKS automatically
RefreshOnIssuerKeyNotFound, AutomaticRefreshInterval, RefreshIntervalNoSame as above; there's no automatic refresh
AudienceNoSet the audience in TokenValidationParameters.ValidAudience
ValidateOptions (Options level)NoUse TokenValidationParameters.ValidateOptions
Challenge, IncludeErrorDetailsNoA challenge writes only the 401 status code, without a WWW-Authenticate header
BackchannelHttpHandlerNoThere are no outbound requests

Also, jwt.NewOptions() doesn't give these fields non-zero defaults—booleans are false and durations are 0.

Every public key in Keys must carry alg

After selecting a key by kid, the validator verifies the signature with the algorithm declared on the key; it doesn't infer the algorithm from the key type. Observed on v0.1.4: after removing the alg field from the same set of RSA public keys, every valid token failed validation (401). Many providers' JWKS include alg, but it isn't a required field; once you have the jwk.Set, check each key and add it if missing, for example:

go
for i := 0; i < keys.Len(); i++ {
	if k, ok := keys.Key(i); ok {
		if _, has := k.Algorithm(); !has {
			_ = k.Set(jwk.AlgorithmKey, jwa.RS256()) // set to the algorithm the issuer actually uses
		}
	}
}

Need to follow the issuer's key rotation?

Because the handler doesn't do metadata discovery, you control where keys come from: fetch them once at startup with jwk.Fetch, or refresh them periodically with jwx's jwk.Cache and get the latest jwk.Set in the factory function (the factory is called each time the handler is resolved).

The identity after successful validation ​

After validation succeeds, the token claims are projected into an identity with authentication type Bearer:

  • sub maps to claims.NameIdentifier (the URI-form constant);
  • iss, jti, and aud (one by one when multi-valued) keep their original names;
  • exp, nbf, and iat don't go into the identity; they're written to the authentication properties' Expires / Issued;
  • All other claims keep their original names and values.

Event hooks ​

jwt.Events has five fields; the v0.1.4 handler actually calls two of them:

HookWhenUsage
OnMessageReceivedBefore the token is readSet ctx.Token, or set ctx.Result to end directly
OnTokenValidatedAfter signature and claim validation passAdd business checks; call ctx.Fail(err) to reject; ctx.Principal is readable and writable
OnAuthenticationFailed, OnForbidden, OnChallengeDeclared, currently never calledDon't rely on them

A few details that are easy to trip over:

  • If you construct &jwt.Events{...} yourself, unset fields are nil and panic when called. Start from jwt.NewEvents() and override the fields you need.
  • When OnTokenValidated returns a non-nil error, the handler moves on to the next validator in the chain; the default chain has only one validator, so the whole thing fails with "no usable validator". To reject explicitly, use ctx.Fail(err) and return nil.
  • When every validator fails, the result is a failure (Succeeded() is false), and the specific reason is written only to the debug log.

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors