You are reading the 0.3.0 documentation. The current release is 0.3.1. Read this page for it →

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 needUse
Tokens from an OAuth or OpenID Connect providermuzak.JWTBearer with JWKS
Tokens you sign yourselfmuzak.JWTBearer with Keys
API keys for partners or servicesmuzak.APIKeyVerifier
One shared secret for an internal routemuzak.RequireBearerToken or muzak.RequireHeaderToken
A browser sessionAppOptions.Sessions and a provider that reads ctx.Session()
A signature over the request bodymuzak.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")).

OptionWhat it decides
IssuersThe iss values accepted, compared exactly
AudienceThe value aud must contain, as a string or one entry of an array
AlgorithmsThe allowlist of JWS algorithms; none is never accepted
KeysKeys configured in the application, each optionally with a kid
JWKSA key set fetched from the issuer and kept fresh
LeewayClock skew allowed on exp, nbf and iat; 60 seconds by default, at most 5 minutes
AllowNoExpiryAccept a token without exp; off by default
TypesAccepted typ values; JWT and at+jwt by default
MaxTokenBytesLongest token read; 8 KiB by default, at most 64 KiB
RealmThe realm the WWW-Authenticate challenge names; api by default
ResourceMetadataPublish 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 of Types. A token carrying crit, jku, jwk, x5u, x5c, enc or zip, or a cty of JWT, is refused.
  • Its signature. Checked with a key of the algorithm's own kind. A token naming a kid is checked only against keys with that kid, and one naming none only against keys that have none. ECDSA signatures are the raw r||s form of exactly the curve's width.
  • Its claims. A JSON object no deeper than sixteen levels, without a repeated member. iss is one of Issuers, aud contains Audience, exp is in the future, and nbf and iat are 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.

AlgorithmsKey
HS256, HS384, HS512Secret, 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, Ed25519ed25519.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.

JWKSOptionsDefaultBound
Clientmuzak.NewClient with public addresses onlyAn *http.Client is accepted too
MaxBytes1 MiBThe document is refused past it
MaxKeys32At most 1024; a document with more is refused whole
Timeout10 secondsPer fetch
MinRefreshInterval30 secondsLeast time between two fetches
MinCacheTTL, MaxCacheTTL5 minutes, 24 hoursBounds 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}), or AllowedNetworks naming its subnet. See HTTP Client. The size limit and the timeout apply whichever client you pass.
  • Cached as the issuer says. Cache-Control: max-age decides how long a set is kept, held within MinCacheTTL and MaxCacheTTL; 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 kid the set does not hold makes it fetched again, at most once per MinRefreshInterval however many such tokens arrive, and every request waiting for the same fetch shares it. A token with a random kid costs 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, EC and OKP keys are read: a symmetric oct key in a public set is a secret anyone can read and is never used. A key must be meant for signatures (use absent or sig, key_ops absent or allowing verify), its alg, 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

RequestEnforced
The route itselfYes
HEAD answered by a GET routeYes, it is that route
OPTIONS and 405 from the route tableNothing runs; they disclose only the methods
Router.Mount, Router.Static, Router.Frontend beneath the routerYes, they inherit its declaration
/openapi.json and /docsYes, 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

SituationStatusChallenge
No credential401Bearer realm="api"
A credential that is not valid401Bearer realm="api", error="invalid_token"
A valid credential without a required scope403Bearer realm="api", error="insufficient_scope", scope="orders:write"
No key set fetched yet, or a lookup failed503none

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.AfterDependencies would 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.

Open source under MIT / Apache-2.0.Built on net/http, and nothing else.