Auth Strategies: Choose Your Path
The model and the wiring are the same for every scheme;
what changes is which scheme auth.rs names and what the config declares about
placement. This chapter is the decision table, then the stored-credential schemes in
depth. Request signers (SigV4, SharedKey, Digest) and the OAuth2/JWT family are
big enough to get their own
chapters, listed here for completeness.
The decision table
| Strategy | How it wires | Proven by |
|---|---|---|
| None | the API type stores BaseInterface directly; endpoints Auth::NONE | a public endpoint |
| Bearer token | config AuthConfigExt::bearer(…); default scheme Token | grok |
| Bearer, named tier | the same scheme at a role: api.as_role("…").flow::<Token>() | grok (management) |
| Basic | config AuthConfigExt::new(…); default scheme Basic | icecast |
| API key in a custom header | config AuthConfigExt::new_with_auth_header(…, HeaderName); default scheme Token | google_places |
| API key in the query string | a scheme of your own writing AuthEntry::query_param — the worked example below | derive from the example |
| Custom scheme prefix | config AuthConfigExt::with_placement(…, AUTHORIZATION, "X "); default scheme Token | derive from the above |
| Fully custom value | write the store directly: config.auth_store().set_auth_entry(…), or a hand-written scheme | derive from the above |
| Endpoint token-exchange | #[derive(AuthScheme)] + #[auth_scheme(endpoint = <Path>, refreshable)] on the carrier — authenticate() runs a network flow and can seat its own renewal | paychex → this chapter’s flow note |
| Cookie login | config CookieConfigExt::new(…).session_auth("sessionid"); #[cookie_auth(endpoint = <Path>)] on the login carrier | → this chapter’s flow note |
| OAuth2 / OIDC / JWT, vendor-bridged | capi_auth_oauth2 / capi_auth_jwt schemes reached through flow::<S>(), plus a vendor crate’s config bridge | google_places → OAuth2, OIDC, and JWT |
| OAuth2 as the default scheme | the client implements ConfigOAuth2Grant itself and stores the grant’s owning handle (Oauth2Flow<BaseInterface<_, _>, OAuth2JwtBearer>) as its API field | ringcentral → OAuth2, OIDC, and JWT |
| Request signers (SigV4, Azure SharedKey, Digest) | a credential-free OneShotSigner / RequestSigner held as config state, the key material in the AuthStore, declared per endpoint; the API type stores BaseInterface | aws_s3, azure_tables → Request Signers and Presigning |
If your API isn’t in the table, it’s almost certainly a derivation of a row — a
custom scheme is a placement declared on the config; a second key is a named tier.
Reach for the closest row and adjust. One row has a quieter alternative: an API
that is open by default but accepts a credential can leave every endpoint on
Auth::DEFAULT.optional() with no store installed at all — the seal refuses only
a required declaration it cannot answer — which is how the GraphQL runtime’s
endpoints serve both a public schema and an authenticated one.
Bearer tokens
The common case, and the one from the wiring chapter: the config
declares AuthConfigExt::bearer(…), and the default scheme is Token. Its
authenticate(token) stores the token under AuthKey::Default with the "Bearer "
prefix beside it, and every endpoint (which defaults to Auth::DEFAULT) gets
Authorization: Bearer <token> placed by the seal, after every pipeline member
and the rate-limit gate have run.
// config.rs
config: AuthConfigExt::bearer(config),
// auth.rs
pub type GrokAuth<Client> = TokenFlow<BaseInterface<GrokConfig, Client>>;
The scheme is the same for a key sent bare: only the config’s placement differs, which is the custom-header row below.
Basic auth
The default scheme is Basic; its authenticate(username, password) stores a
BasicAuthHeader::new(username, password) in the store’s default header:
pub type IcecastAuth<Client> = BasicFlow<BaseInterface<IcecastConfig, Client>>;
BasicAuthHeader base64-encodes username:password (RFC 7617). Remember base64 is
encoding, not encryption — Basic over anything but TLS sends the credential in
effectively clear text. icecast pairs this with .optional() endpoints so a public
stream with no stored credential still succeeds.
API key in a custom header
Many APIs want the key in a vendor header (x-goog-api-key, x-api-key) rather
than Authorization. Point the auth layer at the header in the config, with
AuthConfigExt::new_with_auth_header:
AuthConfigExt::new_with_auth_header(config, HeaderName::from_static("x-goog-api-key"))
The default scheme is still Token: the stored value is the raw key (no prefix),
written into that configured header. google_places does exactly this. The
endpoint side is unchanged — it still just declares Auth::DEFAULT; only where
the credential lands differs.
Writing your own scheme
Nothing in the seam is reserved for the framework’s crates. A scheme is a marker,
a handle holding the interface and a tier, the handle’s AuthFlow construction,
the plumbing macro, and the inherent authenticate the scheme wants; a scheme
that needs something from the config declares its own trait for it, the way the
OAuth2 and JWT add-ons do. The worked example is an API key that rides in the
query string — the one placement the header schemes don’t cover — with the
parameter name coming from the config:
/// What the scheme asks of a config: the parameter the key rides in.
pub trait QueryKeyConfig: HasAuthStore {
fn key_parameter(&self) -> &'static str {
"key"
}
}
/// The scheme: `api.flow::<QueryKey>()` hands back a `QueryKeyFlow`.
pub struct QueryKey;
pub struct QueryKeyFlow<I> {
inner: I,
tier: AuthKey,
}
impl<I> AuthFlow<I> for QueryKeyFlow<I> {
fn build(inner: I, tier: AuthKey) -> Self {
Self { inner, tier }
}
}
impl AuthScheme for QueryKey {
type Handle<I> = QueryKeyFlow<I>;
}
capi_base_interface::impl_flow_handle!(QueryKeyFlow<I> { inner, tier });
impl<I: InterfaceRef> QueryKeyFlow<I>
where
I::Config: QueryKeyConfig,
{
/// Stores `key` so every request carries it as a query parameter.
pub fn authenticate(&self, key: impl Into<String>) {
let config = self.inner.base().config();
config.credential_store().set_auth_entry(
self.tier.clone(),
AuthEntry::query_param(config.key_parameter(), key.into()),
);
}
}
The store places the query parameter the same way it places a header, so the
endpoint side is unchanged — it still declares Auth::DEFAULT. Prefer a header
whenever the API offers one: query-string secrets leak into logs and proxies. An
API whose only credential is that key stores
QueryKeyFlow<BaseInterface<Config, Client>> as its default scheme, built with
into_flow::<QueryKey>(), and api.authenticate("…") is the method above.
A scheme that performs I/O — a token exchange, a login — writes its
authenticate once per lane, gated on the same predicate every add-on spells;
capi_auth_jwt’s JwtBearerFlow is the reference. A scheme whose parameters
should come from a struct’s fields, the way the endpoint-minting derive works,
is a proc macro, and capi_macro_support carries the lane primitives and the
handle template it emits.
Custom placements
Two escape hatches cover the long tail without a scheme of your own:
- A scheme prefix.
AuthConfigExt::with_placement(config, AUTHORIZATION, "Token ")makes theTokenscheme storeAuthorization: Token <value>; the prefix owns its separator. - A fully custom value. When the credential needs computation the placement
model doesn’t cover — several headers at once, a query parameter beside a header —
build the
AuthEntryyourself and write it withconfig.auth_store().set_auth_entry(tier, entry).
Network and cookie flows (a pointer)
Two schemes don’t take a static secret at all:
-
#[derive(AuthScheme)]with#[auth_scheme(endpoint = <Path>)]turns the carrier struct into a token-exchange scheme: its handle’sauthenticate(...)takes the carrier’s body fields in declaration order, builds the carrier, converts it into the endpoint throughInto(the identity when the carrier is the endpoint,endpoint = Self), sends it, decodes a token from the response, and stores it at the handle’s tier. Two options shape the output:returns_tokenhands the decoded token back as well as storing it, andrefreshablemakes the credential one the store can date and renew. Underrefreshablethe derive also emits aRefresherthat re-runs the exchange, andauthenticatereturns anAuthenticated<_>whose.auto_refresh()seats it — so the call is spelledapi.authenticate(id, secret)?.auto_refresh(), and a seated credential found near expiry is renewed at the moment of use. The mode decides the token type’s bound:Into<HeaderValue>for a plain scheme,Into<AuthEntry>plusExpiringToken(the credential’s own lifetime) for a refreshable one.paychexderives its client-credentials exchange this way on the endpoint itself:#[capi] #[derive(Clone, WireModel, ApiEndpoint, AuthScheme)] #[auth_scheme(endpoint = CreateBearerToken, refreshable)] pub struct CreateBearerToken { #[endpoint(location = "body")] pub client_id: String, #[endpoint(location = "body")] pub client_secret: String, }The generic machinery underneath OAuth2’s grants is the next chapter; reactive renewal on a
401(api.refresh_on_unauthorized()) and renewal from a background task (BackgroundRefresh) are orthogonal to the scheme and covered there. -
#[cookie_auth(endpoint = <Path>)]— a different attribute, fromcapi_cookiesrather than the derive — makes the carrier a login scheme:authenticate(...)runs a request whoseSet-Cookieresponse populates the cookie jar (the config must implementHasCookieJar), storing no header at all. It emits the same handle shape, so it is named inauth.rsand reached throughflowexactly like the others; it lives with the cookie jar because that is the capability that gives it meaning. A service that pairs the session with a CSRF token addscsrf = "X-CSRF-Token": the login’s decoded output is stored under that header, so the cookie and the token both ride every later request. On the config side,CookieConfigExt::new(base).session_auth("sessionid")wraps the cookie layer in an auth layer whose store reports that cookie’s presence to the seal, so the endpoints the session protects keepAuth::DEFAULTand only the login — which cannot present the session it establishes — declaresAuth::NONE.
Both reuse the same two-file wiring; only the scheme and what authenticate does
change.
Secrets in carriers and payloads
A derived carrier is a real struct, constructed on every authenticate call, and
under refreshable the refresher keeps one for every renewal after that — so a
live client secret sits in it, and any error path or member that debug-formats
the endpoint would print it. Every carrier therefore hand-writes a Debug that
prints [REDACTED] for the secret field. The payload the exchange returns is
the developer’s to read, so it stays public and readable; its token field is
marked #[wire(sensitive)], which keeps it out of logs and recorded fixtures
without hiding it from the code that uses it. That is the family’s
carrier-versus-payload rule: privacy and zeroization for a value the framework
consumes, a redacting Debug and sensitive for one the developer keeps. The heavyweight credential machinery — OAuth2 grants, PKCE, OIDC discovery,
JWT assertions — is the next chapter; request signers are
the one after.