Security & Authentication¶
See also:
routepackage on pkg.go.devRunnable demos:
examples/adapters-nethttp-security·examples/adapters-chi-security·examples/adapters-mqtt-security·examples/adapters-mqtt5(Demo 3: message-level; Demo 3b: connect-levelSecuredClient)
api/mcphas NO security methods by deliberate, permanent design — MCP security is handled by the host application (its own OAuth flow, or transport-level auth on the SSE/stdio transport itself), not by go-codex. This is the one place go-codex's security model is intentionally asymmetric with REST/events/reqreply.
go-codex documents security requirements in the spec and provides declarative hooks for runtime enforcement — the library does not import any crypto or JWT library. For REST, a security scheme is declared ONCE, directly on the route (rest.WithSecurityScheme) — there is no builder-level scheme registry — and the SAME declaration is consumed identically by both the server (Route.Register) and the client (Route.ClientHandle), so one route definition gets IDENTICAL credential-format enforcement on both ends. Runtime credential validation itself is handled by adapters: server-side via a SecurityFunc hook, client-side automatically inside nethttp.Call (see "HTTP client — CredentialFunc" below).
Connection-level vs message-level security¶
REST is stateless per-request — every nethttp.Call/incoming request IS a
fresh connection-equivalent, so there's no "connection level" distinct
from "message level" at all.
MQTT (both 3.1.1 and 5.0) is different: connections are long-lived and
stateful. The caller calls Connect() ONCE, entirely outside go-codex
(MQTTClient/pahomqtt.Client deliberately expose no connection-lifecycle
methods — go-codex never manages Connect()/Disconnect()), then reuses
that SAME client across many Subscribe/Publish/Serve/Call
invocations for the program's lifetime. This means TWO independent
security layers exist:
- Connection-level — broker ACLs (Mosquitto
acl_file, EMQX rules, etc.) tied to CONNECT-time username/password/TLS client cert, configured ENTIRELY in the caller's own connect code, before anyevents.Channel/reqreply.Routeis touched. This already works today with ZERO go-codex involvement, and is sufficient for "one connection = one identity" topologies (e.g. one IoT device, one dedicated broker connection, one CONNECT-time credential).
go-codex adds an OPTIONAL, codec-validated FORMAT check on top via
mqtt5.SecuredClient/mqtt.SecuredClient (protocol-symmetric across
MQTT 3.1.1 + MQTT5, unlike the message-level model below, since CONNECT
username/password exists in both protocol versions). Validation happens
ONCE, synchronously, at construction — never per message:
var connectBearerAuth = mqtt5.ConnectSecurityScheme{SecurityScheme: route.BasicScheme()}.
WithCodec(codex.String().Refine(validate.MinLen(8)))
client := paho.NewClient(...)
if _, err := client.Connect(ctx, &paho.Connect{
Username: "svc-account", Password: []byte(token),
}); err != nil { /* handle */ }
secured, err := mqtt5.NewSecuredClient(client, connectBearerAuth, "svc-account", token)
if err != nil { /* malformed credential — client is never used */ }
// secured is a drop-in replacement — every Subscribe/Publish/Serve/Call
// call site below is UNCHANGED from how it would look with the raw client.
mqtt5.Subscribe(ctx, secured, router, handle, fn, opts)
NewSecuredClient combines username+password into a single
"username:password" string before validating (the MQTT CONNECT packet
carries them as two separate wire fields — this combined form is a
VALIDATION-TIME representation only, never transmitted; mirrors
examples/go-edge-models/app/registry's internal.BasicAuthCodec
convention for HTTP Basic auth). On failure, returns
ConnectSecurityCredentialError and the underlying client is never
touched. *SecuredClient satisfies MQTTClient/pahomqtt.Client
transparently via Go struct embedding — no other code changes needed.
2. Message-level (WithSecurityScheme + SecurityFunc/
CredentialFunc, shipped — MQTT5-only, see below) — opt-in, per-message
codec-validated credentials, needed when ONE connection carries traffic
for MULTIPLE logical identities (e.g. a gateway relaying many devices'
messages over one shared broker connection) and broker ACLs alone can't
distinguish between them.
Both layers are independent and composable — use connection-level ACLs for
coarse-grained, per-connection authorization, and message-level
SecurityFunc/CredentialFunc for fine-grained, per-message claims within
a single shared connection. Neither requires the other.
Security schemes (REST)¶
import (
"github.com/DaniDeer/go-codex/api/rest"
"github.com/DaniDeer/go-codex/route"
"github.com/DaniDeer/go-codex/validate"
)
// Declare each scheme ONCE as a shared value — Go's ordinary "declare once,
// reference everywhere" idiom, no builder-level registry needed.
var bearerAuth = rest.SecurityScheme{
SecurityScheme: route.BearerScheme("JWT"),
}.WithCodec(codex.String().Refine(validate.BearerToken)) // format check before SecurityFunc/before send
var apiKeyAuth = rest.SecurityScheme{
SecurityScheme: route.APIKeyScheme("X-API-Key", "header"),
}
b := rest.NewBuilder(rest.Info{Title: "User API", Version: "1.0.0"})
// Attach the scheme directly to every route that needs it, via WithSecurityScheme.
createUser, _ := rest.NewRoute[CreateUserReq, User]("POST", "/users",
reqCodec, respCodec,
rest.RouteMeta{
OperationID: "createUser",
Security: []route.SecurityRequirement{route.Require("bearerAuth", "write:users")},
},
rest.WithSecurityScheme("bearerAuth", bearerAuth),
).Register(b)
Builder.OpenAPISpec() aggregates components.securitySchemes automatically from every registered route's own WithSecurityScheme declarations — no separate builder-level step needed. When two routes declare the same scheme name with different values, the last-registered route wins (no error); define the scheme once as a shared value (as above) to avoid relying on this.
Built-in scheme constructors:
route.BearerScheme("JWT") // Authorization: Bearer <token>
route.BasicScheme() // Authorization: Basic <base64>
route.APIKeyScheme("X-API-Key", "header") // header-based API key
route.APIKeyScheme("api_key", "query") // query param API key
route.OAuth2Scheme(route.OAuthFlows{...}) // OAuth 2.0
route.OpenIDConnectScheme("https://.../.well-known")// OIDC discovery
Global and per-route security¶
Builder.AddGlobalSecurity/RouteMeta.Security answer "which routes require auth" — a SEPARATE, unchanged concern from WithSecurityScheme ("what does a named scheme look like"):
// Global security — applies to all operations by default.
b.AddGlobalSecurity(route.Require("bearerAuth"))
// Per-route override — nil inherits global; empty slice = no auth required.
// bearerAuth is the SAME shared SecurityScheme value from the section above.
createUser, _ := rest.NewRoute[CreateUserReq, User]("POST", "/users",
reqCodec, respCodec,
rest.RouteMeta{
OperationID: "createUser",
// Requires bearerAuth with write:users scope
Security: []route.SecurityRequirement{
route.Require("bearerAuth", "write:users"),
},
},
rest.WithSecurityScheme("bearerAuth", bearerAuth),
).Register(b)
// Explicitly public — empty slice overrides global security
publicRoute, _ := rest.NewRoute[struct{}, Info]("GET", "/health",
codex.Empty, infoCodec,
rest.RouteMeta{
Security: []route.SecurityRequirement{}, // no auth required
},
).Register(b)
Security requirement shapes — single, OR, AND, opt-out, inherit¶
route.SecurityRequirement is map[string][]string (scheme name → required
OAuth2 scopes) — a single map can hold MULTIPLE scheme names as keys, and
RouteMeta.Security is a SLICE of these maps. This gives two independent
axes of combination:
- Within one map: every key (scheme name) must be satisfied together — AND.
- Across the slice: any one map fully satisfied is enough — OR.
route.Require(name, scopes...) (used throughout this doc so far) is a
convenience that only ever builds a SINGLE-KEY map — reach for the map
literal directly whenever a requirement needs more than one scheme name at
once (the AND case below). Every scheme name referenced ANYWHERE in
Security still needs a matching rest.WithSecurityScheme(name, ...)
declaration on the same route for its Codec to be enforced — a
referenced name with no matching declaration still gates
SecurityFunc/CredentialFunc invocation correctly, but gets no
credential-FORMAT validation (matches the "no adapter enforces a
SecurityScheme unless explicitly declared" convention used throughout this
doc).
1. Single scheme required (the baseline shown above):
rest.RouteMeta{Security: []route.SecurityRequirement{route.Require("bearerAuth", "write:users")}},
rest.WithSecurityScheme("bearerAuth", bearerAuth),
2. OR — either scheme suffices (two elements in the slice; each
route.Require(...) call already produces one single-key map, so calling
it twice at the slice level is all that's needed):
rest.RouteMeta{
Security: []route.SecurityRequirement{
route.Require("bearerAuth"),
route.Require("apiKey"),
},
},
rest.WithSecurityScheme("bearerAuth", bearerAuth),
rest.WithSecurityScheme("apiKey", apiKeyAuth),
3. AND — both required together (one map with multiple keys — build
the literal directly, since route.Require only ever builds a single-key
map):
rest.RouteMeta{
Security: []route.SecurityRequirement{
{"bearerAuth": nil, "apiKey": nil},
},
},
rest.WithSecurityScheme("bearerAuth", bearerAuth),
rest.WithSecurityScheme("apiKey", apiKeyAuth),
4. Mixed — OR of ANDs ("(bearerAuth AND apiKey) OR (oauth2 with a scope)"):
rest.RouteMeta{
Security: []route.SecurityRequirement{
{"bearerAuth": nil, "apiKey": nil},
{"oauth2": {"read:users"}},
},
},
5. Explicit opt-out (empty slice, NOT nil — overrides global security for just this route):
6. Inherit global (omit Security entirely — the default):
7. Same scheme, different scopes per route (the actual reason
WithSecurityScheme and RouteMeta.Security stay two separate
declarations rather than one combined call — see the "Why two mechanisms"
note below):
// route A — needs the "profile" scope:
rest.RouteMeta{Security: []route.SecurityRequirement{route.Require("bearerAuth", "profile")}},
rest.WithSecurityScheme("bearerAuth", bearerAuth), // SAME shared value
// route B — needs the "admin" scope:
rest.RouteMeta{Security: []route.SecurityRequirement{route.Require("bearerAuth", "admin")}},
rest.WithSecurityScheme("bearerAuth", bearerAuth), // SAME shared value, different required scope
Why two mechanisms, not one: WithSecurityScheme answers "what does
scheme X look like" — a 1:1 mapping (name → shape), reusable across routes.
RouteMeta.Security answers "which combination of schemes does THIS
operation need" — an N:M relationship (routes → AND/OR combinations of
scheme names + scopes), as shapes 2–4 above demonstrate. Folding them into
one declaration would lose that combinatorial expressiveness — there would
be no way to say "requires bearerAuth AND apiKey together" or "requires
bearerAuth with scope A on this route but scope B on that one" without
duplicating the entire scheme definition per combination.
Runtime enforcement (nethttp / chi adapters)¶
nethttp.Register(mux, createUser, handler, nethttp.Options{
// SecurityFunc is called after Codec format validation passes.
// Receives the *http.Request and the route's declared security requirements.
SecurityFunc: func(ctx context.Context, r *http.Request, reqs []route.SecurityRequirement) error {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
return jwtlib.VerifyScopes(token, reqs)
},
})
The adapter enforcement sequence:
1. Extracts the raw credential per scheme type (Authorization: Bearer <token>, X-API-Key: <key>, etc.)
2. Validates it via SecurityScheme.Codec → returns rest.SecurityCredentialError + 401 on failure
3. Calls SecurityFunc → returns rest.SecurityError + 401 on rejection
Routes with nil Security (default) trigger enforcement when global security is set.
nethttp.Call (client-side) runs the SAME sequence, symmetrically, on the OUTGOING request before it is sent — see "HTTP client — CredentialFunc" below.
Credential format validation¶
Use validate constraints to validate raw credential strings before SecurityFunc runs:
// Bearer token: non-empty, no leading/trailing whitespace
codex.String().Refine(validate.BearerToken)
// UUID v4 API key
codex.String().Refine(validate.UUID)
// Non-empty API key
codex.String().Refine(validate.NonEmptyString)
HTTP client — CredentialFunc¶
For the nethttp.Call client, provide credentials via CredentialFunc:
user, err := nethttp.Call(ctx, http.DefaultClient, serverURL, handle, req, nil,
nethttp.CallOptions{
CredentialFunc: func(ctx context.Context, reqs []route.SecurityRequirement) (http.Header, error) {
h := make(http.Header)
h.Set("Authorization", "Bearer "+getToken(ctx))
return h, nil
},
})
CredentialFunc is called only when the route declares security requirements. For static credentials, use CallOptions.ExtraHeaders instead.
Symmetric credential-format validation. If handle was built from a route
declaring rest.WithSecurityScheme(name, scheme) with a non-nil Codec —
whether via Route.Register or Route.ClientHandle, same declaration either
way — nethttp.Call validates CredentialFunc's returned header against that
SAME Codec before sending, reusing the identical extraction/validation logic
the server Handler uses on an incoming request. A malformed credential
returns rest.SecurityCredentialError locally, before any network call, and
fires stats.SecurityObserver.RecordSecurityRejection — catching a
CredentialFunc bug immediately instead of after a round trip and a generic
401.
This does NOT require CredentialFunc to be non-nil on a secured route, and
the check only fires when CredentialFunc actually returns something: a nil
CredentialFunc, or one that deliberately returns (nil, nil) to mean "this
call needs no credential" (e.g. an auth flow that first probes whether the
specific server instance requires auth at all — see
examples/go-edge-models/app/registry's newAuthCredentialFunc), remains
a deliberate non-error. The request is simply sent without the credential,
and it's up to the server to accept or reject it — symmetric with server-side
SecurityFunc.
Caching a CredentialFunc¶
Re-authenticating on every Call is wasteful when the underlying inner
credential fetch is itself an HTTP round trip (e.g. an OAuth2 token
endpoint, or examples/go-edge-models/app/registry's registry token
exchange). nethttp.NewCachingCredentialFunc wraps any CredentialFunc
with TTL-based caching:
credFn, invalidate := nethttp.NewCachingCredentialFunc(inner, nethttp.CachingCredentialFuncOptions{
TTL: time.Hour,
})
inneris invoked at most once per TTL window; concurrent callers during a cache miss share the SAME in-flight call (hand-rolled single-flight — no thundering herd on the auth server, no external dependency).- The returned
invalidate func()immediately expires the cached credential.NewCachingCredentialFuncdoes NOT know when a credential is rejected — the server only reveals that via a 401 response, which is observed byCall, not byCredentialFunc(which runs before the network call). WireinvalidatetoCallOptions.OnCredentialRejectedand retry once, explicitly:
callOpts := nethttp.CallOptions{
CredentialFunc: credFn,
OnCredentialRejected: invalidate, // purges the cache; does NOT retry
}
resp, err := nethttp.CallHandle(ctx, client, baseURL, handle, req, callOpts)
var statusErr nethttp.UnexpectedStatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized {
resp, err = nethttp.CallHandle(ctx, client, baseURL, handle, req, callOpts) // fresh credential now
}
OnCredentialRejected is purely a notification hook — Call never retries
automatically, keeping control flow explicit and in the caller's hands.
CachingCredentialFuncOptions.Observer, when set, receives
stats.CredentialCacheObserver hit/refresh events
(RecordCredentialCacheHit/RecordCredentialCacheRefresh), each including a
location string derived from the security scheme names of the request's
[]route.SecurityRequirement.
One NewCachingCredentialFunc instance is one cache entry — construct a
separate instance per credential scope (e.g. per host/registry) when
different routes need independently-cached credentials.
Security for event channels (AsyncAPI)¶
Just like REST, an events security scheme is declared ONCE, directly on the
channel (events.WithSecurityScheme) — there is no builder-level scheme
registry — and the SAME declaration is consumed identically by both the
server (Channel.Register) and the client (Channel.ClientHandle):
var bearerAuth = events.SecurityScheme{
SecurityScheme: route.BearerScheme("JWT"),
}.WithCodec(codex.String().Refine(validate.BearerToken))
b := events.NewBuilder(events.Info{Title: "User Events", Version: "1.0.0"})
b.AddServer("production", events.Server{
URL: "broker.example.com",
Protocol: "mqtt5",
Security: []route.SecurityRequirement{route.Require("bearerAuth")},
})
userCreated, _ := events.NewChannel[UserCreated]("user/created", codec,
events.Subscribe{
Summary: "Receive user created events",
Security: []route.SecurityRequirement{route.Require("bearerAuth")},
},
events.WithSecurityScheme("bearerAuth", bearerAuth),
).Register(b)
MQTT5 adapter — the server side runs a BUILT-IN codec-based credential
check (extracting the "Authorization" MQTT5 User Property for http/oauth2/
openIdConnect schemes, or the User Property named scheme.Name for
apiKey schemes) BEFORE the optional custom SecurityFunc — same two-step
order as nethttp.Handler:
mqtt5.Subscribe(ctx, client, router, userCreated, handler, mqtt5.SubscribeOptions{
SecurityFunc: func(ctx context.Context, msg *paho.Publish, reqs []route.SecurityRequirement) error {
// Runs AFTER the built-in Codec check passes — add extra business
// logic here (e.g. a database revocation check) if needed.
return checkNotRevoked(msg, reqs)
},
})
The client (publish) side is symmetric: PublishOptions.CredentialFunc
supplies the credential as MQTT5 User Properties, and the SAME built-in
codec check runs BEFORE the message is actually published — mirroring
nethttp.Call's CredentialFunc handling exactly, including the
"a CredentialFunc returning (nil, nil) for 'no credential needed' is not
an error" contract:
mqtt5.Publish(ctx, client, userCreated, 1, false, event, nil, mqtt5.PublishOptions{
CredentialFunc: func(ctx context.Context, reqs []route.SecurityRequirement) ([]mqtt5.UserProperty, error) {
token, err := fetchToken(ctx)
if err != nil {
return nil, err
}
return []mqtt5.UserProperty{{Key: "Authorization", Value: "Bearer " + token}}, nil
},
})
MQTT 3.1.1 (adapters/mqtt) and ZeroMQ pub/sub have no per-message metadata
channel — Codec-level extraction (and CredentialFunc) only applies to
MQTT5; use SecurityFunc + a closure over connection-time credentials for
runtime enforcement on those transports instead.
Security for request-reply routes (reqreply)¶
api/reqreply (MQTT5 only — adapters/zeromq's reqreply Call/Serve have
no security mechanism today, since ZeroMQ carries no per-message metadata;
documented future gap) mirrors the exact same declare-once,
enforce-symmetrically model:
var bearerAuth = reqreply.SecurityScheme{
SecurityScheme: route.BearerScheme("JWT"),
}.WithCodec(codex.String().Refine(validate.BearerToken))
var ComputeRoute = reqreply.NewRoute[ComputeReq, ComputeResp](
"compute/add", computeReqCodec, computeRespCodec,
reqreply.RouteMeta{Security: []route.SecurityRequirement{route.Require("bearerAuth")}},
reqreply.WithSecurityScheme("bearerAuth", bearerAuth),
)
Server (Serve) — built-in codec check first, then the optional custom
SecurityFunc:
mqtt5.Serve(ctx, client, router, handle, fn, mqtt5.ServeOptions{
SecurityFunc: func(ctx context.Context, msg *paho.Publish, reqs []route.SecurityRequirement) error {
return checkNotRevoked(msg, reqs)
},
})
Client (Call) — CredentialFunc supplies the credential, validated
client-side before the request is published:
resp, err := mqtt5.Call(ctx, client, router, handle, req, mqtt5.CallOptions{
CredentialFunc: func(ctx context.Context, reqs []route.SecurityRequirement) ([]mqtt5.UserProperty, error) {
token, err := fetchToken(ctx)
if err != nil {
return nil, err
}
return []mqtt5.UserProperty{{Key: "Authorization", Value: "Bearer " + token}}, nil
},
})
Builder.AddGlobalSecurity(reqs...) and per-route RouteMeta.Security
(nil=inherit global, empty=no auth) work identically to REST/events.
reqreply.SecurityCredentialError/reqreply.SecurityError are the
request-reply analogues of REST's error types — same fields, same
errors.As/slog.LogValuer shape.
SecurityObserver — rejection metrics¶
Implement stats.SecurityObserver on your observer to receive rejection events. Adapters type-assert it — no breaking change to the Observer interface:
type TelemetryObserver struct {
stats.NoopObserver // embed for Observer methods
}
func (o *TelemetryObserver) RecordSecurityRejection(location, scheme string) {
// location = route path (HTTP) or topic (MQTT)
// scheme = first declared security scheme name for the operation
metrics.SecurityRejections.WithLabelValues(location, scheme).Inc()
}
OpenAPI / AsyncAPI output¶
Security schemes appear in components/securitySchemes; global security at document root (REST only — AsyncAPI 3.0 has no document-level global security field); per-operation security overrides inline — all generated automatically from WithSecurityScheme (route/channel-level for REST/events/reqreply — the ONLY declaration mechanism for all three; aggregated by Builder.OpenAPISpec/Builder.AsyncAPISpec, last-registered-wins on name collision) / AddGlobalSecurity / RouteMeta.Security / Subscribe.Security / Publish.Security. No manual YAML needed.
See also¶
- Guide: HTTP Server —
SecurityFuncwiring in the adapter - Guide: HTTP Client —
CredentialFuncfor client-side credentials - Guide: Observer —
SecurityObservermetrics - examples/adapters-nethttp-security — bearer JWT + scopes + observer