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.
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
wireBearerbeforecfg.Freeze(). Host handlers must be mounted insidehosting.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 towicket, so Bearer authentication must be invoked explicitly viaAuthenticateas above; it isn't applied automatically to every request. - The container's built-in
Bearerhandler has no validation keys at all; you must replace it with a handler you construct yourself viaSetAuthenticationHandlerProvider.
Where the token comes from
OnMessageReceivedfires first. The hook can assignctx.Token(taking the token from another header, a cookie, or a query parameter), or setctx.Resultto end processing directly.- If the hook doesn't supply a token, the
Authorizationheader is read; thebearerprefix is case-insensitive. - 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:
| Field | Default | Description |
|---|---|---|
RequireSignedTokens | true | Require signature validation; when true, Keys must be provided |
Keys | nil | Public key set for signature validation (jwk.Set), selected by kid; every key must carry alg, see the note below |
ValidIssuer | Empty | When non-empty, iss must match exactly |
ValidAudience | Empty | When non-empty, aud must contain this value |
ValidateLifetime | true | Validate exp / nbf / iat; when turned off, issuer and audience are still validated |
ClockSkew | 5 minutes (jwt.DefaultClockSkew) | Tolerance for time comparisons; 0 means exact comparison |
Clock | nil | When empty, the handler injects the container's SystemClock |
ValidateOptions | Empty | Additional 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:
| Field | Takes effect? | Description |
|---|---|---|
TokenValidationParameters | Yes | See the table above |
SecurityTokenValidators | Yes | Validator chain; by default only jwt.NewSecurityTokenHandler(). Tried in order; any one succeeding is enough |
SaveToken | Yes | When true, stores the raw token in the authentication properties under the name access_token |
Events (SetEvents) | Partially | See the next section |
Authority, MetadataAddress, RequireHttpsMetadata | No | The handler doesn't do metadata discovery and doesn't fetch JWKS automatically |
RefreshOnIssuerKeyNotFound, AutomaticRefreshInterval, RefreshInterval | No | Same as above; there's no automatic refresh |
Audience | No | Set the audience in TokenValidationParameters.ValidAudience |
ValidateOptions (Options level) | No | Use TokenValidationParameters.ValidateOptions |
Challenge, IncludeErrorDetails | No | A challenge writes only the 401 status code, without a WWW-Authenticate header |
BackchannelHttpHandler | No | There 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:
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:
submaps toclaims.NameIdentifier(the URI-form constant);iss,jti, andaud(one by one when multi-valued) keep their original names;exp,nbf, andiatdon'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:
| Hook | When | Usage |
|---|---|---|
OnMessageReceived | Before the token is read | Set ctx.Token, or set ctx.Result to end directly |
OnTokenValidated | After signature and claim validation pass | Add business checks; call ctx.Fail(err) to reject; ctx.Principal is readable and writable |
OnAuthenticationFailed, OnForbidden, OnChallenge | Declared, currently never called | Don't rely on them |
A few details that are easy to trip over:
- If you construct
&jwt.Events{...}yourself, unset fields areniland panic when called. Start fromjwt.NewEvents()and override the fields you need. - When
OnTokenValidatedreturns 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, usectx.Fail(err)and returnnil. - When every validator fails, the result is a failure (
Succeeded()isfalse), and the specific reason is written only to the debug log.