The Auth Model: Credentials vs Usage
Authentication in Capi rests on one separation: getting a credential and using one are different concerns. An endpoint declares that it needs a credential; a credential store holds the actual secret; and the traversal’s seal places the stored secret into each request that asked for it. Keep those three apart and every auth strategy in the next two chapters is a variation on the same machine.
The two halves
- Usage is declarative and lives on the endpoint. An endpoint’s
auth()says which credential slot this request draws from — nothing about what the secret is. - Credentials are runtime state and live on the config, in an
AuthStore. You put a secret in once (api.authenticate("…")); it stays there and rides every request that declared the matching tier.
Because they’re separate, the same endpoint definition works before and after you authenticate, works with a key from an env var or a vault, and can target a second credential tier without any change to its body or decoder.
AuthStore, keyed by AuthKey
The store lives in capi_authentication and is re-exported at
capi_core::authorization and in the prelude. It is a small, Arc-backed,
cloneable map from an AuthKey to a credential:
pub enum AuthKey {
Default, // the primary credential
Named(Arc<str>), // a second (or third) tier, e.g. "management"
}
You rarely touch AuthStore directly — the scheme’s authenticate(...) does it
for you — but the write underneath the Token scheme is:
store.set_token(AuthKey::Default, api_key); // dressed with the store's default placement
store.set_token("management", other_key); // `impl Into<AuthKey>`: &str → Named
set_token stores a bare token dressed with the placement the config declared
once — written into the store’s default_header_name behind its
default_prefix, which AuthStore::new().with_default_prefix("Bearer ") and
.with_default_header_name(..) set, and which AuthConfigExt::bearer and
with_placement build for you. The token handed in later needs no knowledge of
the placement. The undressed write is set_auth(key, header_value), which
stores the value as the whole header; both accept impl Into<AuthKey>, so a
&str, String, or Arc<str> becomes a Named tier automatically. The stored
token is marked sensitive on insertion, so it’s redacted in logs and Debug
output. Lookups use entry(&key) / contains(&key) — there’s no get — and
remove_auth(&key) / clear() are logout: they drop the entry and any renewal
seat with it.
A scheme prefix is stored beside its token, not inside it
A credential written into Authorization usually carries a scheme name in front of
it — Bearer <token>. The store keeps those two apart:
store.set_auth_entry(
AuthKey::Default,
AuthEntry::prefixed(AUTHORIZATION, "Bearer ", token),
);
The prefix is joined to the token verbatim when the credential is injected, so it
owns its own separator: "Bearer " includes the space, "xai-client-secret."
includes the dot. Keeping them apart is what lets an endpoint present the same secret
somewhere else under a different dressing (below), and it sharpens redaction — the
scheme stays readable in Debug output while the token is replaced.
A credential can occupy more than one place
What the store holds under a key is an AuthEntry: an ordered set of
placements, each one an AuthPart. set_auth builds the common case — a single
header, the store’s default_header_name — and set_auth_with_header picks a
different header for that one entry. Services that split a credential across
several headers, that take an API key as a query parameter, or that expect it as a
WebSocket subprotocol entry build the entry themselves and store it with
set_auth_entry:
// An app id beside an app key, both required on every call.
store.set_auth_entry(
AuthKey::Default,
AuthEntry::new(HeaderName::from_static("x-app-id"), app_id)
.and_header(HeaderName::from_static("x-app-key"), app_key),
);
// An API key the service wants in the query string.
store.set_auth_entry(AuthKey::Default, AuthEntry::query_param("key", api_key));
The seal applies every part of the matched entry: headers are inserted with their prefix and token joined, and query parts are percent-encoded and appended to whatever query the request already carries. Every token is sensitive regardless of where it lands — but a query credential ends up inside the URL, which proxies and access logs record verbatim, so prefer a header whenever the service accepts one.
An entry is stored and replaced as a whole, so a re-minted multi-part credential is never observed half-applied: a request either carries all of the old credential or all of the new one.
What else an entry can be
Placements are the common case, not the whole of it. An AuthEntry can also:
- Be a presence.
AuthEntry::presence()has no parts at all: it says the credential exists, and another mechanism presents it — the session cookie a jar writes at theLateseat. A required declaration resolves against it and the seal writes nothing, which lets an endpoint riding such a credential declare its tier honestly instead ofAuth::NONE. - Expire, and be renewed.
AuthEntry::minted(token, &ctx)stamps an expiry from the context clock and the credential’s own lifetime;expiring_at(..)sets one by hand;store.expires_at(&key)reads it back. A store can hold a seat for the key —seat_refresher/unseat_refresher/has_refresher— and the seal’s resolution renews a stale seated credential at the moment of use (Freshness::Ensure, the default;Skipplaces it as it stands). A failed renewal aborts the request even underoptional(): optional describes tolerable absence, and a credential whose upkeep broke is not absent.store.refresh(&ctx, &key)drives the seat by hand; the OAuth2 chapter shows how a flow seats one. - Carry key material.
AuthEntry::from_key_material(..)andwith_key_material(..)hold the custody handle a signer signs with, beside any placed parts — AWS temporary credentials are a placed session token next to a signing key. The seal refuses a placed header that collides with a header the signer declares as its own output. - Come from a source.
AuthStore::with_source(..)/from_source(..)install anAuthSource, the pluggable backend consulted when the entry map misses: a vault, a keychain, a file. It answers afetch(ctx, key)with the same bundle the map would have held, so consumers never know the difference.
Auth: the endpoint’s declaration
An endpoint declares its need with an Auth, returned from auth():
Auth::DEFAULT // draw from AuthKey::Default (the common case; also the default)
Auth::named("management") // draw from AuthKey::Named("management")
Auth::NONE // send no credential at all
An Auth names zero or more credentials, each carrying an optional flag, so two
builder methods tune how strict a requirement is:
Auth::DEFAULT.optional() // inject the credential if present, proceed anonymously if not
Auth::named("x").required() // the default stance: fail if the credential is missing
Auth::DEFAULT.redacted() // keep the credential's shape, place REDACTED_PLACEHOLDER instead
These three stances map onto how real services treat authentication:
required()— the default. Many services expect every call to be authenticated, and for those a request with no credential shouldn’t even leave the machine — sending it just earns a401round trip.required()fails fast in that case, catching a missing token before anything goes on the wire.optional()— auth-capable but not auth-mandatory. Some services accept a credential without demanding one: send a token and you get the authenticated view, send nothing and you get the public one.optional()captures exactly that — inject the credential if the store has it, otherwise proceed anonymously. This is what makes icecast’s public-stream client work: its endpoints declare.optional(), so a call with no stored credential simply goes out unauthenticated rather than erroring.NONE— authentication must not be attached. A few endpoints should carry no credential at all, andNONEguarantees it: the auth header is left off even when a credential is present. That guarantee is the point — it stops you from leaking a token to a place it doesn’t belong. The clearest cases are endpoints that mint credentials (a login or token-exchange call has nothing to authenticate with yet), and requests to a pre-signed URL: an authenticated endpoint hands back a download location whose authorization is already baked into the URL’s signature, so the follow-up download is modeled as its ownNONEendpoint — attaching your API credential there would be both unnecessary and a needless disclosure.
There is a fourth stance for fixtures and logs. redacted() keeps the shape
of the request’s authentication — the headers and query parameters a live
placement would write, the signer’s declared outputs — with
REDACTED_PLACEHOLDER standing where each value would go; nothing is renewed
and no signer runs, so the request holds no credential at all.
RequestOverrides::redact_credentials() applies the same stance to one send,
which is how the recorder captures a request’s redacted twin — the
redaction chapter and
Fixtures and Sensitive Data.
Most endpoints never call auth() explicitly — the framework default is
Auth::DEFAULT.required() — and you override it only to reach a named tier, relax
to optional(), or opt out with NONE.
The endpoint also says where the credential rides
Which header carries a credential is endpoint vocabulary, not credential vocabulary.
The clearest case is a realtime API reachable from a browser: a WebSocket
constructor takes a URL and a subprotocol list and nothing else, so the token has to
ride Sec-WebSocket-Protocol there — while the same service’s HTTP endpoints take
the same secret as Authorization: Bearer …, drawn from the same store slot.
Two builders on the declaration say so:
Auth::DEFAULT.via_header(X_API_KEY, "") // write the token into this header
Auth::DEFAULT.append_header(SEC_WEBSOCKET_PROTOCOL, "xai-client-secret.") // offer it in this list
via_header writes the header, replacing whatever is there; append_header extends
its comma-separated list, so a session’s static subprotocol entries stay first and the
credential lands after them. The prefix is joined to the token verbatim, exactly as a
stored prefix is — "" for a header that takes the bare token. A directive replaces
the stored credential’s own header placement for that one request, and it carries a
recipe rather than a secret: the endpoint never sees the token.
An endpoint that needs two credentials at once — an application key beside a user
token — chains .and(…), and every builder refines the requirement added most
recently:
Auth::DEFAULT.and(Auth::named("app").via_header(X_APP_ID, ""))
How a declaration becomes a credentialed request
The endpoint’s declaration reaches the seal as a marker on the request head, and the seal resolves it against the store. It is one step of the request lifecycle:
graph LR
ep["endpoint.auth()<br/><small>→ key, optional, placement</small>"]
lm["the seal<br/><small>looks up the key</small>"]
hdr["every part applied<br/><small>headers set, query appended</small>"]
ep --> lm --> hdr
The seal runs after every pipeline member and
after the rate-limit gate, with nothing between it and the wire. If the declaration
is optional() and the store has no matching entry, placement is skipped and the
request proceeds without a credential.
That position is what the whole model rests on, and it buys three things. First, secrecy: for the whole member band the request simply has no credential in it, so a logging or tracing member running in the middle can’t capture a token — there is nothing there to capture yet. Second, freshness: resolution renews a seated credential at the moment of use, so it goes stale neither during a runner’s backoff nor during the gate’s own wait. Third, an intact signature: there is no step between the seal and the wire for anything to occupy, so what a signing scheme signed is exactly what crosses the socket.
The scheme surface
Wiring auth up touches three things, each covered next:
- An auth scheme is a marker type naming a flow handle — the struct whose
inherent
authenticate(...)has that scheme’s parameters. The framework shipsTokenandBasicfor a credential handed in and stored; the auth add-ons ship the network ones;#[derive(AuthScheme)]on a carrier struct derives one that mints through an endpoint of your own. #[capi(interface)]on your API struct makes it be the interface it wraps: it derefs to the handle stored in its field — which is how the default scheme’sauthenticate(...)becomesapi.authenticate(...)— and emits the delegating interface impls.flow::<S>()on the interface reaches every other scheme, andas_role("…").flow::<S>()reaches one at a named tier.
The layer-1 header carriers
At the bottom are the credential types the Token and Basic schemes and the
add-on flows produce, all of which redact their secret in Debug and mark the
header sensitive. Each hands the store a scheme_prefix() and a token() — the
split the entry keeps:
BearerAuthHeader::new(token)→Authorization: Bearer <token>.BasicAuthHeader::new(username, password)→Authorization: Basic <base64>(behind the facade’sauth-basicfeature).AuthorizationHeader::new(prefix, value)→ the generalAuthorization: <prefix> <value>, used for custom schemes and API keys.
The config side of this — the ApiConfig::auth_store override, AuthConfigExt, and the
new_with_auth_header custom-header path — you already stood up in the config
chapter. The next chapter wires the entry point that
fills the store.