Authentication
Authentication in Muzak is declared once. A verifying security scheme is written into the
OpenAPI document like any other scheme, and a route that names it in WithSecurity refuses a
request without a valid credential before any guard, provider or handler runs. The document
and the behaviour are one declaration, so they cannot drift apart.
Two verifying schemes ship with the framework: muzak.JWTBearer for bearer tokens issued by
an identity provider, and muzak.APIKeyVerifier for API keys. Anything they do not cover, a
browser session, a signed webhook, a credential checked against your own database, is a
guard or a provider, exactly as before.
| You need | Use |
|---|---|
| Tokens from an OAuth or OpenID Connect provider | muzak.JWTBearer with JWKS |
| Tokens you sign yourself | muzak.JWTBearer with Keys |
| API keys for partners or services | muzak.APIKeyVerifier |
| One shared secret for an internal route | muzak.RequireBearerToken or muzak.RequireHeaderToken |
| A browser session | AppOptions.Sessions and a provider that reads ctx.Session() |
| A signature over the request body | muzak.CaptureBody and a guard |
JWT bearer tokens
app := muzak.New(muzak.AppOptions{
Title: "Orders API",
SecuritySchemes: map[string]muzak.SecurityScheme{
"oidc": muzak.JWTBearer(muzak.JWTOptions{
Issuers: []string{"https://login.example.com/"},
Audience: "https://api.example.com",
Algorithms: []string{"RS256"},
JWKS: muzak.JWKSOptions{
URL: "https://login.example.com/.well-known/jwks.json",
},
}),
},
})
orders := muzak.NewRouter(muzak.WithSecurity(muzak.Require("oidc", "orders:read")))
orders.Get("/orders", handlers.ListOrders)
orders.Get("/orders/health", handlers.Health, muzak.Public())
app.Include(orders, muzak.WithPrefix("/v1"))
A request to /v1/orders without Authorization: Bearer <token> is answered with 401 before
ListOrders runs. A valid token that does not grant orders:read is answered with 403.
/v1/orders/health is open, because Public() exempts it.
The issuers, the audience and the algorithms are required. There is no default that accepts whatever a token says: a scheme that leaves one out is a build error.
Every key of a scheme is trusted for every issuer it lists, so a token signed with one
issuer's key may name another listed issuer as its own. List several issuers in one scheme
only when they share their keys, as the spellings of one identity provider's issuer do. Two
identity providers are two schemes, offered as alternatives:
muzak.WithSecurity(muzak.Require("workforce"), muzak.Require("customers")).
| Option | What it decides |
|---|---|
Issuers | The iss values accepted, compared exactly |
Audience | The value aud must contain, as a string or one entry of an array |
Algorithms | The allowlist of JWS algorithms; none is never accepted |
Keys | Keys configured in the application, each optionally with a kid |
JWKS | A key set fetched from the issuer and kept fresh |
Leeway | Clock skew allowed on exp, nbf and iat; 60 seconds by default, at most 5 minutes |
AllowNoExpiry | Accept a token without exp; off by default |
Types | Accepted typ values; JWT and at+jwt by default |
MaxTokenBytes | Longest token read; 8 KiB by default, at most 64 KiB |
Realm | The realm the WWW-Authenticate challenge names; api by default |
ResourceMetadata | Publish RFC 9728 metadata; off by default |
Reading the claims
The verified claims are a *muzak.Claims, available as a Dep, through muzak.From and
through muzak.TryFrom.
type ListOrdersIn struct {
Limit int `query:"limit" default:"20"`
Claims muzak.Dep[*muzak.Claims]
}
Claims holds the registered claims Muzak checked: Issuer, Subject, Audience,
ExpiresAt, NotBefore, IssuedAt, ID, the Scopes granted by scope or scp, and
the KeyID and Algorithm of the header. Raw is the payload as it was signed.
A Dep[*muzak.Claims] is checked when the application is built: every way into the route must
go through a JWT scheme. A route that also admits an API key, or anonymous requests, reads the
claims with muzak.TryFrom[*muzak.Claims](ctx) instead, and the build error says so. See
Dependencies for Dep, From and TryFrom.
Custom claims
muzak.ClaimsAs decodes the payload into a type of your own, once per request and type.
type TenantClaims struct {
Tenant string `json:"tid"`
Roles []string `json:"roles"`
}
tenant, err := muzak.ClaimsAs[TenantClaims](ctx)
if err != nil {
return Out{}, err // a 401, with the reason logged
}
To hand them to a handler as a dependency, wrap the call in a provider:
muzak.Needs(func(ctx *muzak.Context) (TenantClaims, error) {
return muzak.ClaimsAs[TenantClaims](ctx)
})
What a token has to be
A token is accepted only when every one of these holds. A token that fails any of them gets
the same 401, and nothing says which check failed.
- Its shape. A JWS compact serialization of three segments, no longer than
MaxTokenBytes, each segment in strict base64url: no padding, no+or/, no whitespace, and no non-zero trailing bits, so that each token has exactly one spelling. - Its header. A JSON object no deeper than four levels and no larger than 2 KiB, without a
repeated member, naming an algorithm on the allowlist. A
typ, when present, is one ofTypes. A token carryingcrit,jku,jwk,x5u,x5c,encorzip, or actyofJWT, is refused. - Its signature. Checked with a key of the algorithm's own kind. A token naming a
kidis checked only against keys with thatkid, and one naming none only against keys that have none. ECDSA signatures are the rawr||sform of exactly the curve's width. - Its claims. A JSON object no deeper than sixteen levels, without a repeated member.
issis one ofIssuers,audcontainsAudience,expis in the future, andnbfandiatare not, each within the leeway. Dates are JSON numbers, fractional or not, between 0 and the end of the year 9999.
The payload is read only after the signature has verified.
Keys and algorithms
A key is used only by algorithms of its own kind. This is what defeats the classic algorithm-confusion attack, where a token signed with HS256 uses the server's RSA public key as its HMAC secret: an RSA key is never an HMAC secret, whatever the token says.
| Algorithms | Key |
|---|---|
HS256, HS384, HS512 | Secret, at least as long as the hash: 32, 48 or 64 bytes |
RS256, RS384, RS512, PS256, PS384, PS512 | *rsa.PublicKey of at least 2048 bits |
ES256, ES384, ES512 | *ecdsa.PublicKey on P-256, P-384 or P-521 respectively |
EdDSA, Ed25519 | ed25519.PublicKey |
muzak.JWTBearer(muzak.JWTOptions{
Issuers: []string{"orders-service"},
Audience: "orders-api",
Algorithms: []string{"HS256"},
Keys: []muzak.JWTKey{
{ID: "2026-10", Secret: settings.SigningKey},
{ID: "2026-07", Secret: settings.PreviousSigningKey},
},
})
A short RSA key, a private key passed where the public one belongs, an ECDSA key on a curve no
allowed algorithm uses, a point that is not on its curve and an HMAC secret shorter than its
hash are all build errors. JWTKey.Algorithm pins a key to one algorithm, which is how a
32-byte secret is used for HS256 while HS512 is allowed for another key.
Key sets
With JWKS.URL set, the scheme fetches the issuer's JSON Web Key Set and keeps it.
JWKSOptions | Default | Bound |
|---|---|---|
Client | muzak.NewClient with public addresses only | An *http.Client is accepted too |
MaxBytes | 1 MiB | The document is refused past it |
MaxKeys | 32 | At most 1024; a document with more is refused whole |
Timeout | 10 seconds | Per fetch |
MinRefreshInterval | 30 seconds | Least time between two fetches |
MinCacheTTL, MaxCacheTTL | 5 minutes, 24 hours | Bounds on the response's max-age |
- Fetched over https. Plain http is accepted only for a loopback address, in a test. The same holds for every URL a redirect leads to: a set that arrives over plain http from anywhere else is refused, so a redirect down to http cannot replace the keys.
- Through the SSRF-safe client. The default client refuses private, loopback and link-local
addresses. An identity provider on a private network, such as a Keycloak beside the service,
needs
muzak.NewClient(muzak.ClientOptions{AllowPrivateNetworks: true}), orAllowedNetworksnaming its subnet. See HTTP Client. The size limit and the timeout apply whichever client you pass. - Cached as the issuer says.
Cache-Control: max-agedecides how long a set is kept, held withinMinCacheTTLandMaxCacheTTL; a response without one is kept for an hour. - Refreshed in the background. The set is a lifecycle component named
jwks <scheme>. It is fetched as the application starts, without holding the start up, and again in the last tenth of each copy's life, so a token rarely waits for it. - Fetched by the first request that needs one. A token that arrives while no set is held
(in a test without the lifecycle, or before the first background fetch has finished) starts
a fetch, and that request waits for it up to
Timeout, whatever its own deadline, so that its client leaving does not fail the fetch for everyone else. A request waiting for a fetch another one started stops waiting when its own context ends. - Rotation without a flood. A token naming a
kidthe set does not hold makes it fetched again, at most once perMinRefreshIntervalhowever many such tokens arrive, and every request waiting for the same fetch shares it. A token with a randomkidcosts the issuer nothing. - The last good set is kept. A fetch that fails, answers with an error, sends something that
is not a key set or sends one that is too large keeps the set already in use, and the failure
is logged. There is no age past which the last good set is dropped, however long fetches keep
failing, so watch the log for them. Until a first fetch has succeeded, a token is answered
with
503, which is the server's failure and not the client's. - Strict keys. Only
RSA,ECandOKPkeys are read: a symmetricoctkey in a public set is a secret anyone can read and is never used. A key must be meant for signatures (useabsent orsig,key_opsabsent or allowingverify), itsalg, when present, must be allowed and fit the key, an EC point must be on its curve, an RSA modulus must be 2048 to 8192 bits, and a key carrying private members is refused. A key that fails is skipped without costing the rest of the set.
Scopes
The scopes Require names are checked against what the token grants: the space-separated
scope claim, an scp claim that is an array or such a string, or both.
r.Post("/orders", handlers.CreateOrder,
muzak.WithSecurity(muzak.Require("oidc", "orders:write")))
A valid token without the scope is answered with 403 and an insufficient_scope challenge
naming the scopes required.
API keys
"partners": muzak.APIKeyVerifier(muzak.APIKeyOptions{
Name: "X-API-Key",
Keys: []muzak.APIKey{
{ID: "acme", Key: settings.AcmeKey, Scopes: []string{"orders:read"}},
{ID: "globex", Key: settings.GlobexKey},
},
}),
In is header by default, or query or cookie, matching APIKeyHeader, APIKeyQuery
and APIKeyCookie in the document. The verified caller is a *muzak.APIKeyPrincipal with an
ID, the Scheme that verified it and the Scopes it grants.
- Constant time. Keys are held as SHA-256 digests, and a request's key is hashed and compared with every one of them without stopping at a match, so neither which key matched, nor how much of one did, nor how long one is shows in the response time.
- No ambiguity. A request that sends the key twice, in two headers, parameters or cookies, is refused.
- Never logged. Neither the key nor its digest reaches a log or a response.
- Checked at build. A key under 16 bytes or over 1024 (the most read from a request), two principals sharing a key and two keys sharing an ID are build errors.
A key in a query string is written to logs and browser history along with the URL, so prefer a header. A key in a cookie is an ambient credential: the browser attaches it to a request another site makes it send, as it does a session cookie. Pair a cookie key with cross-origin protection, which refuses a state-changing request a browser sends from another origin.
A key set too large to list, or one stored in a database, is resolved with Lookup, which
runs only for a key no static key matched:
Lookup: func(ctx *muzak.Context, key string) (*muzak.APIKeyPrincipal, error) {
digest := sha256.Sum256([]byte(key))
client, err := store.ClientByKeyDigest(ctx.Context(), digest[:])
if errors.Is(err, store.ErrNotFound) {
return nil, nil // 401
}
if err != nil {
return nil, err // 503, logged
}
return &muzak.APIKeyPrincipal{ID: client.Name, Scopes: client.Scopes}, nil
},
Store keys as digests, as above, so a leaked table is not a leaked set of keys. A lookup error is logged, so it must not quote the key.
Combining schemes
WithSecurity takes alternatives, and a SecurityRequirement names schemes that are needed
together. Muzak enforces both exactly as the document describes them.
// A token, or a partner key.
muzak.WithSecurity(muzak.Require("oidc"), muzak.Require("partners"))
// A token and a partner key together.
muzak.WithSecurity(muzak.SecurityRequirement{"oidc": nil, "partners": nil})
// A token when there is one, anonymous otherwise.
muzak.WithSecurity(muzak.Require("oidc"), muzak.SecurityRequirement{})
The first requirement a request meets admits it, and its principals reach the route. A request that presents a credential is judged by it even where anonymous requests are admitted: an expired token is refused rather than quietly served as nobody, so the client learns it needs a new one.
A route may name a verifying scheme and a descriptive one in one requirement: the verifying one is enforced, and the guard that checks the other refuses on its own. Alternatives that mix the two are a build error, because a request that satisfies a described alternative cannot be told from one that satisfies nothing.
A declaration replaces the one above it instead of adding to it, as it does in the document,
since alternatives and combinations cannot be told apart once they are merged. That cuts both
ways. A route's WithSecurity replaces its router's, and an inner router's Public(), or its
own WithSecurity naming only descriptive schemes, replaces a verifying requirement given
where it is included: the routes beneath it are then no longer enforced. Declare the
requirement on the innermost router that needs it, or check the security list of each
operation in the document.
What is covered
| Request | Enforced |
|---|---|
| The route itself | Yes |
HEAD answered by a GET route | Yes, it is that route |
OPTIONS and 405 from the route table | Nothing runs; they disclose only the methods |
Router.Mount, Router.Static, Router.Frontend beneath the router | Yes, they inherit its declaration |
/openapi.json and /docs | Yes, for a declaration on muzak.New itself |
A route or mount with Public() | No |
The gate runs after the rate limit is counted, so a client guessing tokens spends its budget,
before a body CaptureBody keeps is read, so a request without a valid credential cannot make
the server buffer one, and before every guard and provider, so neither ever sees an
unauthenticated request. A response behind a verifying scheme carries
Cache-Control: private, no-cache unless the handler sets its own, and Vary: Authorization
(or the API key's header, or Cookie).
Like guards declared on muzak.New, a requirement declared there, as in
muzak.New(opts, muzak.WithSecurity(muzak.Require("oidc"))), covers /openapi.json and
/docs. Guarded documentation is sent Cache-Control: private, no-cache and counts against the
application's rate limit. To publish the documentation while the API stays private, declare the
requirement where the routers are included instead, or set AppOptions.DisableDocs to serve
neither. See OpenAPI.
Refusals
| Situation | Status | Challenge |
|---|---|---|
| No credential | 401 | Bearer realm="api" |
| A credential that is not valid | 401 | Bearer realm="api", error="invalid_token" |
| A valid credential without a required scope | 403 | Bearer realm="api", error="insufficient_scope", scope="orders:write" |
| No key set fetched yet, or a lookup failed | 503 | none |
The body is the standard error envelope with its standard sentence, Authentication is required. or You do not have access to this resource., rendered by the configured error
renderer, so AppOptions.ProblemDetails applies. It never quotes the token, and never says
whether the signature, the issuer, the audience or the expiry was wrong. An API key scheme
names no challenge, since there is no standard one for it.
Protected resource metadata
An OAuth client that knows only an API's URL, an MCP client among them, discovers where to get a
token from the API's RFC 9728 metadata. ResourceMetadata publishes it:
muzak.JWTBearer(muzak.JWTOptions{
// ...
ResourceMetadata: &muzak.ResourceMetadata{
Resource: "https://api.example.com",
ScopesSupported: []string{"orders:read", "orders:write"},
},
})
GET /.well-known/oauth-protected-resource
{
"resource": "https://api.example.com",
"authorization_servers": ["https://login.example.com/"],
"scopes_supported": ["orders:read", "orders:write"],
"bearer_methods_supported": ["header"]
}
The document is public, whatever WithSecurity the application declares, and left out of the
OpenAPI document. The application's own guards still run for it, as they do for every route, so
an application-wide guard keeps clients from reading it; declare such a guard on an included
router instead. AuthorizationServers defaults to the scheme's issuers. A resource with a path,
https://api.example.com/v1, is described at /.well-known/oauth-protected-resource/v1, as the
RFC places it (muzak.ProtectedResourcePath followed by the resource's path). Every challenge
then names it:
WWW-Authenticate: Bearer realm="api", error="invalid_token", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"
It is off by default. The path is the one in Resource, served by this application as it
stands, so an application behind a proxy that strips or adds a prefix must make the public URL
and the served path agree. This is the document an MCP client reads to find where to sign in;
see MCP Tools.
Shared secrets
For an internal service, a webhook receiver or an admin subtree, the credential is one secret both sides know. Two guards ship with the framework.
// An Authorization: Bearer <token> header.
muzak.RequireBearerToken(settings.AdminToken)
// Any named header, such as an internal service token or a webhook
// signing key.
muzak.RequireHeaderToken("X-Token", settings.AdminToken)
app.Include(routers.Admin(),
muzak.WithPrefix("/admin"),
muzak.WithTags("admin"),
muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", settings.AdminToken)),
)
Both compare in constant time, and RequireBearerToken answers a missing credential with a
WWW-Authenticate header so a client knows which scheme to use. They are guards, so the
document does not know about them unless you declare a descriptive scheme too; see
Documenting a guard.
A provider of your own reads a bearer credential with muzak.BearerToken(ctx), which reports
whether a well-formed one was present and matches the scheme name case-insensitively, as RFC
9110 requires.
Comparing secrets
if !muzak.SecureCompare(given, expected) {
return muzak.Unauthorized("")
}
Comparing a credential with == leaks its contents: the comparison returns as soon as two
bytes differ, so an attacker who can time the response can recover the secret one byte at a
time. SecureCompare hashes both inputs first and compares the digests, which also keeps the
length of the expected secret from leaking.
Use it for tokens, API keys and signatures. It is not a password verification function. A
password must be checked against a slow, memory-hard hash such as the one
golang.org/x/crypto/argon2 provides.
Resolving a user
A verified token says who the caller is. Your application usually wants its own record of them, which is a provider that reads the claims:
func GetCurrentUser(ctx *muzak.Context) (CurrentUser, error) {
claims := muzak.From[*muzak.Claims](ctx)
users := muzak.From[*core.UserStore](ctx)
user, err := users.BySubject(ctx.Context(), claims.Subject)
if err != nil {
return CurrentUser{}, muzak.Unauthorized("").Wrap(err)
}
return CurrentUser{Username: user.Username}, nil
}
r.Get("/users/me", handlers.CurrentUser,
muzak.WithSecurity(muzak.Require("oidc")),
muzak.Needs(core.GetCurrentUser))
The provider runs after the gate, so the claims are always there. Wrap records the real cause
in the log and keeps it out of the response. See
Dependencies.
Signing in with a session
A browser flow exchanges credentials for a session once, then presents the session cookie
with every request. AppOptions.Sessions keeps the session, encrypted in the cookie or in a
store, and requires AppOptions.CrossOriginProtection beside it; see
Sessions and
Cross-Origin Requests. The sign-in handler checks the
password and records who signed in:
func Login(ctx *muzak.Context, in schemas.LoginIn) (schemas.LoginOut, error) {
expected, known := accounts[in.Username]
// The comparison runs even for an unknown account, and the same answer is
// given either way, so the endpoint is not an account enumerator.
matches := subtle.ConstantTimeCompare([]byte(expected), []byte(in.Password)) == 1
if !known || !matches {
return schemas.LoginOut{}, muzak.Unauthorized("the username or password is incorrect")
}
// A new session first, so a session someone planted in the browser is
// worth nothing at the moment it would have become valuable.
session := ctx.Session()
if err := session.Regenerate(); err != nil {
return schemas.LoginOut{}, err
}
if err := session.Set("user", in.Username); err != nil {
return schemas.LoginOut{}, err
}
return schemas.LoginOut{Username: in.Username}, nil
}
Four things matter on a sign-in route, all visible above:
- The same answer either way. Telling an unknown account from a wrong password turns the endpoint into an account enumerator.
- The same timing either way. Returning early on an unknown account says the same thing more quietly, which is why the comparison runs regardless.
- A rate limit counted before the guards. A request a guard refuses is still counted, so
failed sign-ins cost the attacker their budget.
RateLimitOptions.AfterDependencieswould give that up, which is why a sign-in route never uses it. - A password checked against a slow hash. The example compares plain values because there is nothing to protect in it. A real one stores an argon2 or bcrypt digest.
Resolve the caller on the way back in with a provider:
func GetCurrentUser(ctx *muzak.Context) (CurrentUser, error) {
username, ok := muzak.SessionGet[string](ctx.Session(), "user")
if !ok {
return CurrentUser{}, muzak.Unauthorized("sign in first")
}
return CurrentUser{Username: username}, nil
}
Credentials on a WebSocket
A browser cannot set headers on a WebSocket handshake or an EventSource, so a JWT scheme,
which reads Authorization, only covers clients that are not browsers. A browser's WebSocket
authenticates with the session cookie it attaches itself, or a short-lived ticket in the query,
resolved by a provider:
func GetTicket(ctx *muzak.Context) (Ticket, error) {
if user, ok := muzak.SessionGet[string](ctx.Session(), "user"); ok {
return Ticket{User: user}, nil
}
if t := ctx.Query("ticket"); t != "" {
return tickets.Redeem(ctx.Context(), t) // single use, short-lived
}
return Ticket{}, muzak.Unauthorized("a session or a ticket is required")
}
A cookie is ambient authority a browser attaches without being asked, which is why a cross-origin handshake is refused by default. A token in a query string ends up in access logs and browser history; Muzak's own access log records no query strings for that reason. See WebSockets.
A credential is judged once, when the connection or the stream opens. A WebSocket or an event
stream that stays open outlives the token that admitted it: nothing closes it when the token's
exp passes. Set WSOptions.MaxLifetime or SSEOptions.MaxLifetime to something near the
lifetime of your tokens, and the client reconnects with a fresh one.
Verifying a signature over the body
A signed request is verified from its bytes. Declare muzak.CaptureBody() and the bytes are
kept for the request. They are read after the rate limit is counted and after a verifying
scheme has admitted the request, and before the guards and providers run, so the guard below
refuses an unsigned request before anything decodes it.
r.Post("/webhooks/stripe", handlers.Stripe,
muzak.CaptureBody(),
muzak.Needs(core.VerifyStripeSignature))
func VerifyStripeSignature(ctx *muzak.Context) (Verified, error) {
body, ok := ctx.RawBody()
if !ok {
return Verified{}, muzak.NewHTTPError(
http.StatusInternalServerError, "this route does not capture its body")
}
expected := hmac.New(sha256.New, secret)
expected.Write(body)
if !hmac.Equal(expected.Sum(nil), presented) {
return Verified{}, muzak.Unauthorized("the signature does not match")
}
return Verified{}, nil
}
Verify the original, never a re-encoding of it. Marshalling the bound input back to JSON produces a different document, and a signature over it never matches.
Documenting a guard
A guard is a function that Muzak can run and cannot read, so a route protected by one is documented with a descriptive scheme, which says what the guard checks and enforces nothing:
app := muzak.New(muzak.AppOptions{
SecuritySchemes: map[string]muzak.SecurityScheme{
"admin": muzak.APIKeyHeader("X-Token"), // descriptive
},
})
admin := muzak.NewRouter(
muzak.WithSecurity(muzak.Require("admin")),
muzak.WithDependencies(muzak.RequireHeaderToken("X-Token", settings.AdminToken)),
)
BearerAuth, BasicAuth, HTTPAuth, APIKeyHeader, APIKeyQuery, APIKeyCookie, OAuth2 and
OpenIDConnect are descriptive, and behave exactly as they always have. JWTBearer and
APIKeyVerifier appear in components.securitySchemes exactly as BearerAuth("JWT") and the
matching APIKey... constructor do, so switching a route from a descriptive scheme and a guard
to a verifying scheme changes nothing in the document. See
OpenAPI.
Testing
A test signs its own tokens with a key the test configures. An HMAC secret is the shortest path:
func testApp(t *testing.T) *muzak.App {
secret := bytes.Repeat([]byte("k"), 32)
return buildApp(muzak.JWTBearer(muzak.JWTOptions{
Issuers: []string{"test"},
Audience: "orders-api",
Algorithms: []string{"HS256"},
Keys: []muzak.JWTKey{{Secret: secret}},
}))
}
func TestListOrdersNeedsAToken(t *testing.T) {
client := testclient.New(t, testApp(t))
res := client.Get("/v1/orders")
res.AssertStatus(http.StatusUnauthorized)
res.AssertErrorCode(muzak.CodeUnauthorized)
}
App.Override replaces providers, and a verifying scheme is not one: like a guard, it keeps
refusing in a test exactly as it does for a client, so a test that wants to be let in presents a
credential. A suite whose every request needs one gives it to the client once:
client := testclient.New(t, testApp(t),
testclient.WithHeader("Authorization", "Bearer "+signTestToken(t)))
Where to go next
Authorization covers what a verified caller is allowed to do, Sessions covers the session a browser signs in to, HTTP Client covers reaching an identity provider on a private network, and MCP Tools covers serving routes to an MCP client that signs in through the protected resource metadata.