Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

StrategyHow it wiresProven by
Nonethe API type stores BaseInterface directly; endpoints Auth::NONEa public endpoint
Bearer tokenconfig AuthConfigExt::bearer(…); default scheme Tokengrok
Bearer, named tierthe same scheme at a role: api.as_role("…").flow::<Token>()grok (management)
Basicconfig AuthConfigExt::new(…); default scheme Basicicecast
API key in a custom headerconfig AuthConfigExt::new_with_auth_header(…, HeaderName); default scheme Tokengoogle_places
API key in the query stringa scheme of your own writing AuthEntry::query_param — the worked example belowderive from the example
Custom scheme prefixconfig AuthConfigExt::with_placement(…, AUTHORIZATION, "X "); default scheme Tokenderive from the above
Fully custom valuewrite the store directly: config.auth_store().set_auth_entry(…), or a hand-written schemederive 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 renewalpaychex → this chapter’s flow note
Cookie loginconfig CookieConfigExt::new(…).session_auth("sessionid"); #[cookie_auth(endpoint = <Path>)] on the login carrier→ this chapter’s flow note
OAuth2 / OIDC / JWT, vendor-bridgedcapi_auth_oauth2 / capi_auth_jwt schemes reached through flow::<S>(), plus a vendor crate’s config bridgegoogle_placesOAuth2, OIDC, and JWT
OAuth2 as the default schemethe client implements ConfigOAuth2Grant itself and stores the grant’s owning handle (Oauth2Flow<BaseInterface<_, _>, OAuth2JwtBearer>) as its API fieldringcentralOAuth2, 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 BaseInterfaceaws_s3, azure_tablesRequest 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 the Token scheme store Authorization: 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 AuthEntry yourself and write it with config.auth_store().set_auth_entry(tier, entry).

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’s authenticate(...) takes the carrier’s body fields in declaration order, builds the carrier, converts it into the endpoint through Into (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_token hands the decoded token back as well as storing it, and refreshable makes the credential one the store can date and renew. Under refreshable the derive also emits a Refresher that re-runs the exchange, and authenticate returns an Authenticated<_> whose .auto_refresh() seats it — so the call is spelled api.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> plus ExpiringToken (the credential’s own lifetime) for a refreshable one. paychex derives 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, from capi_cookies rather than the derive — makes the carrier a login scheme: authenticate(...) runs a request whose Set-Cookie response populates the cookie jar (the config must implement HasCookieJar), storing no header at all. It emits the same handle shape, so it is named in auth.rs and reached through flow exactly 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 adds csrf = "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 keep Auth::DEFAULT and only the login — which cannot present the session it establishes — declares Auth::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.