Welcome

Axess authenticates users and non-human callers in Axum applications: a session layer, factor verification, cookies, policy evaluation. Three decisions make it different from the alternatives.

A session is never half logged in. Authentication state is a typed enum, and a partially-completed login is its own variant (Authenticating), not an Authenticated session with a flag unset. A handler cannot mistake one-factor for finished, because the type does not allow it.

No clock or RNG is read directly. Session ids, OTP windows and lockout expiry all go through a Clock or SecureRng trait: the system in production, a controlled sequence in tests. A whole login flow, side effects included, runs in a unit test with no infrastructure and no flakes. That is what makes a token-issuance race a failing test rather than a postmortem.

Authorisation is Cedar policy, not scattered role checks. Declarative, schema-validated, deny-by-default, in policy files rather than handlers. Your code asks and gets an AuthzDecision, so reviewing the rules is one artifact review instead of a hunt across handlers.

Ten small crates, so an adapter you do not use is an adapter you do not compile. The split also draws the line that matters most: per-credential algorithms in axess-factors, the state machine and federation machinery in axess-core. The next chapter covers it.

What axess is not

  • Not a SaaS. No hosted control plane, and your application keeps owning its user data.
  • Not an Identity Provider, in its primary use. In OAuth and OIDC terms axess is the Relying Party: it delegates identity to an external IdP and runs a session on the resulting tokens. Point it at Keycloak, Ory Hydra, Okta, Entra ID or whatever you already run. Later chapters use RP and OP for those two roles. The local-idp feature does mint workload JWTs in-process, but that is service-to-service issuance, not a user-facing OP.
  • Not an HTTP server. Axum is; axess is a Tower layer plus extractors, and your code owns the lifecycle.
  • Not a general-purpose session library. The session machinery serves the authentication state machine. If you want HTTP sessions without authentication or authorisation, smaller libraries do it better.

The workspace, in one table

The crate split is structural, and the table below is a fair approximation of which one you reach for in any given situation. The chapter Architecture at a glance expands on the dependency direction and the rules that keep leaf crates from depending on the orchestrator.

CrateRole
axessFacade. Re-exports the public API. Application code depends on this.
axess-coreSession state machine, AuthnService, AuthzStore, federation adapters (OAuth, OIDC, LDAP, mTLS, FIDO2, JWT, K8s SA, GitHub OIDC), device identity, OBO/delegated access, middleware, storage backends. The orchestrator.
axess-factorsPer-credential verifier primitives: Argon2id, TOTP, HOTP. Composable on their own.
axess-identityTyped IDs (UserId, TenantId, WorkloadId) and the Principal { Human, Workload } enum.
axess-eventsAudit event payloads and async sinks.
axess-cacheTTL+LRU cache with single-flight. Used by the Cedar entity cache and the OIDC JWKS cache.
axess-clockClock trait, SystemClock, MockClock. The DST time foundation.
axess-rngSecureRng trait, SystemRng, MockRng. The DST entropy foundation.
axess-stringsShortString, an immutable identifier: inline to 22 bytes, &'static str in place, or shared behind an Arc.
axess-macrosrequire_authn!, require_partial_authn!, require_authz! procedural macros.

When to reach for axess

Axess fits when at least two of these hold:

  • Multi-factor authentication that varies per user or per tenant. The most common driver: composing factors and threading the result through a typed state machine is the value over a session library.
  • Policy-driven authorisation in one language across roles, relationships and contextual conditions.
  • Multi-tenancy. Factors, methods and policies scope at three tiers (System, Tenant, User) by default.
  • Device identity, workload identity or delegated access.
  • A regulated industry. The audit trail is shaped as evidence, and there is FAPI 2.0 conformance work behind it.

It does not fit a single-factor session, a hosted IdP, or a non-HTTP protocol: the state machine is shaped against Axum extractors and middleware. Each has better answers elsewhere.

  • Evaluating? Read Architecture at a glance next: the verifier-versus-orchestrator line, the dependency direction, the three state slices in a request, and the DST mechanics underneath. Twenty minutes there saves an hour in every chapter after.
  • Integrating? Getting started walks a minimal Axum application end to end, and examples/sqlite/ is the production-shaped version with a real database, encrypted sessions, two-factor login, rate limiting, health checks and metrics.
  • Inherited an integration? The navigation is grouped by concern. Parts II and V carry the day-to-day surface; the rest is reference.
  • Deploying? Read Security posture and Operations runbook first. The defaults are conservative for development, and production has knobs that must be set explicitly. Both chapters name them.

Status

Axess is published on crates.io; that page carries the current version. The 0.x line is pre-1.0: minor versions may break source compatibility, and each break is catalogued in Migration guide. The goal post-1.0 is to maintain the SemVer discipline Rust libraries are held to elsewhere.

Vulnerability reports go through the private channel described in SECURITY.md. Please do not file security issues on the public GitHub tracker.

Architecture at a glance

Which crate owns what, how the pieces compose, and where you plug in. Four decisions carry the rest of the book: the verifier-versus-orchestrator line, the three state slices, the DST foundation, and the naming conventions. With those in mind, later chapters need no further scaffolding.

Evaluating? Read it end to end. Mid-integration? Skim, and come back when something surprises you.

Workspace shape

Axess is ten library crates plus a set of example applications. The split is not cosmetic. It enforces a structural invariant (leaf crates do not depend on the orchestrator), it gates compile cost for features adopters do not use, and it makes the verifier-versus-orchestrator line explicit in the dependency graph.

flowchart TD
  facade["axess<br/><i>facade</i>"]
  core["axess-core<br/><i>orchestrator</i>"]
  factors["axess-factors<br/><i>verifiers</i>"]
  macros["axess-macros<br/><i>guard macros</i>"]
  identity["axess-identity<br/><i>typed IDs</i>"]
  events["axess-events<br/><i>audit payloads</i>"]
  cache["axess-cache<br/><i>TTL cache</i>"]
  clock["axess-clock<br/><i>Clock trait</i>"]
  rng["axess-rng<br/><i>SecureRng trait</i>"]
  strings["axess-strings<br/><i>ShortString</i>"]

  facade --> core
  facade --> factors
  facade --> macros

  core --> factors
  core --> identity
  core --> events
  core --> cache
  core --> clock
  core --> rng
  core --> strings

  factors --> identity
  factors --> clock
  factors --> rng

  cache --> clock
  events --> identity

The axess crate is a thin facade that re-exports the curated public API from axess-core and axess-factors. Application code depends on this crate and only this crate. The internal split is free to reorganise without breaking adopters, provided the types surfaced at the facade level stay compatible.

axess-core is the orchestrator. It owns the session state machine, AuthnService, AuthzStore, the Axum middleware stack (CSRF, rate limit, request id, trace id), session storage backends, the device-identity ladder, the workload identity resolvers, and the audit dispatch. If a type drives a transition or owns persistent state, it lives here.

axess-factors holds the per-credential verifiers. The list is long because the credential surface authentication actually has is long:

  • Argon2id, TOTP, HOTP and email OTP.
  • FIDO2, LDAP bind and mTLS.
  • OAuth and OIDC, with discovery, a JWKS cache and logout-token claim validation; plus JWT validation and the PKCE helpers.
  • Federation adapters for Kubernetes service accounts, GitHub Actions and generic OAuth resource servers.
  • A bearer-token extractor and an outbound OAuth client.

The crate is composable on its own and is the obvious extension point when you need a custom factor: implement the verifier trait, register it with the service, the rest stays the same.

Everything else in the workspace is a leaf. Each leaf crate owns one concept (typed IDs, TTL cache, the Clock trait), and depends only on other leaves on its own row of the dependency graph. The structural invariant under review is straightforward: no leaf crate may depend on axess-core. Flipping any of these to depend on the orchestrator would create a cycle through the facade and is rejected at review.

The verifier-versus-orchestrator line

The most important line in the workspace runs between axess-factors and axess-core. Per-credential algorithms and their data shapes live on the verifier side. The sum types and the composition machinery that combine them live on the orchestrator side.

This is concrete. The Fido2Config struct, the Fido2Provider trait, and the WebAuthn ceremony itself live in axess-factors. The FactorKind::Fido2 variant, the FactorConfig::Fido2(Fido2Config) wrapping, and the FactorStep::factor(FactorKind::Fido2) composition helper live in axess-core. The same pattern applies to LDAP, to OAuth, to every factor: the algorithm and its config are verifier-side, the enum variant and the composition are orchestrator-side.

The reason for the line is the kind of change each side absorbs. The verifier is the thing you might want to swap (an alternative WebAuthn library, a custom OTP scheme, an LDAP binding that reads from a sidecar rather than directly). The orchestrator is the thing you do not swap (the state machine, the audit dispatch, the storage interface) but do want to extend (add a factor, add a workflow, add a backend). Keeping the two in separate crates makes the swap and the extension into independent operations. A change in axess-factors does not invalidate orchestrator code; a change in axess-core does not touch the verifier crates.

The line also shows up in the dependency direction. axess-core depends on axess-factors, never the reverse.

The one exception that proves the rule

axess-core hosts one piece of code that does not fit the RP-side-orchestrator framing: the in-process IdP under crate::local_idp (feature local-idp, off by default). LocalIdp mints workload-identity JWTs on-host, which is OP-side issuance, not verifier composition. It lives in axess-core deliberately, not by oversight. The choice is between two costs: carve LocalIdp into a sibling crate that mirrors the verifier/issuer split at workspace shape, or accept one feature-gated OP-side module inside the orchestrator crate. The carve-out has been considered (see the ROADMAP) and rejected on the same reasoning that retired the earlier axess-delegated crate: the structural benefit is real but small, the maintenance overhead of an additional workspace member is real, and no adopter is asking for LocalIdp as a separate dependency. Adopters who do not enable local-idp pay nothing for it; adopters who do enable it find it through axess::local_idp::* regardless of which crate hosts the implementation.

The internal layout reflects the boundary even when the crate boundary does not. Primitives shared between the production [LocalIdp] and the test [LocalIdpFixture] live in axess-core/src/local_idp/primitives.rs, outside the testing/ tree, so production code does not have to import from a test module. The fixture itself stays under crate::testing::local_idp and imports the primitives, which is the dependency direction the prior arrangement got backwards.

The three state slices

Most authentication libraries conflate three independent state machines into one bag of fields and call the result a "session". Axess keeps them separate. This is not a stylistic choice; the slices answer different questions, change on different cadences, and are owned by different concerns.

flowchart LR
  subgraph auth["Authentication state"]
    direction TB
    s1["Guest"] --> s2["Identifying"]
    s2 --> s3["Authenticating"]
    s3 --> s4["Authenticated"]
    s3 --> s5["PendingWorkflow"]
    s5 --> s4
  end
  subgraph authz["Authorisation state"]
    direction TB
    a1["AuthzStore<br/><i>policies + schema<br/>(loaded once)</i>"]
    a2["AuthzSession<br/><i>per-request facade</i>"]
    a3["AuthzEntityProvider<br/><i>app-supplied graph</i>"]
    a1 --> a2
    a3 --> a2
  end
  subgraph principal["Principal state"]
    direction TB
    p1["Principal::Human"]
    p2["Principal::Workload"]
  end

Authentication state is AuthState, the session state machine covered in Part II. It transitions through factor verification, lives inside SessionData behind a cookie, and is what RequestAuthnService::verify_factor mutates. It answers the question "is this caller authenticated, and to what tier?" It changes on factor verification, which is rare in absolute terms.

Authorisation state is AuthzStore, holding the Cedar policy set and its schema, loaded once at startup. A per-request AuthzSession then evaluates those policies against an entity graph that the application supplies through an AuthzEntityProvider. It does not live in the session; it is rebuilt fresh per request. It answers the question "is this principal allowed to perform this action against this resource?" It changes when policies are redeployed, which is even rarer.

Principal state is Principal { Human | Workload }. A human principal carries a UserId and TenantId; a workload principal carries a WorkloadId. The principal is extracted from the authentication state for humans and from a workload-identity resolver (bearer JWT, mTLS, K8s service account, and so on) for non-humans. It changes on every single request.

The slices are independent because they answer different questions and change on different cadences. Treating them as one bag conflates the questions and the cadences. Keeping them apart lets each evolve without disturbing the others.

Deterministic simulation testing

Every place in axess that reads wall time or sources entropy on the hot path goes through an injected trait. This is the discipline that lets the test suite be reproducible and that lets subtle timing or ordering bugs become failing tests rather than rare incidents.

Two traits carry the foundation. Clock is the first:

pub trait Clock: Send + Sync {
    fn now(&self) -> chrono::DateTime<chrono::Utc>;
}

pub struct SystemClock;          // delegates to chrono::Utc::now()
pub struct MockClock { /* ... */ } // advances under test control

SecureRng is the second:

pub trait SecureRng: Send + Sync {
    fn fill_bytes(&self, dest: &mut [u8]);
}

pub struct SystemRng;          // delegates to getrandom
pub struct MockRng { /* ... */ } // seeded; reproduces byte sequence

A small detail matters here. SecureRng::fill_bytes takes &self, not &mut self. The mock implementation guards its internal counter with a Mutex so that Arc<dyn SecureRng> is dyn-compatible and concurrent use is serialised without forcing every call site to plumb a mutable borrow through. The trade is a single locked critical section per random fill, which is irrelevant on the authentication hot path.

The wiring matches. AuthnService<I, F> holds Arc<dyn SecureRng> and Arc<dyn Clock> as construction-time fields. The service is generic over the identity store (I) and factor store (F) but type-erased over clock and RNG, so swapping in MockRng or MockClock does not change the service's type signature. Tests do this with .with_rng(MockRng::new(seed)) and .with_clock(MockClock::default()); production wires SystemRng and SystemClock.

The same discipline extends to backends. The pattern is uniform: the production implementation talks to a real database or external service, and a Mock* implementation does the same thing in memory under test control.

TraitProduction implementationTest mock
IdentityStore / FactorStorereal databaseMockIdentityStore / MockFactorStore
SessionRegistryValkey or memoryMemorySessionRegistry
OAuthProviderHTTP plus JWKS cacheMockOAuthProvider
Fido2ProviderWebAuthn ceremonyMockFido2Provider
LdapProviderLDAP directoryMockLdapProvider
DeviceStoreSQL or ValkeyMemoryDeviceStore
DeviceResolverheader or IPRedactedResolver, NoopDeviceResolver

A complete login including session-registry interactions, factor verification, refresh-token rotation, and audit emission can be exercised in a #[tokio::test] with no database, no Valkey, no network. The same test that detects a regression on a development laptop detects it in CI without further configuration.

One carve-out is worth naming. The axess-cache crate has an opt-in moka-cache feature that runs Moka's wall-clock-driven background eviction. That feature breaks DST and is documented as breaking it. The default ClockTtlCache takes a Clock trait and is DST-clean. If your test suite runs against the default configuration, you are inside the determinism envelope.

Storage backends

Identity persistence is adopter-owned. Axess does not prescribe a user or tenant or factor schema, because every application already has one and the schemas do not agree on much. What axess does prescribe is the trait surface you implement, split into three tiers so that adopters can narrow what they have to write.

The narrowest tier is IdentityLookup, with ten read verbs. It is enough to support a read-replica path or a test fixture. The middle tier, IdentityAuthnLog: IdentityLookup, adds four per-attempt audit writes; it is required for production because lockout decisions depend on the audit log. The widest tier, IdentityAdmin: IdentityAuthnLog, adds nine verbs covering privileged provisioning, suspension, and GDPR erasure, and is required for any control-plane surface.

The umbrella alias IdentityStore: IdentityAdmin preserves the all-three-tiers shape for production backends. NoopAuthnLog is an adapter that wraps an IdentityLookup and satisfies the IdentityAuthnLog signature with a no-op, suitable for fixtures and read-replica contexts. Production must implement IdentityAuthnLog directly, however; the noop disables lockout, which is a security posture you do not want by accident.

Session, refresh-token, and device storage have first-party backends for the obvious targets:

TraitMemorySQLitePostgresMySQLValkey
SessionStorealways-onsqlitepostgresmysqlvalkey
SessionRegistryalways-on(adopter)(adopter)(adopter)valkey
RefreshTokenStorealways-onadopteradopteradopteradopter
DeviceStoredevicedevice, sqlitedevice, postgres(adopter)device, valkey
DelegatedCredentialStorealways-onadopteradopteradopteradopter

The word "adopter" means axess defines the trait and provides a memory implementation; the SQL or Valkey-backed implementation is yours. The chapter Identity store implementation walks through the pattern, and examples/sqlite/ ships a complete one.

Session backends are also re-exported through the facade under the axess::backends::{sqlite, postgres, mysql, valkey, memory} namespace. Application code writes use axess::backends::sqlite::{SessionStore, DeviceStore} rather than stitching together flat SqliteSessionStore, SqlDeviceStoreError, and similar symbols. The grouping is a facade detail; backend module paths inside axess-core are internal.

The generic Store<K, V> surface

All five session backends also implement the generic axess_core::store::Store<SessionId, SessionData> trait. Adopters who want a backend-agnostic key/value-with-TTL surface (test doubles, generic operations endpoints, multi-backend deployments) can hold an Arc<dyn Store<…>> or a generic S: Store<…> and dispatch uniformly. SessionStore stays the primary surface for session-domain operations (cycle, find_sessions_for_user) because those carry primitives the generic Store deliberately omits.

A fully codec-parameterised SqlStore<K, V, C: Codec<V>> was evaluated and rejected. The dialect-specific SQL bodies are too thin to justify the sqlx::Database bound noise: only ON CONFLICT versus ON DUPLICATE KEY UPDATE plus three placeholder styles differ. The slice that does dedupe cleanly lives in session/storage/sql_helpers.rs.

Naming conventions

A reviewer reading axess code can predict a type's responsibility from its prefix and suffix. The conventions are tight on purpose; they let you scan a module index without reading any function bodies.

Type prefixes

PrefixScopeExamples
Auth*Shared across authentication and authorisationAuthSession, AuthState, AuthEvent, AuthMethod, AuthPrincipal
Authn*Authentication onlyAuthnService, AuthnError, AuthnScope, AuthnBackend
Authz*Authorisation onlyAuthzStore, AuthzSession, AuthzDecision, AuthzError

Auth* is shared infrastructure. Authn* is what you reach for when handling a login attempt. Authz* is what you reach for when deciding whether a request may proceed. If you see a function that takes AuthSession and returns AuthzDecision, you know without opening it that it is bridging authentication state into authorisation evaluation.

Type suffixes

SuffixMeaning
*OutcomeMulti-variant result from an authentication operation (LoginOutcome, FactorOutcome, SignupOutcome)
*DecisionBinary allow/deny verdict (AuthzDecision)
*ConfigConfiguration or parameters (SessionConfig, TotpConfig, RateLimitConfig)
*StorePersistence trait or implementation (SessionStore, IdentityStore, DeviceStore)
*RegistrySession validity tracking (SessionRegistry, MemorySessionRegistry)
*ProviderExternal integration trait (OAuthProvider, Fido2Provider, LdapProvider)
*ResolverExtract typed value from a request (DeviceResolver, PrincipalResolver)
*ErrorError type (AuthnError, OAuthError, CryptoError)
*BuilderBuilder pattern (SessionConfigBuilder, AuthEventBuilder)

The conventions are not retroactive style guides. They are how the public surface is built today. New types adopt them; PR review catches violations.

Method verb conventions

VerbSemanticsExamples
get_*Lookup by primary key, deterministic, O(1)get_user(id)
find_*Search by business criteria, may scanfind_user(identifier, tenant)
load_* / save_*Deserialise / serialise persisted stateload_factor(scope, kind)
begin_* / complete_*Multi-step ceremony start / finishbegin_login(), complete_oauth_login()
verify_*Check a credential or assertionverify_factor()

If you read find_user_by_email, you know it may be O(n) and may miss. If you read get_user, you know the id was already validated and the call should succeed unless the user was deleted.

Visibility

Internal types for cross-module use within axess-core (SessionHandle, SessionInner, LoadOutcome, FinalizeOutcome) are pub(crate). The public API surface is defined by the re-exports in axess-core's lib.rs and the facade in axess's lib.rs. The default for new types is pub(crate); promotion to pub requires concrete demand.

Security invariants

Three invariants run through every part of the workspace. They are not advice; they are enforced by lints, by review, and in some cases by the type system.

#![forbid(unsafe_code)], declared at the root of all ten published crates. There is no unsafe code in axess.

Constant-time comparison for any byte-level secret check. HMAC cookie verification, TOTP code verification, OAuth CSRF state, refresh-token device binding, session fingerprint: all of these compare bytes through subtle::ConstantTimeEq. The alternative, == on bytes, leaks timing information and is rejected at review.

Zeroization on drop. Password hashes are wrapped in ZeroizedString. TOTP and HOTP shared secrets use Zeroizing. The session signing key zeroes its bytes in its Drop impl. The discipline is not perfect (an attacker with sufficient memory access can still win), but the surface is reduced.

The full production posture, including integration requirements and compliance touch-points, is in Security posture.

What lives where, in one paragraph

If you read nothing else from this chapter: state machines, storage, middleware, federation adapters, device identity, and OBO/delegated access live in axess-core. Factor algorithm primitives (Argon2id, TOTP, HOTP) live in axess-factors. Typed IDs and the principal enum live in axess-identity. Anything that delegates to time or randomness goes through axess-clock or axess-rng. Adopters depend on the axess facade; the internal split is free to reorganise behind that boundary.

Everything else is detail. The rest of the book is detail.

Further reading

  • The session state machine covers the five-state machine in full, including PendingWorkflow.
  • Factors and methods covers verifier composition, method authoring, and the scope hierarchy.
  • Cedar policy fundamentals covers policy loading, the evaluator, and the entity provider contract.
  • Session lifecycle and crypto envelope covers the cookie shape, the AES-256-GCM envelope, and fingerprint binding.
  • Contributing covers the AX-NNN policy, the DST non-negotiable, and the naming conventions tied back to this chapter.

Getting started

By the end you have a running Axum application that logs a user in with a password, holds the session in a signed cookie, and refuses a protected route until the login completes. No database: the in-memory backend is a one-trait swap away from SQLite, covered at the end and in examples/sqlite/.

Already have an Axum application? Add the dependencies in Dependencies, drop in the SessionLayer and AuthnService from The minimum viable wiring, and wire the handler from Adding password login. The rest is rationale and a tour of the production-shaped example.

Prerequisites

You need Rust 1.94.0 or later on the stable channel (the workspace MSRV), Axum 0.8.x, and a Tokio runtime in your binary (#[tokio::main] is fine). Axess does not depend on system libraries, message brokers, or external IdPs by default. The defaults are deliberately zero-infra: the in-memory session store, the in-memory backend, and the password, TOTP, HOTP, and email-OTP factors all work out of the box for development and tests.

Dependencies

The shortest functional Cargo.toml looks like this.

[dependencies]
axess = "0.7"             # facade -- depend on this, never on the internal crates
axum = "0.8"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tower = "0.5"             # transitively from axum, but listed for clarity

The defaults of the axess facade enable authz and device. Everything else is opt-in via features. For this chapter we will also turn on memory for the in-memory session store and testing for InMemoryBackend. Both are development conveniences; a deployment with a real database needs neither.

axess = { version = "0.7.0", features = ["memory", "testing"] }

The complete feature reference lives in the crate-level docs on docs.rs and is surveyed in the project's README. Per-feature chapters in this book (Backends, OAuth, and so on) state their required feature at the top.

The minimum viable wiring

Four moving pieces, in the order you wire them:

  1. The backend looks up users and verifies their factors.
  2. The session store persists session data across requests.
  3. A signing key HMAC-signs the cookie so it cannot be tampered with.
  4. AuthnService is what handlers reach for to drive the state machine.

Two Tower layers sit on top. SessionLayer reads the cookie at the start of every request, hydrates the session, and writes it back on response. client_ip::layer works out the client's address once, from the TCP peer and the proxies you say you trust, so that nothing downstream has to read a header and guess. Serve the router with into_make_service_with_connect_info::<SocketAddr>() or there is no peer for it to work from.

Here is the whole thing in one file. We will walk through each line right after.

use axess::authn::{AuditContext, AuthnService, FactorCredential, FactorOutcome, LoginOutcome};
use axess::{AuthSession, InMemoryBackend, MemorySessionStore, SessionLayer};
use axum::Json;
use axum::extract::State;
use axum::{Router, http::StatusCode, response::IntoResponse, routing::get};
use serde::Deserialize;
use std::{sync::Arc, time::Duration};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // 1. Backend -- one type implements both IdentityStore and FactorStore.
    let backend = InMemoryBackend::new().with_user_password("alice", "default", "Gnomes2+");

    // 2. Session store + 3. signing key.
    let session_store = MemorySessionStore::new();
    let signing_key: [u8; 32] = [0; 32]; // PLACEHOLDER, see "Signing keys" below.

    // 4. AuthnService -- type-erased over clock and RNG; production wires
    //    SystemClock + SystemRng.
    let service = Arc::new(AuthnService::new(backend.clone(), backend));

    // 5. SessionLayer threads the session through each request.
    let session_layer = SessionLayer::new(session_store, signing_key)
        .with_ttl(Duration::from_secs(86_400))
        .with_secure(false); // dev only -- see "Cookie security" below.

    let app = Router::new()
        .route("/", get(public_page))
        .route("/dashboard", get(protected_page))
        .route("/login", axum::routing::post(login))
        .with_state(service)
        .layer(session_layer);

    // 5. Resolve the client address once, outside everything that reads
    //    one. Without this the audit rows say `ip_source = 'unknown'` and
    //    a tenant IP policy cannot be satisfied. `loopback_only` is the
    //    right set for a dev server bound to 127.0.0.1; a deployment
    //    behind a proxy names that proxy's ranges instead.
    let app = axess::client_ip::layer(app, axess::client_ip::TrustedProxies::loopback_only());

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
    // `with_connect_info` is what puts the peer address where the
    // client-IP layer can read it. Serve without it and every request
    // arrives with no peer, so the layer has nothing to check a forwarded
    // chain against and resolves to nothing.
    axum::serve(
        listener,
        app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
    )
    .await?;
    Ok(())
}

async fn public_page() -> &'static str {
    "everyone can see this"
}

async fn protected_page(session: AuthSession) -> impl IntoResponse {
    if session.is_authenticated().await {
        (StatusCode::OK, "welcome").into_response()
    } else {
        (StatusCode::UNAUTHORIZED, "log in first").into_response()
    }
}

This compiles and runs. Visiting http://127.0.0.1:3000/ returns "everyone can see this". Visiting /dashboard returns 401, because no session is authenticated yet. Adding the login flow is the next section.

What each line is doing

InMemoryBackend::new() constructs a backend that holds users, factor configurations, and authentication-attempt logs in memory. The convenience method with_user_password seeds one user (alice, in tenant default, with the Argon2id-hashed password Gnomes2+). Production replaces this with a real backend that implements IdentityStore and FactorStore against your database. The trait surface is identical.

MemorySessionStore::new() is the trivial session backend. Session data lives in a HashMap behind an RwLock, and disappears on process exit. The first replacement is axess::backends::sqlite::SessionStore (with the sqlite feature), covered in Backends.

AuthnService::new(backend.clone(), backend) takes two arguments because the identity store and the factor store can be different types. In the in-memory case they are the same object, hence the clone. In production they typically remain the same struct (a single backend implementing both traits), again with a clone.

SessionLayer::new(store, key) constructs the Tower layer. The chained .with_ttl(86_400) sets a one-day session lifetime, and .with_secure(false) permits HTTP cookies for local development. See Cookie security below for the production setting.

AuthSession is an Axum extractor. Receiving it as a handler argument hydrates the session for the current request, and is_authenticated() returns true only when the state is AuthState::Authenticated. There are also is_guest(), is_authenticating(), and a typed .state() accessor if you want to match on the enum directly.

Adding password login

The convenience seeded by with_user_password configures a single-factor method called password. A login is two HTTP requests. The first is POST /login with a JSON body carrying the username and password. Axess transitions the session from Guest to Authenticating, verifies the password, and on success transitions to Authenticated. Every request after that carries the cookie that identifies the session, and AuthSession reads Authenticated.

use axess::authn::{AuditContext, AuthnService, FactorCredential, FactorOutcome, LoginOutcome};
use axess::{AuthSession, InMemoryBackend};
use axum::{extract::State, http::StatusCode, response::IntoResponse, Json};
use serde::Deserialize;
use std::sync::Arc;


#[derive(Deserialize)]
struct LoginForm {
    username: String,
    password: String,
}

async fn login(
    session: AuthSession,
    State(service): State<Arc<AuthnService<InMemoryBackend, InMemoryBackend>>>,
    // The audit context is an extractor: it reads the address the
    // client-IP layer resolved, plus the user agent and request id. It
    // cannot fail, so there is no rejection to handle.
    audit: AuditContext,
    Json(form): Json<LoginForm>,
) -> impl IntoResponse {
    // 0. Derive the request-scoped handle. Every call below goes through
    //    it, which is not a convention to remember: `begin_login` and
    //    `verify_factor` do not exist on the shared service.
    let service = service.with_audit_context(audit);

    // 1. Begin the login. Transitions Guest -> Authenticating.
    match service
        .begin_login(&form.username, "default", &session)
        .await
    {
        // The identifier resolved and the first factor is known. For a
        // password-only method that is `FactorKind::Password`.
        Ok(LoginOutcome::FactorRequired(_)) => {}
        Ok(LoginOutcome::InvalidCredentials) => {
            return (StatusCode::UNAUTHORIZED, "invalid credentials").into_response();
        }
        Ok(LoginOutcome::Locked { until }) => {
            return (StatusCode::FORBIDDEN, format!("locked until {until:?}")).into_response();
        }
        Ok(LoginOutcome::StepUpRequired { .. }) => {
            return (StatusCode::UNAUTHORIZED, "step-up required").into_response();
        }
        Err(e) => return (StatusCode::UNAUTHORIZED, format!("{e}")).into_response(),
    }

    // 2. Verify the password factor. Note the argument order:
    //    credential first, session second.
    match service
        .verify_factor(
            // `Password` holds a `ZeroizedString`, which wipes itself on
            // drop. `.into()` from a `String` is the conversion.
            &FactorCredential::Password(form.password.clone().into()),
            &session,
        )
        .await
    {
        Ok(FactorOutcome::Authenticated) => (StatusCode::OK, "logged in").into_response(),
        Ok(FactorOutcome::FactorRequired(next)) => {
            // Unreachable for a password-only method, but the branch matters
            // when chaining factors (password + TOTP, etc).
            (StatusCode::OK, format!("next factor: {next:?}")).into_response()
        }
        Ok(FactorOutcome::InvalidCredential) => {
            (StatusCode::UNAUTHORIZED, "invalid credentials").into_response()
        }
        Ok(FactorOutcome::Locked { until }) => {
            (StatusCode::FORBIDDEN, format!("locked until {until:?}")).into_response()
        }
        Err(e) => (StatusCode::UNAUTHORIZED, format!("{e}")).into_response(),
    }
}

Two different outcome types appear there, and the difference is the point.

begin_login returns a LoginOutcome: the answer to "can this identifier start a login at all, and what does it need first?" FactorRequired(FactorKind) says the flow is open and names the first factor. InvalidCredentials and Locked say it is not. Handle those two here rather than falling through, or the code goes on to offer a password prompt for an account that is locked or does not exist.

verify_factor returns a FactorOutcome: the answer to "did this one credential check out, and are we done?" Authenticated is the terminal success: every required factor has passed and the session is now Authenticated. FactorRequired(FactorKind) means this factor verified but another is needed; the state stays Authenticating and the variant names what comes next. InvalidCredential is a wrong password, and Locked is the lockout policy firing on this attempt.

Both calls take the session, but in different positions: begin_login(identifier, tenant, session) and verify_factor(credential, session). The credential comes first because it is the subject of the verb. Neither takes a client address: that rides on the handle, resolved once by client_ip::layer.

The branching is the whole point of the explicit state machine. There is no version of "logged in" that means "we believe one factor, you can let them in". is_authenticated() returns true only when every required factor has passed.

Wiring the login route

The minimum-viable router picks up the new handler:

let app = Router::new()
    .route("/", get(public_page))
    .route("/login", axum::routing::post(login))
    .route("/dashboard", get(protected_page))
    .with_state(service)
    .layer(session_layer);

A login flow now works end-to-end. Start the server, curl once to log in, hold the cookie, curl again to reach /dashboard.

$ curl -c jar -X POST http://127.0.0.1:3000/login \
       -H 'content-type: application/json' \
       -d '{"username":"alice","password":"Gnomes2+"}'
logged in

$ curl -b jar http://127.0.0.1:3000/dashboard
welcome

What just happened

A full request walks the following path. The numbers correspond to the wiring steps from The minimum viable wiring.

The browser sends the request with a Cookie: header carrying the session id. SessionLayer (5) extracts the cookie, verifies its HMAC signature against the signing key, looks up the session in the MemorySessionStore (2), and rebuilds the AuthState. Axum invokes the handler with the hydrated AuthSession extractor. The handler reads or mutates the session through AuthnService (4), and mutations flag the session dirty. On response, SessionLayer re-serialises the session if it is dirty, re-signs the cookie, and sets it on the response.

The state machine, the backend, the session store, and the layer are independent moving parts. Swapping the in-memory backend for a SQLite-backed one does not touch the state machine or the session store. Swapping the session store for Postgres does not touch the state machine or the backend.

Signing keys

The example uses [0; 32] as the signing key. That is fine for a five-minute demonstration. It is not fine for anything else.

In production the signing key is a 32-byte random value loaded from a secrets manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, sealed Kubernetes secrets, or your platform's equivalent). The key must be stable across process restarts; the HMAC of an existing session cookie is computed with this key, and if the key changes underneath, every existing session becomes invalid on the next request.

Rotating the signing key is supported via SessionLayer::with_previous_signing_key, which keeps the old key available for a transitional period so sessions signed with the previous key continue to validate while new sessions sign with the new one. (SessionCrypto has a similarly named with_previous_key for the at-rest envelope; they rotate different keys.) The Operations runbook walks through the rotation sequence in detail.

Setting .with_secure(false) in the example permits the cookie to be sent over HTTP, which is necessary for localhost development. In production, you terminate TLS at the edge and call .with_secure(true). The cookie will then only be sent over HTTPS. The other defaults are already production-shaped: HttpOnly is on, SameSite=Lax is set, and the cookie path is the application root.

The Cookies, fingerprinting, hijack detection chapter covers the rest of the surface: the HMAC fingerprint binding that detects when a session cookie is replayed from a different user agent, the trusted-proxy configuration that controls how X-Forwarded-For is interpreted, and the SameSite=Strict trade-off.

Going further

This chapter is deliberately the minimum. The real examples/sqlite/ extends the same shape with everything you will actually want in production:

  • A real SQLite backend: OurBackend implements IdentityStore and FactorStore over a sqlx::SqlitePool.
  • A SQLite-backed session store with AES-256-GCM encryption at rest.
  • Password plus TOTP two-factor login for a second user, self-service signup and TOTP enrollment, and a password-reset flow over email OTP.
  • Rate limiting on the auth routes, a health check on the session store, and atomic auth-attempt counters exposed at /metrics.
  • A background interval task that purges expired sessions.

Read the example, run it, compare its app.rs to the snippet in this chapter. The shape is the same; there are simply more pieces wired in.

After that, the order in which you read the rest of the book depends on your goal.

GoalNext chapter
Add a second factor (TOTP, FIDO2, OAuth)Factors and methods
Replace InMemoryBackend with your databaseIdentity store implementation
Switch the session store to Postgres, MySQL, or ValkeyBackends: SQLite, Postgres, MySQL, Valkey
Add authorisation policiesCedar policy fundamentals
Run multiple tenantsMulti-tenancy
Federated login (Google, Okta, Azure AD)OAuth 2.0 and OIDC
Workload identity for non-human callersWorkload identity overview
Production deploymentOperations runbook

Common stumbling points

A handful of failures bite first-time integrators. They are worth naming up front so the chapter that solves them is easy to find.

If your handler cannot see AuthSession, the extractor needs the layer to populate request extensions. Add use axess::AuthSession; and check that SessionLayer is in .layer(...) on the router.

If begin_login returns InvalidCredentials for a user you are sure exists, check the tenant. The example seeds alice in tenant default, and naming a different tenant gives the same InvalidCredentials as a wrong password: axess does not tell a caller that a user exists elsewhere, or that they exist at all. That makes this particular typo quiet to debug, which is the cost of not leaking tenant membership.

If sessions disappear on process restart, that is correct for MemorySessionStore. Use SqliteSessionStore, PostgresSessionStore, or ValkeySessionStore (with their respective features) for persistence. See Backends.

If you need to attach application data to a session, SessionData has a custom field for that. The size cap is 64 KiB to keep oversize cookies from becoming a DoS surface. See Session lifecycle and crypto envelope §"Custom session data".

If the user logs out, AuthSession::clear() or service.logout(&session).await resets the state to Guest, rotates the session id (defeating fixation), and clears the cookie on response.

Each of these has a dedicated chapter or section later in the book. The goal here was to get you running, not to be complete. You are running. The rest is detail.

The session state machine

AuthState is the most important type in axess. Everything that matters about an authenticated session, both for the type system and for a reviewer reading a handler, is captured by which of its five variants you are looking at. The whole library is built around the representational claim that authentication is not a boolean, not a flag column, and not a row in a sessions table that the handler reads and then trusts. It is an enum, transitions on the enum are methods on the enum, and a partial login is a distinct variant rather than a "finished" session with one field missing.

The orchestration-versus-pure split is what keeps the machine independently testable, and it is the part worth reading twice.

The five variants

The enum lives at axess-core/src/session/data.rs in the workspace. Each variant carries exactly the data its phase needs. There is no field on Authenticated for "current factor being verified" because at that point no factor is in progress, and there is no field on Guest for "tenant" because no user has been identified yet. The absence is the point.

pub enum AuthState {
    Guest,

    Identifying {
        user_id: UserId,
        tenant_id: TenantId,
    },

    Authenticating {
        user_id: UserId,
        tenant_id: TenantId,
        method_name: Arc<str>,
        remaining: Vec<FactorKind>,
        completed: Vec<FactorKind>,
        attempt_count: u32,
        last_attempt: Option<DateTime<Utc>>,
    },

    Authenticated {
        user_id: UserId,
        tenant_id: TenantId,
        authn_time: DateTime<Utc>,
        factors_completed: Vec<FactorKind>,
    },

    PendingWorkflow {
        user_id: UserId,
        tenant_id: TenantId,
        workflow: WorkflowState,
    },
}

Guest is the default. A request with no cookie, or a cookie whose session has been logged out or expired, arrives at the handler with an AuthSession whose state is Guest. There is no user identity in scope.

Identifying is the brief intermediate state for flows that prompt for a username before asking for any credential. Most applications skip it and go straight from Guest to Authenticating. It exists for the two-page login pattern where step one collects the identifier and step two collects the password, possibly with the identifier carried over a hidden form field or a short-lived intermediate token. The variant records who is being identified but says nothing about credentials.

Authenticating is where most of the action happens. The session knows six things: who it is trying to authenticate, which method is in progress, what factors are still required, what factors have already been verified this attempt, how many credential attempts have been made, and when the last attempt landed.

The method is recorded because a tenant might have several, and the choice is locked in before any factor runs. The last two fields exist because lockout decisions depend on them. A method that allows three attempts before locking the user out for fifteen minutes needs exactly this information, and putting it in the variant rather than in a side table keeps the decision local and reviewable.

Authenticated is the terminal success state. It carries the user id, the tenant id, the moment of successful authentication (for the audit trail), and the list of factors that were used. The factor list is load-bearing. A tenant policy that requires Fido2 for certain routes can check factors_completed.contains(&FactorKind::Fido2) directly, without consulting an external store.

PendingWorkflow is the variant most adopters do not initially expect and end up reaching for once they ship a real signup flow. It models the state where a user has authenticated enough to identify themselves but is in the middle of a multi-step ceremony (signup, password reset, email verification, or a custom workflow) and should not be treated as fully logged in until the ceremony completes. The variant wraps a WorkflowState that records which workflow is in progress, which step the user is on, and when the workflow started.

pub struct WorkflowState {
    pub kind: WorkflowKind,
    pub current_step: u32,
    pub total_steps: u32,
    pub initiated_at: DateTime<Utc>,
}

pub enum WorkflowKind {
    Signup,
    PasswordReset,
    EmailVerification,
    Custom(Arc<str>),
}

Custom(Arc<str>) is the extension point. If your application has a KYC flow, a hardware-key registration flow, or a multi-step recovery ceremony, you name it as a custom workflow and the session machinery treats it like the built-in kinds. The string is interned through Arc<str> because workflow names recur and the cost of repeated allocation adds up across a busy login surface.

The transition method

Factor verification is the only mutation that the state machine exposes. The transition is AuthState::advance_factor, which takes a FactorKind and a timestamp, and returns an AdvanceOutcome that tells the caller what just happened.

impl AuthState {
    pub(crate) fn advance_factor(
        &mut self,
        kind: &FactorKind,
        authn_time: DateTime<Utc>,
    ) -> AdvanceOutcome { ... }
}

pub enum AdvanceOutcome {
    NotApplicable,
    StillAuthenticating,
    Completed,
}

The visibility on the method is pub(crate), which is the choice that keeps the orchestration honest. The pure state mutation is reachable only from within axess-core. Application code never calls it directly. Instead, application code calls RequestAuthnService::verify_factor, which is the orchestrator method that locks the session, performs the factor's cryptographic verification through axess-factors, calls advance_factor on the typed state, and dispatches on the returned outcome.

The three outcomes are exhaustive. NotApplicable means the call was made against a state that does not accept factor verification (you cannot verify a factor against a Guest session, for instance). StillAuthenticating means the factor verified and more factors are required to complete the method. Completed means the final required factor for this method just passed, and the session should transition to Authenticated. The orchestration layer translates Completed into a typed Authenticated variant with the right authn_time and factors_completed, applies session id rotation to defeat fixation, and writes the session back to the store.

The orchestration split

The split between AuthState (the pure data and pure transition methods) and AuthSession (the Axum extractor with its RwLock, dirty flag, and side-effect dispatch) is a deliberate choice with two payoffs.

The first payoff is testability. Unit tests on the state machine do not need tokio, do not need RwLock, do not need a fake session store, and do not need an extractor harness. They construct an AuthState directly, call advance_factor (or one of the other pub(crate) transition methods), and assert on the resulting variant. A regression in the transition logic surfaces as a one-line test against the enum, not as an integration test against a contrived HTTP request.

The second payoff is auditability. Every orchestration side effect (id rotation, fingerprint binding, dirty-flag handling, store write-back) lives in one file (the SessionService::call() method, walked through in Session lifecycle and crypto envelope) rather than scattered across transition methods. A code review of the orchestration is self-contained; a code review of the state machine is self-contained; neither has to mentally reconstruct the other.

The pattern is worth naming because it shows up again in the runtime. Pure state machines compose cleanly with async orchestrators that hold the locks and dispatch side effects, and the two halves get reviewed and tested independently.

Where Authenticated stops being the right shape

The natural temptation when integrating axess for the first time is to treat Authenticated as the "done" state and Guest as the "not done" state, and to ignore the intermediate variants. Resist it. The intermediate variants are how axess represents real-world flows that do not fit a binary, and reaching into them lets your application behave correctly without inventing parallel state on the side.

A signup flow that captures a username and password, mints a session, and then asks the user to verify their email before granting any access should sit in PendingWorkflow { kind: EmailVerification, ... }, not in Authenticated. A handler that protects the dashboard checks is_authenticated(), which returns true only for the Authenticated variant, and the user sees the email-verification page until the ceremony completes. The variant change at completion time then transitions to Authenticated, the same handler now lets the user in, and the application does not need to model a "needs to verify email" column on the users table.

A password-reset flow follows the same pattern with WorkflowKind::PasswordReset. The user proves identity (with an email-OTP, say), the session enters PendingWorkflow, the password-reset page becomes accessible, the user submits a new password, and the session transitions back to Guest (forcing them to log in fresh with the new password). The reset page is unreachable from Guest and unreachable from Authenticated, which is correct in both directions: a not-logged-in user should not see it, and a fully logged-in user does not need it.

The pattern generalises to any post-identification ceremony. The typical question to ask is "should the user be considered fully logged in during this step?" If the answer is no, PendingWorkflow is the right variant. If the answer is yes, and you simply want the user to do something next, then Authenticated plus a flag on the user record fits better.

Logging out and identifier rotation

RequestAuthnService::logout (and AuthSession::clear, which calls into it) transitions any state to Guest. The transition is more than a state change. The session identifier is rotated, the cookie is cleared on the response, the session row is deleted from the session store, and an audit event is emitted. The combination defeats session fixation. Even if an attacker knew the session id before logout, the id changes on the next login.

The orchestration layer also rotates the session id at the transition to Authenticated, for the same reason. A user who logs in receives a new session id, distinct from any id observed while they were a guest. The cookie is reissued; the old id is unreachable on subsequent requests. The rotation is invisible to application code and lives in the orchestration; the state machine just sees the variant change.

Custom session data

Real applications need to attach data to a session that axess does not model: a preference, a feature-flag selection, a partial form draft. SessionData has a custom field for this, and the size cap is sixty-four kilobytes. The cap exists because the session is round-tripped through a cookie (or its server-side analogue), and a session that grows without bound becomes a DoS surface. Sixty-four kilobytes is enough for almost any sensible use; anything larger probably belongs in the database keyed by user id rather than in the session.

Adding a custom field is purely additive. The SessionData struct exposes custom: HashMap<String, serde_json::Value> (the implementation may evolve, but the field-with-cap shape is stable), and you write through accessor methods on the session handle. The state-machine variants do not change. The schema-migration story covered in Schema migration handles upgrade paths without breaking existing sessions.

The foundation the rest of the book stands on

The state machine is the foundation that lets the rest of the book be shorter. Factor composition (Factors and methods) works because Authenticating::remaining is a list, not a single field. Step-up authentication works because the orchestrator can transition from Authenticated to Authenticating with a non-empty remaining list when a sensitive route demands a stronger factor. Cedar authorisation works because Authenticated carries factors_completed, which the entity provider can serialise into a Cedar attribute the policy can match on. Audit events work because every transition produces a distinct AuthEvent variant with the right fields populated.

None of these features required a different enum; they all read out of the state machine that was already there. The enum carries the authentication question, and the rest of the library asks it.

Further reading

Three chapters build directly on this one. Factors and methods covers which factors fit into the variants and how methods compose. Scope hierarchy covers how begin_login picks the right method given Global, Tenant and User overrides. Refresh tokens and session continuity covers how a session survives across long-lived sessions, key rotation and token theft. Session lifecycle and crypto envelope in Part V covers the cookie, the encryption envelope, and the orchestration's dirty-flag handling.

Factors and methods

A factor is a single credential check: a password, a TOTP code, a WebAuthn assertion, an LDAP bind, an OAuth token exchange. A method is a sequence of factors that together count as a successful login. Composing factors into methods, and scoping methods to apply per-user or per-tenant rather than globally, is the day-to-day surface adopters work with.

Vocabulary

The four words that recur are factor, step, method, and scope. They sound interchangeable in casual writing, and they are not in the code.

A factor is one credential verifier, identified by a FactorKind variant: Password, Totp, Hotp, EmailOtp, Fido2, LdapBind, or Federated(FederatedProvider). Each factor has a config struct (PasswordConfig, TotpConfig, and so on) that the relevant adopter seeds at provisioning time and the service reads at verification time.

A step is one node in a method. A step is either a Required(kind) demand for a specific factor, or an AnyOf(vec![kind1, kind2, ...]) disjunction that lets the user choose among several factors at that position. The step is the unit of authoring; a method is a sequence of steps.

A method is an ordered sequence of steps with a stable name. Examples in the wild: "password-only" (one step, Required(Password)), "password-then-TOTP" (two steps, Required(Password) then Required(Totp)), "password-then-second-factor" (two steps, Required(Password) then AnyOf(vec![Totp, Fido2, EmailOtp])). The name matters because the session records which method is in progress, and the audit trail names the method when recording success or failure.

A scope is the tier at which a method is configured. There are three tiers (System, Tenant, User), covered in detail in Scope hierarchy. The short version: a system default applies everywhere; a tenant can override it; a user can override the tenant. Resolution is the simple inversion of authority: user override beats tenant override beats system default.

The factor list

The current FactorKind enum and its companion config sum-type live in axess-core/src/authn/factor.rs.

pub enum FactorKind {
    Password,
    Totp,
    Hotp,
    EmailOtp,
    Fido2,
    LdapBind,
    Federated(FederatedProvider),
}

pub enum FederatedProvider {
    Github,
    Google,
    Microsoft,
    Custom(String),
}

pub enum FactorConfig {
    Password(PasswordConfig),
    Totp(TotpConfig),
    Hotp(HotpConfig),
    EmailOtp(EmailOtpConfig),
    Fido2(Fido2Config),
    LdapBind(LdapBindFactorConfig),
    // Federated configs live with their provider's verifier crate.
}

FactorKind is the discriminator the state machine carries. FactorConfig is the data the verifier needs. They mirror each other because the verifier-versus-orchestrator split (see Architecture at a glance) puts the algorithm and its config in axess-factors and puts the discriminator and the composition machinery in axess-core. A new factor lands as a new FactorKind variant, a new FactorConfig variant, and a new verifier crate (or module) under axess-factors.

The federated case is intentionally a parameterised variant rather than a flat list. Each federated provider has its own configuration shape (Google's audience claim differs from GitHub's; Microsoft adds tenant directory parameters), and the wire formats are different enough that flattening them into one enum would require a discriminator inside the config. Parameterising the kind itself makes the config sum-type smaller and the type system honest about the variation.

Custom(String) is the extension point for IdPs the upstream library does not name explicitly. Adopters who federate against Okta, Auth0, Azure AD as a generic OIDC provider, or an in-house IdP plug in with the OAuth-RS resolver and a custom string identifier; the workload identity chapter (Workload identity overview) describes the same pattern from the inbound-resolver side.

Composing and verifying

How a method becomes a chain, and what one step does.

How factors compose

The composition primitives are FactorStep and Method. A FactorStep is one node in a method. A Method is a vector of steps plus a name.

pub enum FactorStep {
    Required(FactorKind),
    AnyOf(Vec<FactorKind>),
}

pub struct Method {
    pub name: Arc<str>,
    pub steps: Vec<FactorStep>,
}

The two-step Required(Password) then AnyOf(vec![Totp, Fido2]) method handles a common shape: the user enters their password, then completes one of two second factors, and which one is their choice. They may not have registered a passkey yet, or their phone may be at home and only the hardware key with them. The state machine's Authenticating::remaining field carries the residue of steps yet to complete: after the password step, remaining looks like [AnyOf(vec![Totp, Fido2])] and the application's login page renders the choice between them.

Required(kind) is shorthand for a one-element AnyOf(vec![kind]), but the distinction matters for audit clarity. A successful login that went password + totp reads cleanly when the audit log records "completed Required(Totp)"; the same login through an AnyOf step records "completed AnyOf::Totp" and a reviewer asks why the choice was offered at all. Use Required when there is no choice.

The orchestrator does not support arbitrary expression trees of factors (you cannot say "two of these three" with a single step). The omission is on purpose. Real authentication methods are short sequences with at most one decision point per step, and admitting arbitrary expressions would invite policies that pass formal review but defeat operational understanding.

The verify_factor path

Application code drives factor verification through RequestAuthnService::verify_factor, on the request-scoped handle that AuthnService::with_audit_context returns. The signature is

pub async fn verify_factor(
    &self,
    credential: &FactorCredential,
    session: &AuthSession,
) -> Result<FactorOutcome, AuthnError<I::Error>>;

with FactorCredential the runtime credential value:

pub enum FactorCredential {
    Password(ZeroizedString),
    OtpCode(Arc<str>),
    Fido2Assertion(serde_json::Value),
}

and FactorOutcome the result of the call:

pub enum FactorOutcome {
    Authenticated,
    FactorRequired(FactorKind),
    InvalidCredential,
    Locked { until: Option<DateTime<Utc>> },
}

The handler in your application takes the credential off the request (form body, JSON, header, whatever), wraps it in the right FactorCredential variant, and calls verify_factor. Three things then happen inside the service.

First, the service acquires the session's write lock and reads its current state. If the state is not Authenticating, the call returns an AuthnError. If the state is Authenticating, the service inspects remaining to determine which factor is expected next. A mismatch between the credential the client supplied and the factor the method expects returns FactorOutcome::InvalidCredential without engaging the verifier, which keeps the cryptographic cost of failed attempts predictable.

Second, the service dispatches to the appropriate verifier in axess-factors. The password case calls Argon2id. The TOTP case calls the RFC 6238 verifier with the user's stored secret and the current window. The FIDO2 case calls the WebAuthn ceremony, which is itself stateful and threads through the session's challenge field. Federated cases dispatch to their respective OAuth or OIDC handlers.

Third, the service translates the verifier's result into a FactorOutcome and an AdvanceOutcome from the state machine. A successful verification calls AuthState::advance_factor, which returns Completed if no factors remain (the orchestrator promotes the session to Authenticated) or StillAuthenticating if more factors are required (the orchestrator leaves the state in Authenticating and returns FactorOutcome::FactorRequired(kind) with the next expected kind). A failed verification increments attempt_count, updates last_attempt, and returns FactorOutcome::InvalidCredential or Locked depending on the attempt policy.

The Locked outcome is the lockout decision in band. The until: Option<DateTime<Utc>> field carries the unlock time when one is scheduled (a five-minute exponential backoff after three attempts, for instance) or None when the lockout requires administrative intervention. The application surfaces this to the user with the right copy; the audit log records the lockout regardless.

Begin and complete

verify_factor is the verb that drives a method forward, but a login also has a start and an end. The start is RequestAuthnService::begin_login, which transitions a Guest session into Authenticating. The end is the orchestrator's promotion of Authenticating to Authenticated when the last factor completes (or to PendingWorkflow when a workflow is in progress).

begin_login does three things:

  1. Looks up the user in the named tenant. An identifier matching nothing returns LoginOutcome::InvalidCredentials, the same outcome a wrong password gives, and runs the same store queries against a dummy id so the latency matches too. There is deliberately no "no such user" to branch on.
  2. Loads the method that applies under the scope hierarchy (see Scope hierarchy): the sequence of steps this user will walk.
  3. Transitions the session to Authenticating, with remaining set to that method's full step list.

complete_signup is the corresponding verb for the PendingWorkflow case. After a signup ceremony completes (email verified, KYC checks passed, terms accepted), the orchestrator transitions the session from PendingWorkflow { kind: Signup, ... } to Authenticated. The factor list on the resulting Authenticated variant is the list that was used during the signup, which is what the audit trail wants and what subsequent policy evaluation reads.

Extending it

Your own factor, and asking for another when risk rises.

Adding a custom factor

The pattern for adding a factor that axess does not ship is the same pattern that produced the factors that axess does ship. There are four moving parts.

The first part is the verifier itself. It lives in axess-factors (or in a separate crate that depends on axess-factors) and exposes a function or trait that takes the stored config plus the runtime credential and returns a verifier-side result. For a hash-based factor this is straightforward (compute the hash, constant-time compare); for a ceremony-based factor (FIDO2, OAuth) the verifier threads through the session-side challenge and the response.

The second part is the FactorKind variant. Adding a variant is a breaking change to the public surface, which is what you want: any match on FactorKind in adopter code now flags a missing arm, and the adopter chooses to handle the new factor or to reject it with an explicit pattern. There is no "add a variant silently" mechanism in axess, and that omission is intentional.

The third part is the FactorConfig variant and the storage adapter that loads it. The factor config goes into the configured factor store; the load path resolves the scope (System, Tenant, User) and returns the right config for the user being authenticated. Adopters implement the factor store, so the storage decision is theirs.

The fourth part is the credential type. A new factor that requires a new shape of input adds a variant to FactorCredential. A factor that maps to one of the existing variants (a password-like factor reuses Password, a code-based factor reuses OtpCode) avoids the addition.

The work is small. The factors that axess ships today each take fewer than a thousand lines of Rust including tests. The reason the work stays small is that the orchestration and the state machine do not change; the verifier is doing one job, behind a fixed contract.

Step-up authentication

Step-up is the pattern where an already-Authenticated session is asked to re-prove identity (or to prove with a stronger factor) before performing a sensitive action. Axess models this by transitioning the state from Authenticated back to Authenticating with a non-empty remaining list. There is no require_step_up orchestrator method. What axess ships is the decision, not the transition: decide_step_up is a free function over a Device and a StepUpPolicy, returning Some(factors) when the device's trust level calls for them and None when it does not. StepUpPolicy is a factor list per DeviceTrustLevel: unknown and seen demand factors by default, trusted and revoked demand none. begin_login surfaces the answer as LoginOutcome::StepUpRequired { device_id, allowed_factors }.

Driving the session back into Authenticating with that list is the caller's step.

The state-machine view is uniform. The session is Authenticating again; the factor list contains the stepped-up factors; the session remembers (in completed) which factors it already cleared. verify_factor works the same way it did during the original login, and on the final Completed outcome the session transitions back to Authenticated with a fresh authn_time and an updated factors_completed.

The application controls when step-up is required. The Cedar policy engine can express "this action requires Fido2 in factors_completed" (see Cedar policy fundamentals), or the handler can demand it directly. The state machine does not impose a policy; it provides the shape that lets the policy be enforced.

How far two steps get you

A method composed of a Required(Password) followed by an AnyOf(vec![Totp, Fido2, EmailOtp]) covers an enormous share of real deployments without any further structure. A per-tenant override for a specific tenant that requires Required(Fido2) instead of the disjunction covers the rare case where one tenant must be stricter. A per-user override that adds Required(EmailOtp) for a flagged user covers the regulatory case where one user is on a watch list.

None of these require new code beyond an entry in the method store. The state machine, the verifier dispatch, and the audit pipeline all read the method out of the configured scope and execute it. The next chapter, Scope hierarchy, covers the configuration tier in detail.

Further reading

Scope hierarchy covers Global, Tenant, and User configuration tiers and how begin_login resolves them at runtime. Cedar policy fundamentals covers how the policy engine reads factors_completed and authorises against it. Part III, Factor cookbooks, has a chapter per real-world factor (Password and TOTP, FIDO2 and WebAuthn passkeys, OAuth 2.0 and OIDC, and so on) that walks through the integration details one factor at a time.

Scope hierarchy

Methods and factor configurations live at three tiers: System, Tenant, and User. The mechanism takes a paragraph to describe and a long time to get right.

Used well, the three tiers make a multi-tenant deployment feel like one configuration with two override surfaces. Used carelessly, they become a maze where nobody can answer "what method is this user actually using?" without running a query.

The model

Three tiers, and why a lower one must opt in rather than inherit.

The three tiers

AuthnScope lives in axess-core/src/authn/types.rs. It is a three-variant enum, ordered from broadest to narrowest:

pub enum AuthnScope {
    System,
    Tenant(TenantId),
    User { tenant_id: TenantId, user_id: UserId },
}

System is the platform-owned default tier. A method or factor configured at system scope is a template the platform makes available; tenants adopt it explicitly (see "Adoption, not silent inheritance" below).

Tenant(TenantId) is a per-tenant configuration. A method configured at tenant scope applies to every user in that tenant.

User { tenant_id, user_id } is a per-user configuration. A method configured at user scope applies to that one user.

The ordering is the ordering of authority. Narrower beats broader.

Adoption, not silent inheritance

System is a template tier, not a runtime broadcast tier. A factor configured at System scope does not automatically become a login option for every tenant; a tenant adopts the template explicitly at provisioning time or through an administrative reconfiguration. The FactorTemplate catalog in axess-core::authn::factor is the surface for this: platform operators curate a set of templates; tenant provisioning selects which templates this tenant will use and materialises tenant-scoped rows.

This preserves the invariant that a tenant admin never gets surprised by a factor they did not opt into. Silent inheritance would make platform-wide changes propagate to tenants who had reasons for their existing configuration; explicit adoption forces those changes to route through the tenant's own configuration surface.

At runtime, once a user has an active method that includes some factor kind, the store walks the User → Tenant → System resolution chain to find the applicable config data (see next section). The System row is legitimate as the config source for a factor the user has already activated (through their method); it is never the grant.

How it resolves

Narrowest scope wins, resolved in one query by the store rather than the caller.

How resolution works

At begin_login time and again at each verify_factor step, the service asks the factor store for the applicable config for the factor kind that is next in the user's method. Resolution walks the scope chain from narrowest to broadest, returning the first hit.

The chain helper AuthnScope::resolution_chain produces the ordered sequence of scopes to query. For a user with tenant_id = T and user_id = U, the chain is [User { T, U }, Tenant(T), System].

Application code does not walk the chain: the store does, in one query, and returns a [ResolvedFactor] carrying both the config and the scope it was resolved from:

pub struct ResolvedFactor {
    pub config: FactorConfig,
    pub resolved_from: AuthnScope,
}

// In service code, one call:
let resolved = factors
    .resolve_factor(&user_scope, kind)
    .await?
    .ok_or(AuthnError::NoFlow)?;

Backends implement resolve_factor as a single ordered SELECT (the SQLite example does this via a UNION ALL with a rank column and LIMIT 1). Latency-wise this is one round trip regardless of where the config actually lives.

The resolved_from field lets callers know which tier served the request: used by the failure-counter CAS logic to know whether to CAS against an existing user-scope row or to create a new one from a tenant/system template.

For admin and display code that wants "what did this tenant explicitly configure?", the store also exposes load_factor which returns the config at exactly the requested scope with no fallback. Never on the auth hot path: use resolve_factor there.

The same chain is used for each factor in the method. A method that chains password and TOTP resolves the password config first (which might be a user-scoped override) and then the TOTP config (which might be a tenant default). Each factor's configuration is resolved independently, which is the right shape for the common case where the user has chosen their own TOTP device but the tenant has standardised the password policy.

Storage encoding

The factor store schema has tenant_id (NOT NULL) and user_id (nullable) columns:

tenant_iduser_idScope
TenantId::SYSTEMNULLSystem
<tenant>NULLTenant(tenant)
<tenant><user>User { tenant, user }

tenant_id is never NULL for configuration scope; platform-owned rows live under the reserved TenantId::SYSTEM tenant. This keeps FK integrity uniform (tenant_id REFERENCES tenants(id)) and lets SQL callers use a single tenant_id = ? clause without the IS NULL special-cases that a two-optional encoding would need.

ScopeColumns in axess-core/src/authn/types.rs is the in-code representation of the pair; it exposes tenant_id: TenantId (always populated) and user_id: Option<UserId> (populated only for User scope).

Note this is distinct from audit-event storage, where a NULL tenant_id means "tenant not yet known" (pre-authenticated event, failed login for an unknown user), never "System scope."

What gets scoped

The hierarchy applies to three kinds of object: factor configurations, methods, and lockout policies. Each plays the same game, with the same chain-walking resolution.

Factor configurations are the per-factor stored data: the password hash for a user, the TOTP secret for a user, the FIDO2 credential public keys for a user, the LDAP bind parameters for a tenant, the system default Argon2id parameters, the system default TOTP drift window. Most user-specific factor configurations are user-scoped because they belong to a specific user (a password hash is intrinsically per-user). Policy-shaped configurations (Argon2id parameters, drift windows) are typically tenant-scoped or system-scoped.

Methods are the ordered sequences of factor steps. A tenant typically configures a single default method (password-plus-TOTP, say), and a small minority of tenants override it (a regulated tenant requires FIDO2 instead of TOTP). Individual users very rarely have a custom method; when they do, it is because policy demands a stronger factor for a flagged user. Methods live at User or Tenant scope only; the SQLite example rejects System scope on save_method and friends, because runtime authentication methods must be materialised per tenant.

Lockout policies are the rate and threshold for locking out a user after repeated failed attempts. System defaults exist. Tenants with stricter risk postures override at tenant scope. Per-user lockout policies exist but are rare; they usually mean "this user is on a watch list and gets locked out faster than the rest".

The pattern across all three is identical. Configure a sensible system default. Let tenants adopt it or override when they have a real reason. Reach for the user-scoped override only when policy demands per-individual differentiation. The more configuration you do at the narrowest scope, the more state you have to reason about during incidents.

Using it

Moving an existing deployment onto it, and the shapes to avoid.

Migration patterns

The scope hierarchy is the right tool for rolling out factor changes in a controlled way. The pattern is to introduce the change at the narrowest scope, verify it on a small population, and broaden as confidence accumulates: but broadening happens through explicit per-tenant adoption, never through silent system-wide broadcast.

A worked example. A SaaS deployment wants to require FIDO2 for all users, replacing the existing password-plus-TOTP method. The cautious roll-out has three phases.

Phase one is User-scoped pilot. The operations team configures the new method (Required(Password) then Required(Fido2)) at user scope for a small set of internal users. These users go through the new flow first, surface any UX problems, and validate that the FIDO2 ceremony works end-to-end against your relying-party configuration.

Phase two is Tenant-scoped pilot. The team configures the new method at tenant scope for a single early-adopter tenant. Their users transition next, and the pilot widens to a population that includes real customer traffic. The user-scoped overrides from phase one are removed (they no longer differ from the tenant default).

Phase three is Per-tenant rollout. With confidence from both pilot phases, the team iterates the remaining tenants, either by updating each tenant's method configuration to the new FIDO2-plus-password sequence, or (if the tenant admin is empowered) by prompting the admin to adopt the new method template. Each tenant transitions independently, with an audit event per change. There is no system-wide broadcast: if a tenant deliberately does not adopt the new method (e.g. their user base has no FIDO2 hardware), they retain the old method and the roll-out simply skips them.

The pattern works in reverse for emergency revocation. If the new method has a bug that surfaces during rollout, the team can override at tenant scope or user scope for the affected population without redeploying. The narrower scope wins; the affected users walk the old method while the bug is fixed.

How Cedar policy interacts

The scope hierarchy answers "what method does this user authenticate with?" Cedar answers "what is this user allowed to do once authenticated?" The two surfaces are distinct, and confusing them leads to authorization-as-authentication mistakes.

A common pattern is to use Cedar to require a method outcome rather than to choose one. A policy might require that factors_completed.contains("Fido2") for an action against a sensitive resource. The method itself remains the resolved one from the scope hierarchy. If the method does not include FIDO2, the user reaches the sensitive route and gets a deny; you then offer step-up to add FIDO2 (covered in Factors and methods §"Step-up authentication"), the user completes it, and the policy now passes.

The split between choice (scope hierarchy) and demand (Cedar policy) is what makes this work. The hierarchy decides what factors are available; the policy decides which of them are required for which actions. A user can have a stronger method than the policy minimum and satisfy the policy without effort; a user with a weaker method gets prompted for step-up.

Anti-patterns

The hierarchy invites a few mistakes that are worth naming explicitly.

Overusing user-scoped configuration. Every user-scoped row in the factor store is a piece of state that an operator has to maintain. If a tenant decides to change its method, the tenant-scoped row updates; the user-scoped overrides do not. After a few months of incremental changes, the user-scoped rows are out of sync with the intended policy, and nobody remembers why each row exists. The fix is to use user scope only when policy genuinely requires per-individual differentiation, and to document the reason in a separate field next to the row.

Treating System as a runtime broadcast tier. A factor configured at System scope is a template: the correct pattern is to adopt (materialise a tenant-scoped row) rather than to depend on resolution to reach it silently. Depending on system-tier fallback turns platform-wide edits into surprise tenant-level changes. Materialise on adoption; treat runtime fallback to System as a convenience, not a management model.

Conflating method scope with tenant identity. The hierarchy says nothing about which tenants exist; it says only how to resolve a configuration for a given (tenant, user) pair. Tenant provisioning, tenant suspension, and tenant deletion are covered in Multi-tenancy.

How the hierarchy scales

The hierarchy is the reason an axess deployment scales from "one company with one method" to "a SaaS with hundreds of tenants, each with its own posture, and a few high-risk users on stricter policies" without restructuring anything. The same code path (begin_login, verify_factor, Authenticated) handles the single-tenant case and the hundred-tenant case. The only difference is which scope holds the configuration.

The pattern is not unique to axess. Cedar policies, audit retention policies, and rate-limit thresholds all follow the same three-tier pattern. The vocabulary is consistent across the library so a reviewer who has internalised the resolution rule does not have to re-learn it for each subsystem.

Further reading

Multi-tenancy covers tenant provisioning, the TenantId lifecycle, cross-tenant refusal, and the three-lever lockout. Cedar policy fundamentals covers how authorisation policy reads the resolved method's factors_completed field. Identity store implementation walks through the storage adapter that resolves the scope chain against a relational schema.

Refresh tokens and session continuity

A session cookie keeps a user logged in until it expires or is cleared. A refresh token is the mechanism that extends that lifetime past the cookie's short window, without exposing a long-lived bearer credential to the client. The choice between a long cookie and a short cookie plus a refresh token is the choice between a stolen cookie that is valid for a day and one that is valid for an hour and then shows up as theft the next time the legitimate user refreshes.

The code is in axess-core/src/session/refresh.rs.

Why refresh tokens at all

A naive long-lived session is one cookie that lives for a month. If the cookie is stolen, the attacker has a month of access. The legitimate user has no way to know the cookie was stolen unless they notice the attacker's actions in their account.

A short-lived session with a refresh token is two credentials. The session cookie lives for an hour and grants access. The refresh token lives for a month and grants only the right to mint a new session cookie. The refresh exchange happens server-side, typically when the session cookie expires; the client sends the refresh token, the server checks it, and the server issues a fresh session cookie (and optionally a fresh refresh token).

The cost is one extra round-trip per hour. The benefit is twofold. First, a stolen session cookie expires within the hour. Second, and more importantly, a stolen refresh token gets caught the next time either the attacker or the legitimate user attempts to refresh, because the system detects that a token has been used twice and revokes the entire token family.

The stored shape

RefreshToken is the row that lives in the refresh token store:

pub struct RefreshToken {
    pub id: RefreshTokenId,
    pub user_id: UserId,
    pub tenant_id: TenantId,
    pub token_hash: String,
    pub issued_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
    pub revoked: bool,
    pub device_info: Option<String>,
    pub family_id: Option<TokenFamilyId>,
    pub device_id: Option<DeviceId>,
}

Three fields are worth dwelling on.

token_hash is the SHA-256 hash of the token string, not the string itself. The plaintext token is generated when the token is issued (through SecureRng for DST), returned to the client once, and never stored. The hash is what lives in the database. A database breach that leaks every row of the refresh token store does not leak any usable token, because the hash is one-way. The verification path hashes the client-supplied plaintext and compares it constant-time against the stored hash.

The hashing uses an optional pepper, configured through RefreshTokenConfig::hash_pepper. When set, the hash is HMAC-SHA256(pepper, plaintext); when unset, the hash is plain SHA-256(plaintext). The pepper is a deployment-level secret stored outside the database (in the secrets manager that holds the session signing key, typically) and adds defence in depth: an attacker who breaches the database alone cannot mount an offline brute-force attack against the hashes.

family_id is the link to the token's lineage. Every refresh token issued in a single authentication chain shares a TokenFamilyId. The first token issued at login starts a family; each subsequent token issued by rotation extends the same family. When the system detects that a token from a family has been used after rotation (which is what theft looks like), it revokes the entire family.

device_id is the link to the device identity ladder, and it is what makes revocation travel in both directions between the two stores. The section on the cascade below has the detail; Device identity covers the ladder itself.

How families catch theft

Alice logs in. The server issues refresh token A, in family F. A is delivered to her browser; the hash of A is stored in the database with family_id = F.

An hour later, Alice's session cookie expires. Her browser sends A back to refresh. The server hashes the plaintext, finds the row, verifies it is not revoked, marks A as revoked (rotation), and issues a new refresh token B in the same family F. B is delivered to the browser.

Meanwhile, an attacker has stolen the cookie and copied A. The attacker now sends A to refresh. The server hashes the plaintext, finds the row, and sees that A is already marked revoked.

The clean refresh-after-rotation invariant says that a revoked token should never be presented again. If it is, either Alice's browser is broken (unlikely), or the network retried (rare and recoverable), or the token has been stolen and the attacker is racing the legitimate user. The conservative response is to assume the worst: revoke the entire family F. Token B (which Alice's browser holds and has not yet used) is now revoked. The next time Alice's browser refreshes, it fails. The user has to log in again, but during the brief window between detection and re-login the attacker has no access either.

The detection-and-revoke pattern is implemented in the refresh_session function: when a revoked token is presented, the function calls revoke_family(user_id, family_id) and emits an audit event noting the suspected compromise. The application can also wire an on_token_compromise callback to receive the event synchronously and take application-specific action (logging Alice out of related sessions, alerting her by email, escalating to fraud review).

What a long-lived session cannot do is notice. An attacker who avoids every other alert still cannot avoid this one, because it is the legitimate user's next refresh that fires it, and the cost is a single re-login per detection.

Device-binding cascade

When the device feature is enabled, refresh tokens are bound to the device that received them. A refresh token issued from a browser on Alice's laptop carries device_id = Some(laptop). A refresh token issued from her phone carries device_id = Some(phone). Family revocation cascades to the device store, marking the relevant device as Revoked; device revocation cascades back to the token store, revoking every token bound to the device.

The cascade is the mechanism behind "log out everywhere on this device" and "this device was lost, revoke all access from it". The operator marks the device revoked in the device store; the cascade revokes every refresh token bound to it; the next refresh from that device fails. The user is logged out of every session that ran through the device, including any session that was idle but still holding a refresh token.

The opposite direction matters too. When a family-revoke triggers from a token-reuse detection, the cascade marks the relevant device as compromised. The device's three-stage trust ladder (Unknown to Seen to Trusted, covered in Device identity) is short-circuited to the terminal Revoked state. Subsequent logins from the same device fingerprint surface as a fresh Unknown device, which the user re-establishes trust on with whatever step-up the application requires.

The collect_family_device_targets helper gathers (TenantId, DeviceId) pairs from a family for the cascade. The helper exists because the device store and the refresh-token store are independent persistence layers, and the cascade is the place where they coordinate. The application's on_token_compromise callback receives the list and decides which cascade to apply (some applications mark devices Revoked directly; others write an intermediate audit event and let an operator confirm).

Configuration

RefreshTokenConfig is the operator's tuning surface:

pub struct RefreshTokenConfig {
    pub ttl: Duration,
    pub max_per_user: usize,
    pub rotation: bool,
    pub hash_pepper: Option<Vec<u8>>,
}

The defaults are conservative for most applications: a thirty-day TTL, ten concurrent tokens per user, rotation enabled, and no pepper.

ttl is how long a refresh token is valid before it expires without being used. Thirty days is enough that most users do not feel the expiry in normal use, and short enough that an abandoned device's tokens become unusable in a bounded time. Applications with stricter posture set this lower; applications with weak step-up at re-login set this higher.

max_per_user is the cap on how many refresh tokens a user can have active at once. The cap exists to prevent a runaway "log in from every device the user owns" pattern from filling the token store. Issuing a new token past the cap evicts the oldest one. Ten is generous for most users (a phone, a laptop, a tablet, plus a few spares); applications with operators who routinely log in from ephemeral machines push this higher.

rotation controls whether a refresh issues a new token (true) or extends the existing one (false). Rotation enabled is the default and is what makes family-based theft detection work. Rotation disabled is faster (one less write per refresh) but defeats the family detection mechanism, because a token never moves to revoked under normal use. The recommendation is to leave rotation on; the performance cost is negligible.

hash_pepper is the optional shared secret used to HMAC the token hash. Adding a pepper is a defence-in-depth measure that helps when the database is breached but the secrets manager is not. The pepper must be stable across the deployment (otherwise existing tokens become unverifiable); rotation is supported through the same pattern as the session signing key, covered in Operations runbook.

Atomicity contracts

The RefreshTokenStore trait documents that production backends must implement three methods atomically. The atomicity is what makes the family-based theft detection sound; a non-atomic implementation opens a time-of-check-to-time-of-use window, where an attacker could race the legitimate user past the detection between the moment the token is checked and the moment it is spent.

rotate_token must atomically mark the current token revoked and issue a new token in the same family. Two requests racing each other must result in one rotation and one detected reuse, not two rotations.

issue_with_eviction must atomically issue a new token and evict the oldest if the user is at the max_per_user cap. A non-atomic implementation can leave a user with eleven active tokens momentarily, which is harmless, or evict the wrong token under contention, which can log a legitimate session out for no reason.

revoke_family must atomically revoke every token in a family. Partial revocation defeats the detection mechanism: an attacker holding a token from a half-revoked family can still refresh.

The first-party SQL adapters use transactions to satisfy these contracts. Custom adapters need to do the same; the contract is documented on the trait so reviewers can check it explicitly.

Choosing a point on the continuum

Refresh tokens and session cookies are the two ends of a continuum between "convenience" and "security". A session cookie alone is the convenience end. A refresh token with family-based theft detection and device-binding cascade is what lets axess sit much closer to the security end without compromising user experience: sessions feel permanent because they refresh transparently, and theft gets caught the next time anyone attempts a refresh.

The mechanism is the same one that lets axess support "log out of everything" at the user-account level and "this device was lost" at the device level, because the cascade between tokens, families, and devices is the same in both directions. A session that has lived its whole life behind axess can be revoked through any of the three handles, and the others follow.

Further reading

Device identity covers the three-stage device assurance ladder, the per-tenant fingerprint pepper, and the retention sweep. Session lifecycle and crypto envelope covers the session cookie itself, the AES-256-GCM envelope, and the orchestration that issues and reads cookies. Operations runbook covers key rotation for the session signing key, the refresh-token pepper, and the device fingerprint pepper.

Password and TOTP

The four factors axess-factors ships by default (password, totp, hotp, email_otp) are the ones most adopters reach for first. They require no external IdP, no specialised hardware, no extra infrastructure. Password (Argon2id) and TOTP (RFC 6238) are the common pairing, and the pattern they illustrate generalises to every other factor in the library.

The feature flags password, totp, hotp, email_otp are all on by default in axess-factors. No Cargo.toml change is needed to use them.

Password (Argon2id)

The password factor verifies a user-supplied secret against a stored Argon2id hash. The choice of Argon2id rather than bcrypt or PBKDF2 is the standard one for new systems built today; the parameter tuning is the operational lever you reach for first.

The configuration struct is PasswordConfig, and it is smaller than you might expect:

pub struct PasswordConfig {
    /// Argon2id PHC hash string, zeroized on drop.
    pub hash: ZeroizedString,
    /// Strength rules applied when setting a new password.
    pub rules: PasswordRules,
}

There are no Argon2 parameters to set, and no pepper. Hashing is the password_auth crate's generate_password_hash / verify_password pair, which picks recommended Argon2id parameters, generates a fresh random salt per hash, and encodes the parameter set into the stored hash itself, in PHC string format (the Password Hashing Competition's $argon2id$v=19$m=... encoding). Verification reads the parameters back out of the stored hash, so raising the cost later is a matter of upgrading the crate and rehashing on next login. Old hashes keep verifying against the parameters they were made with.

That also means the cost is not yours to tune from axess, and the answer to "can I use a pepper" is that you would have to apply it yourself before calling generate_password_hash, storing the result in hash. Nothing in the type stops you; nothing in the type helps you either.

What is configurable is the strength rules:

pub struct PasswordRules {
    pub min_length: usize,        // default 12
    pub require_uppercase: bool,  // default true
    pub require_lowercase: bool,  // default true
    pub require_digit: bool,      // default true
    pub require_special: bool,    // default false
    pub history_count: usize,     // default 0, no reuse check
}

So axess does enforce complexity, and the defaults are stricter than the habitual eight-character minimum: twelve characters with upper, lower and a digit. require_special is off by default because the character-class requirement that most reliably produces Password1! is the one that demands punctuation.

history_count is the reuse check, and it is off by default because it costs something to turn on: a non-zero value makes the flow call IdentityPasswordHistory::password_history and record_password_hash. That trait has no default bodies, so the password flow is bounded on it and a store that does not implement it will not compile against the flow. Set history_count to 12 for the SOC2-shaped "cannot reuse the last twelve" rule, and implement the trait at the same time.

Rules are resolved per tenant, through IdentityLookup::password_rules_for_tenant, which defaults to PasswordRules::default(). A deployment with one policy can ignore it; a deployment that sells a stricter tier can override it per tenant without touching the login path.

There is no maximum length in the rules. You should want one, because Argon2id is deliberately expensive and an unbounded password field is a cheap way to burn server CPU. Impose it at the edge, where the request is parsed, before the value reaches the hasher.

TOTP (RFC 6238)

The TOTP factor verifies a six-digit code derived from a shared secret and the current time window. The shared secret is twenty bytes of cryptographic randomness, generated at enrolment time and stored alongside the user's other factor configurations.

The configuration struct is TotpConfig:

pub struct TotpConfig {
    pub secret: ZeroizedString,      // raw bytes; base32 for provisioning URIs
    pub digits: u8,                  // default 6
    pub period_secs: u32,            // default 30
    pub algorithm: OtpAlgorithm,     // Sha1 | Sha256 | Sha512, default Sha1
    pub past_window: u32,            // default 1
    pub future_window: u32,          // default 1
    pub last_step: Option<u64>,      // last validated counter; blocks replay
}

Two fields there are not decoration. The drift window is two numbers, not one: past_window for a client behind the server and future_window for one ahead of it. Both default to 1, because a phone's clock runs ahead as often as behind. NTP-synced devices drift forward across time-zone changes, and a handset's OS clock is commonly a few hundred milliseconds early, so a one-sided window rejects valid codes from those users.

last_step is the replay defence. It records the counter of the last code accepted, so a code that already worked cannot be used again inside its remaining validity. That makes TotpConfig mutable state, not just configuration: your factor store must persist the updated value after a successful verification, or the same intercepted code stays usable for the rest of its window.

secret is zeroized in memory on drop. It holds the raw bytes, twenty cryptographically random ones from SecureRng; base32 is the encoding applied when the secret goes into a provisioning URI for a QR code or a manual key, not how it is stored. Adopters serialise it to and from their factor store however the store's encryption envelope prefers.

digits is six in line with every TOTP authenticator in production use. RFC 6238 admits up to eight, but no widely deployed TOTP app generates eight-digit codes, so the field exists for symmetry rather than for variability.

period_secs is the time window each code is valid for. Thirty is the RFC default and what every authenticator app expects. Increasing the period (to sixty seconds, say) reduces the chance that a user typing slowly enters a code that has just expired, at the cost of doubling the window an intercepted code remains valid. The recommendation is to keep this at thirty unless you have a specific reason to change it.

algorithm is an OtpAlgorithm: Sha1, Sha256 or Sha512. SHA-1 is the RFC 6238 default and the only one guaranteed to interoperate. Most modern authenticator apps handle SHA-256; few handle SHA-512. Stay on SHA-1 unless you control which app the users will use.

past_window and future_window count the adjacent time steps the verifier accepts on each side. One and one, against a thirty-second period, gives a ninety-second total acceptance range. Lifting either reduces friction for users with a drifting clock at the cost of widening the window an intercepted code stays usable, and widening past_window in particular gives a brute-force attempt more valid targets per guess. The defaults are the right trade for most deployments.

Composing password and TOTP

A method that combines password and TOTP is two FactorSteps:

use axess::{FactorKind, FactorStep, Method};

let password_plus_totp = Method {
    name: "password-then-totp".into(),
    steps: vec![
        FactorStep::Required(FactorKind::Password),
        FactorStep::Required(FactorKind::Totp),
    ],
};

The method is stored at whatever scope the deployment wants (Global default, Tenant override, User override; see Scope hierarchy). At begin_login time the resolver loads the method, the session transitions to Authenticating with remaining = [Password, Totp], and the login flow walks the two factors in order.

The application's login page renders the password prompt while the session is in Authenticating with remaining[0] == Password, and the TOTP prompt while in Authenticating with remaining[0] == Totp. A successful TOTP verification calls advance_factor, which returns Completed, and the orchestrator transitions the session to Authenticated. The user is logged in.

A common variant offers TOTP plus another second factor as a choice:

let password_plus_2fa_choice = Method {
    name: "password-then-2fa-choice".into(),
    steps: vec![
        FactorStep::Required(FactorKind::Password),
        FactorStep::AnyOf(vec![
            FactorKind::Totp,
            FactorKind::Fido2,
            FactorKind::EmailOtp,
        ]),
    ],
};

The login page after the password step shows three options. The user picks one; the application calls verify_factor with the appropriate credential; on success, the session is authenticated.

TOTP enrolment

Enrolment is a separate ceremony from login. The user is already authenticated (often immediately after signup), and the application walks them through registering a TOTP device. The shape is uniform across deployments.

The server generates a new TOTP secret through SecureRng. It serialises the secret as a base32 string and as an otpauth://totp/<issuer>:<account>?secret=<base32>&issuer=<issuer> URI suitable for embedding in a QR code. The UI displays the QR code (scanned by the user's TOTP app) and offers a copy of the base32 secret for users whose apps prefer manual entry.

The user enters a six-digit code from their app, the server verifies it against the same TOTP algorithm that login uses, and on success the server persists the secret to the factor store under the user's scope. The user is now enrolled. Their next login that demands TOTP will succeed.

Two operational details matter at enrolment.

The first is that the verification at enrolment must succeed before the secret is persisted. A user who scans the QR code but mistypes the verification code (or scans into the wrong app) should not be left with a stored secret that they cannot reproduce. The standard pattern is: generate the secret in memory, display the QR code, hold the secret in a short-lived enrolment record (in the session custom field, for example), verify the user's code, persist on success, discard on failure.

The second is recovery codes. A user who loses access to their TOTP device cannot log in with a method that requires TOTP. The deployment must offer a recovery path: either a recovery code printed at enrolment time (a long random string the user stores in a password manager), an email-OTP fallback factor, or an administrative reset flow with identity verification. Axess does not opinionate which path to take; the choice depends on the deployment's risk profile. The common pattern is to generate a recovery code at enrolment, treat it as a one-shot factor stored under the user's scope, and offer it as an alternative second factor.

HOTP and email OTP, briefly

The HOTP factor is the counter-based variant of TOTP. Instead of deriving the code from the current time window, the verifier derives it from a monotonically-increasing counter that advances on every successful verification. HOTP is the right choice for hardware tokens that have no clock (some YubiKey configurations, for instance). The configuration mirrors TotpConfig with a counter field instead of a period.

The email OTP factor verifies a six-digit code delivered to the user out of band, typically by email. The configuration carries the code length, the validity window (default fifteen minutes), and the count of allowed attempts before the code is revoked. The delivery is the application's responsibility; axess provides the verification side, the application provides the email send. The chapter Audit events covers the events emitted at email-OTP issuance and verification.

Threat model

A password-plus-TOTP login is robust against three common attacks and weak against one.

It is robust against either credential leaking on its own. The password alone does not complete a login without the TOTP code, and the TOTP secret alone does not complete one without the password. That also covers credential stuffing: an attacker replaying credentials leaked from another service is unlikely to hold the user's TOTP secret as well.

It is weak against a real-time phishing attack: a fake login page that prompts the user for their password, forwards it to the real server, prompts the user for their TOTP code, forwards that to the real server, and steals the resulting session. FIDO2 (covered in FIDO2 and WebAuthn passkeys) is the standard defence against this class of attack, because the WebAuthn ceremony binds the authentication to the origin and cannot be replayed against a different origin.

For applications where real-time phishing is a credible threat (financial services, healthcare, anything that handles regulated data), the recommendation is to offer FIDO2 as the second factor and treat TOTP as a fallback for users who do not yet have a passkey. The combination is what most regulators are asking for today.

Troubleshooting

A few failures recur often enough to be worth naming.

If TOTP verification fails consistently, the most likely cause is clock skew between the server and the authenticator app. The drift_window config accommodates a few seconds; larger drift points to a misconfigured NTP setup on either side. Logging the generated and accepted windows at debug level surfaces the offset quickly.

If TOTP verification fails for some users but not others, the likely cause is that the affected users scanned the QR code into an app that defaults to SHA-256 (some less common authenticators do), while the server defaults to SHA-1. The fix is to either align the server to SHA-256 (and re-enrol users), or to ensure the QR code URI explicitly specifies SHA-1.

If password verification is slow under load, the Argon2id parameters are probably set higher than the server class can support at the offered concurrency. The fix is to either lower the memory cost or to add CPU. Lower the memory cost first; below sixty-four megabytes you are out of the modern recommendation, and sixty-four megabytes is what current guidance suggests as a minimum.

If password verification is fast but logins occasionally take multiple seconds, the bottleneck is somewhere else (the factor store, the session store, an outbound network call in the login handler). Inspect the trace.

Further reading

Factors and methods covers the composition machinery this chapter exercises. FIDO2 and WebAuthn passkeys covers the WebAuthn second factor that supplants TOTP for the highest-assurance deployments. Identity store implementation covers how the password hash and TOTP secret are persisted alongside the user. Audit events covers the events emitted at every step of the password and TOTP flow.

FIDO2 and WebAuthn passkeys

FIDO2 is the answer to real-time phishing. Every other second-factor mechanism in this book (TOTP, HOTP, email OTP, SMS) is vulnerable to an attacker who proxies the user's input through a fake login page to the real server in real time. WebAuthn, the browser-side standard that FIDO2 implements, binds each authentication to the origin where the credential was registered, and a credential registered against accounts.example.com cannot be exercised against accounts-example-com.attacker.example. The defence is structural, not behavioural: the browser refuses to use the credential at the wrong origin, regardless of what the user clicks.

The feature flag is fido2 (off by default), enabled with features = ["fido2"] on the axess facade.

The two ceremonies

WebAuthn has two ceremonies, and an integration touches both. The first is registration: the user has authenticated to your application by some other means (signup with email verification, an already-logged-in session, an OAuth callback) and is registering a new authenticator. The second is authentication: the user already has a registered credential, is logging in, and the WebAuthn ceremony proves possession.

Registration is the more involved of the two because it is where the relying-party configuration matters. The server starts the ceremony by calling Fido2Provider::start_registration, which returns a CreationChallengeResponse. The handler serialises that to JSON and returns it to the browser, which calls navigator.credentials.create() with the JSON deserialised. The browser produces a RegisterPublicKeyCredential, which the page posts back to the server. The server calls Fido2Provider::finish_registration, which verifies the response against the challenge stored in the session, and on success returns the credential public key and metadata. The application persists this to the factor store under the user's scope, indexed by the credential id.

Authentication mirrors registration. The server calls Fido2Provider::start_authentication, which returns a RequestChallengeResponse listing the credential ids the user has registered. The browser calls navigator.credentials.get() with the serialised challenge. The browser produces a PublicKeyCredential, the page posts it back, and the server calls Fido2Provider::finish_authentication, which verifies the signature against the stored public key.

The challenge in both ceremonies is the part the session machinery threads through. The server generates the challenge from SecureRng at begin, stores it in the session (in a typed field on SessionData::custom or a dedicated extension), and reads it back at finish. The challenge is one-shot; it is consumed at finish, regardless of success, to prevent replay. The whole ceremony lives inside the typed state machine: a begin without a subsequent finish leaves the session in a state where the next call expects the finish, and any other call returns an error.

Relying-party configuration

The relying party is the server that owns the credentials. WebAuthn identifies the relying party by an origin (the host plus scheme plus port) and by a relying-party id (an effective domain suffix of the origin). The two pieces of configuration that matter at registration time are:

pub struct Fido2Config {
    pub rp_id: String,            // e.g. "example.com"
    pub rp_name: String,          // human-readable, "Example Inc."
    pub rp_origin: Url,           // e.g. "https://accounts.example.com"
    pub user_verification: UserVerificationPolicy,
    pub attestation: AttestationConveyancePreference,
    pub resident_key: ResidentKeyRequirement,
}

rp_id is the relying-party id, a string equal to or a suffix of the host part of the origin. Setting it to the apex domain (example.com rather than accounts.example.com) lets credentials registered on the accounts subdomain be used across other subdomains of the same apex (app.example.com, admin.example.com), which is usually what you want. Setting it to the full hostname scopes credentials to that hostname alone, which is appropriate when other subdomains belong to other applications you do not trust.

rp_origin is the full origin where the registration happens. The browser cross-checks this against the page's actual origin and refuses the registration if they do not match. Wildcards are not allowed; multi-region deployments register credentials under each region's specific origin.

user_verification controls whether the authenticator must verify the user's presence (a fingerprint, a PIN, a face scan) at authentication time, in addition to proving possession of the authenticator. Required is the right setting for high-assurance deployments; Preferred is the right setting for usability when some authenticators do not support verification.

attestation controls how much detail the authenticator reports about itself at registration. None is the right default unless you have a specific reason to track which authenticator models your users register (some regulatory frameworks require this for hardware-key deployments). Direct records the attestation statement; the trade-off is privacy (the authenticator vendor is identifiable from the attestation).

resident_key controls whether the authenticator stores the credential identifier on-device (a resident key, or "passkey"), or whether the credential id is server-side and the authenticator only stores the key material. Required is what makes a passkey: the user does not have to type a username, because the authenticator holds the credential id and surfaces it directly to the browser. Preferred allows either, with the device's preference deciding. Discouraged is the legacy mode where the server provides the credential id list.

The passkey-or-not choice is the most consequential for usability. Passkeys are what users mean today when they say "biometric login": the user clicks a button, taps their fingerprint, and they are in. Non-resident credentials require the user to enter a username first, which is the older WebAuthn flow and what most existing TOTP-style second factors look like. New deployments should default to passkeys; older deployments adopting WebAuthn alongside existing flows often start with non-resident and migrate.

Storage

Each registered credential is one row in the factor store. The row carries:

  • The credential id (a byte string, base64-encoded for storage).
  • The public key (the bytes WebAuthn returns at registration).
  • The signature counter (used to detect credential cloning).
  • The attestation statement (if attestation was set above None).
  • The user verification flag from registration.
  • The authenticator transports list (USB, NFC, internal, hybrid).

The signature counter is the part that catches credential cloning. WebAuthn authenticators increment the counter on each successful signature. The server stores the counter at registration and updates it at each authentication. A counter that fails to increase between authentications indicates the credential has been cloned (the clone's counter started from the same value as the original and diverged on first use). The defence is conservative: revoke the credential and require re-registration.

The credential is scoped per user (each user has zero or more registered credentials), which is the natural per-user scope from the chapter Scope hierarchy. A user with multiple authenticators (a phone passkey plus a hardware key, say) has multiple credentials under their scope; the authentication ceremony enumerates them and the user's authenticator (or the browser, in the passkey case) picks one.

Adding passkeys to an existing flow

A common rollout is to keep an existing password-and-TOTP method and to offer passkey enrolment as an opt-in. The method shape:

let passkey_or_password = Method {
    name: "passkey-or-password".into(),
    steps: vec![
        FactorStep::AnyOf(vec![
            FactorKind::Fido2,
            FactorKind::Password,
        ]),
        // When Password is chosen, demand a second factor.
        // Implementing this conditional path takes a richer state
        // machine; the common simplification is two methods.
    ],
};

The conditional in the second step (require TOTP only if the user took the password branch) is the part axess does not natively support, because FactorStep does not nest. Two parallel methods handle the case more cleanly: one method (passkey-only, single-step FIDO2) for users with a registered passkey, and another method (password-then-totp, two-step password-and-TOTP) for users without one. The scope hierarchy chooses the right method per user. When a user enrols a passkey, the application updates their user-scoped method to passkey-only; if the passkey is later revoked, the application reverts to password-then-totp.

The pattern keeps both flows in production simultaneously, lets each user transition independently, and avoids the conditional in the state machine. The audit log records which method ran for which user, so the rollout is observable.

Threat model

A passkey login is robust against the classes of attack that password-and-TOTP is weak against. Real-time phishing is defeated because the credential is origin-bound at registration. Credential stuffing is defeated because the credential is unique to the relying party. Server-side breach is defeated because what is stored is a public key, not a secret.

Three things remain in the attack surface.

A compromised endpoint. An attacker with full control of the user's device can ask the authenticator to perform any authentication the device permits. The defence here is user-verification: the authenticator must prove the user is present (biometric or PIN). For a deployment where this matters, user_verification: Required is non-negotiable.

Account recovery. A user who loses their passkey needs to recover access; the recovery path becomes the weakest link in the chain. The recommendation is to enrol at least two passkeys (a primary on the phone, a backup on a hardware key, say), and to offer a step-up administrative recovery flow with strong identity verification rather than a password-reset email. The recovery flow gets attacked because the primary login is robust; make sure the recovery is at least as strong.

Sync-fabric credentials. A passkey synced through Apple iCloud Keychain, Google Password Manager, or 1Password is available on every device the user has signed into that sync fabric. This is what makes passkeys usable; it also means a breach of the sync fabric compromises the credential. The implication is operational, not architectural: deployments that must defend against sync-fabric compromise pair the passkey with a device-bound credential (a hardware key, an attestation-bound device passkey), and require the device-bound credential for the highest-sensitivity actions through Cedar policy.

Troubleshooting

Three failures recur during a first integration.

SymptomUsuallyWhat to do
"The relying party ID is not a registrable suffix of the page origin"rp_id does not match the page origin. example.com works from a page on accounts.example.com; attacker.com does notCheck the host part of the URL the browser is actually on
Authentication works on one device, fails on anotherThe credential is a passkey on the first device and was never synced. Some authenticators register non-syncable credentials by defaultCheck resident_key: Required and the authenticator's own documentation
The signature counter check fails for legitimate usersThe authenticator does not implement the counter. Some legacy hardware keys do notLog the mismatch and let the authentication proceed

That last one trades clone detection for usability on those authenticators, so it is a policy decision rather than a fix, and the application makes it explicitly.

Further reading

Factors and methods covers the composition machinery this chapter exercises. Device identity covers the device-bound trust ladder that complements passkeys for high-assurance deployments. Cedar policy fundamentals covers how a policy demands FIDO2 for specific actions (the factors_completed.contains("Fido2") check).

OAuth 2.0 and OIDC

Federated login through an Identity Provider you do not control is the most common reason adopters reach for OAuth. The user has a Google account, an Okta account, a corporate Azure AD account, and you accept a login from any of them rather than asking the user to invent and remember another password. The mechanism is OAuth 2.0 for the authorisation flow and OpenID Connect for the identity assertion layered on top. The division of labour is the thing to hold on to: what axess wires up, and what your integration code still owns.

The feature flag is oauth (off by default), enabled with features = ["oauth", "jwt-rust-crypto"] on the axess facade. oauth transitively enables oidc (the discovery and JWKS-cache machinery) and jwt (the ID token validator), and jwt needs a crypto backend named beside it: jwt-rust-crypto (pure Rust) or jwt-aws-lc (FIPS-capable, needs a C toolchain).

Axess supports generic OIDC-based external login and SSO, including standard providers such as Google and Microsoft Entra ID when configured with the appropriate issuer metadata and client credentials. SAML / Shibboleth federation is not currently supported out of the box.

The shape of the flow

A federated login involves the user, the application (the OAuth client, in OAuth language, which is axess), and the Identity Provider (the OAuth server, which is the third-party IdP). The flow is the authorisation code grant with PKCE, which is what every modern OIDC deployment uses.

sequenceDiagram
    actor User
    participant App as Application (axess client)
    participant IdP as Identity Provider

    User->>App: GET /auth/login/google
    App->>App: build auth URL with PKCE + state + nonce
    App->>User: 302 to IdP authorize endpoint
    User->>IdP: GET /authorize?...
    IdP->>User: login + consent
    User->>App: GET /auth/callback?code=...&state=...
    App->>IdP: POST /token (code + pkce_verifier)
    IdP->>App: { id_token, access_token, refresh_token }
    App->>App: validate ID token (issuer, audience, nonce, signature)
    App->>App: optionally fetch /userinfo
    App->>App: transition session to Authenticated
    App->>User: 302 to /dashboard

Axess owns six pieces of the flow:

  • the PKCE verifier and challenge
  • the CSRF state, generated and bound
  • the OIDC nonce, generated and bound
  • discovery of the IdP's endpoints and signing keys
  • the token exchange
  • ID token validation: signature, audience, nonce, and azp when the audience is multi-valued

Your integration owns three:

  • the redirect to the IdP authorize URL
  • the callback handler that picks up the code
  • mapping validated claims onto a user record in your identity store

Configuring a provider

You do not implement the trait; discover builds a provider from the IdP's own document.

The provider

OAuthProvider is the trait that represents an IdP. The trait is asynchronous because every method may need to fetch JWKS, perform discovery, or hit the token endpoint. Adopters do not implement this trait themselves under normal circumstances; the OAuthProviderConfig constructor in axess-factors produces a provider from a discovery URL plus client credentials, and the returned provider implements the trait.

let provider = OAuthProviderConfig::discover(
    "https://accounts.google.com/.well-known/openid-configuration",
    client_id,
    client_secret,
    "https://your-app.example.com/auth/callback/google".parse()?,
)
.await?;

discover fetches the IdP's discovery document, validates it contains the endpoints axess needs (authorization, token, JWKS, userinfo, sometimes end-session), constructs a Discovery value, and sets up the JWKS cache against the IdP's signing-key endpoint. The cache is single-flight (concurrent JWKS misses dedupe to one request) and debounced (the cache refuses to refresh more often than once every few seconds, defeating a denial-of-service that triggers constant JWKS fetches).

The configuration record carries four things:

  • The client id and secret, both provisioned at the IdP.
  • The redirect URI, where the IdP sends the user after authentication.
  • The ceremony timeout: how long the intermediate state on the session may live before the flow has to restart.
  • The scopes to request. openid and profile at minimum; email if you need the user's email address; offline_access if it needs a refresh token to keep acting as the user after the initial session expires.

Multiple providers

A common shape is to offer login with several IdPs side by side (Google, GitHub, Microsoft). Each provider is its own OAuthProvider instance constructed at startup; your code registers them under a provider_name key. The login URL carries the provider name (GET /auth/login/google); the callback URL also carries the name (GET /auth/callback/google). Axess dispatches to the right provider per request.

A per-tenant variation is also common: each tenant's users federate against the tenant's own IdP (an Okta workspace, an Azure AD directory). The provider name in this case is the tenant slug; the provider is constructed at tenant provisioning time (or lazily, on first use) and cached. The scope hierarchy chapter covers the pattern for storing per-tenant configurations.

The login flow

The three steps a user passes through, in order.

Begin the login

The handler that starts the federated login transitions the session into a state that holds the PKCE verifier, the CSRF state, and the nonce, and returns a redirect to the IdP's authorize URL with those values bound in.

use axess::{AuthnService, AuthSession, OAuthLoginOptions};
use axum::response::{IntoResponse, Redirect};

async fn begin_oauth_login(
    session: AuthSession,
    State(service): State<Arc<AuthnService<...>>>,
    Path(provider_name): Path<String>,
) -> impl IntoResponse {
    match service
        .begin_oauth_login(&session, &provider_name, OAuthLoginOptions::default())
        .await
    {
        Ok(auth_url) => Redirect::to(auth_url.as_str()).into_response(),
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
    }
}

begin_oauth_login does three things:

  1. Generates the PKCE verifier through SecureRng and derives the S256 challenge that travels in the authorize URL.
  2. Generates the CSRF state and the OIDC nonce, also through SecureRng, and stores all three in the session's intermediate state.
  3. Composes the authorize URL from the client id, redirect URI, scopes, challenge, state and nonce, and returns it.

The redirect URI passed at this step must exactly match the one registered with the IdP at provisioning time. A mismatch is the single most common reason a federated login fails out of the box.

Handle the callback

The IdP, on successful user authentication and consent, redirects the user to the registered redirect URI with a code and a state query parameter. The application's callback handler picks these up, verifies the state matches what was stored on the session (defeating CSRF), and calls into axess to perform the token exchange.

async fn finish_oauth_login(
    session: AuthSession,
    State(service): State<Arc<AuthnService<...>>>,
    Path(provider_name): Path<String>,
    Query(callback): Query<CallbackQuery>,
) -> impl IntoResponse {
    match service
        .finish_oauth_login(&session, &provider_name, &callback.code, &callback.state)
        .await
    {
        Ok(_authenticated) => Redirect::to("/dashboard").into_response(),
        Err(e) => (StatusCode::UNAUTHORIZED, format!("{e}")).into_response(),
    }
}

#[derive(serde::Deserialize)]
struct CallbackQuery {
    code: String,
    state: String,
}

finish_oauth_login does seven things:

  1. Reads the PKCE verifier, CSRF state and nonce from the session's intermediate state.
  2. Cross-checks the supplied state against the stored one, returning OAuthError::CsrfMismatch if they disagree.
  3. POSTs to the IdP's token endpoint with the code, verifier, client id and client secret.
  4. Extracts the ID token, access token and optional refresh token.
  5. Validates the ID token: signature against the cached JWKS, issuer, audience, nonce, expiry, and azp when the audience is multi-valued.
  6. Optionally fetches userinfo to supplement the ID token claims.
  7. Transitions the session to Authenticated, or to PendingWorkflow if the federated flow is one step of a longer ceremony such as signup.

If any of the seven steps fails, the function returns an OAuthError variant naming what failed. The session does not transition; the intermediate state is cleared (to prevent replay); the callback handler can render an error.

ID token validation

The ID token validation is where most of the security of an OIDC integration lives. Axess performs the full set of checks RFC 6749 and OpenID Connect Core 1.0 require; the integration code does not have to write them. The checks are:

Signature verification against the IdP's JWKS. The cache holds the current signing keys; if the ID token's kid header does not match a cached key, the cache refreshes (subject to the single-flight and debounce protections). A signature that fails against the refreshed keys produces OAuthError::IdTokenValidation, carrying the underlying reason as a string; a kid the refreshed JWKS still does not contain produces OAuthError::UnknownKid instead, which is the one to alert on because it usually means the IdP rotated keys in a way the cache cannot follow.

The issuer check. The ID token's iss claim must exactly match the discovery document's issuer field. A mismatch indicates either a misconfigured IdP, a discovery-document substitution attack, or an attempt to replay an ID token from a different issuer; all three produce OAuthError::IdTokenValidation. There is no per-check variant: the enum carries one validation error whose string names which claim failed, so match on the variant and log the string rather than branching on the reason.

The audience check. The ID token's aud claim must contain the client's registered client id. If aud is a single value, the check is straightforward. If aud is an array (which happens when the IdP issues tokens valid for multiple clients), the check ensures the client id is in the array, and additionally enforces the azp (authorized party) check: the azp claim must exist and equal the client id, regardless of the array's contents. The azp check defeats a class of attacks where an ID token issued for one client is replayed against a different client whose id is also in the audience array.

The fourth is the nonce check. The ID token's nonce claim must exactly match the nonce that was generated at begin_oauth_login time and stored in the session. The nonce defeats ID token replay: an attacker who captures an ID token cannot reuse it against the same client because the session-bound nonce will not match on a later login.

The fifth is the expiry check. The ID token's exp claim must be in the future at the moment of validation, with a small clock-skew allowance. The clock comes from the injected Clock trait, so DST tests can exercise expiry handling deterministically.

The sixth is iat (issued-at) bounds. The token must have been issued within the last few minutes; tokens older than that indicate replay. The bound is configurable but defaults to five minutes, which matches what RFC 7519 implementations typically use.

Logging out

Two mechanisms, driven from opposite ends.

Back-channel logout

When the IdP supports OIDC back-channel logout, the IdP sends a POST to a registered logout endpoint at your application with a logout_token. The application validates the token and, on success, revokes the user's session.

The validation is similar to ID token validation but slightly different: the audience and issuer checks apply, the azp check applies when audience is multi-valued, and an additional check on the events claim verifies the token is a back-channel logout token (the URI http://schemas.openid.net/event/backchannel-logout must be present). Axess implements this through OAuthProvider::verify_logout_jwt, which returns the claims on success.

The size cap on the logout token is eight kilobytes, the iat bound is five minutes, and the clock-skew tolerance is sixty seconds. The caps protect against denial-of-service through oversize tokens; the bounds defeat replay of a captured logout token after a meaningful delay.

RP-Initiated Logout

The opposite direction is RP-Initiated Logout: you initiates a logout that propagates to the IdP, so the user is logged out of the IdP session as well as your own. Axess constructs the end-session URL through OAuthProvider::build_end_session_url, which takes the ID token hint (the user's last issued ID token, signed by the IdP), an optional post_logout_redirect_uri (where to send the user after logout), and an optional state value.

The post_logout_redirect_uri must be on an allowlist that the application configures. The allowlist exists to defeat open-redirect attacks: an attacker who can manipulate the redirect URI could send the user to an arbitrary external site after logout, which is the shape of a phishing setup. The allowlist is a small explicit list of allowed URIs; anything else is rejected at build_end_session_url time.

Threat model

OAuth and OIDC together are robust against a handful of attacks when the implementation does the validations above correctly.

Against CSRF on the callback: the state parameter binds the callback to the session that started the login. An attacker who tricks a user into hitting the callback URL with a stolen code cannot complete the login because the state will not match.

Against ID token replay: the nonce binds the ID token to the session's login attempt. An ID token captured by an attacker cannot be replayed against a different session.

Against ID token forgery: signature validation against the JWKS catches an attacker who synthesises an ID token without the IdP's signing key.

Against audience confusion (an ID token issued for one client used against another): the audience check plus the azp check on multi-element audiences catch this.

Against authorization code interception: PKCE binds the code to the verifier you generated. An attacker who intercepts the code cannot exchange it without the verifier.

Against open-redirect phishing on logout: the allowed_post_logout_redirect_uris allowlist catches an attacker who tries to manipulate the redirect URI.

The attacks OAuth and OIDC do not defend against are the ones FIDO2 defends against (real-time phishing of the IdP login page itself) and the ones that depend on the IdP's own security posture (a compromised IdP issues compromised tokens, and no client-side check catches that). The defence for the latter is operational: monitor which IdPs you accept, audit periodically, and rotate the registered client secret if the IdP suffers a breach.

Troubleshooting

A few failure modes recur during initial integration.

If the callback returns an error about state mismatch, the most likely cause is that the user took longer than the ceremony timeout to complete the IdP login. The intermediate state on the session has expired and the state value is no longer recoverable. Increasing the ceremony timeout (a generous fifteen minutes is reasonable) is the fix.

If the token exchange returns an invalid-client error, the client id or secret in OAuthProviderConfig does not match what the IdP has registered. The most common variant is using a public-client id at the IdP while configuring axess with a confidential-client expectation (or vice versa). Check the IdP's client registration page.

If the ID token validation returns an audience mismatch on an IdP that supports multiple clients, the aud claim is probably an array and the azp claim is missing. Some IdPs do not emit azp when they should; configuring the IdP to issue azp is the fix. Axess deliberately refuses to bypass the azp check because doing so would open the audience-confusion attack.

If the userinfo endpoint returns a 401 after a successful token exchange, the access token's scopes do not include the ones the userinfo endpoint requires. The fix is to add the required scopes (typically profile and email) to the scopes configuration.

Further reading

FAPI 2.0 covers the financial-grade extensions that layer on top of the OAuth provider for regulated deployments: PAR (pushed authorization requests, which send the authorize parameters server-to-server instead of through the browser), DPoP (demonstrating proof of possession, which binds a token to a key the client holds), and JARM (JWT-secured authorization response mode, which signs the IdP's response back to the client). Workload identity overview covers the inbound resolver side of the same machinery, where your service is the OAuth server accepting tokens issued by federated workload-identity systems. Local IdP covers the in-process IdP, both production LocalIdp for workload-identity issuance and the LocalIdpFixture that mints test tokens against a controllable JWKS for integration tests.

FAPI 2.0

FAPI is the OpenID Foundation's Financial-grade API profile, a set of additional requirements on top of OAuth 2.0 and OIDC that address the threat model of regulated financial APIs. The headline differences from baseline OAuth are mandatory Pushed Authorization Requests (PAR), mandatory sender-constrained tokens through DPoP or mTLS, optional JWT Authorization Response Mode (JARM), and stricter ID token lifetime bounds.

The feature flag is fapi (off by default), which implies oauth. The base OAuth chapter (OAuth 2.0 and OIDC) covers everything that remains true under FAPI; this chapter covers only what changes.

Whether FAPI applies to you

What the profile is, what it demands, and who needs it.

Axess is the Relying Party, not the OP

A FAPI deployment has two parties. The OpenID Provider (OP, also called the IdP) owns user identity, runs the login UI, and issues tokens; in open-banking this is typically the bank's own SSO or a hosted Keycloak / Ory Hydra / Curity instance. The Relying Party (RP) is the application that delegates identity to the OP, accepts the resulting tokens, and runs a session on top. Axess fills the RP role. PAR, DPoP, JARM, and RP-Initiated Logout are all RP-side protocols that exist to talk to an external OP; without an OP to talk to, none of them make sense.

This is a deliberate architectural choice. Building a FAPI-conformant OP is a multi-year project (Keycloak, Hydra, Curity, and the commercial vendors are the established options) and is largely disjoint from the RP-side machinery axess provides (sessions, MFA verifiers, Cedar authorization). The verifier-vs-orchestrator split in the workspace (covered in Architecture at a glance) is the internal expression of the same boundary; axess does not become the OP, and adopters are expected to point at one. The examples/fapi/ crate ships a pre-configured Keycloak realm in a podman container as a quick way to get an OP locally for the demo, but in production the issuer URL would point at whatever OP your organisation already runs.

The local-idp feature is the one place axess does issuance, but that is on-host workload-identity issuance (service-to-service flows where a sidecar mints JWTs for its own workloads), not a user-facing OP. Local IdP covers that surface.

What FAPI changes

Four mechanisms, each closing a specific gap in baseline OAuth.

PAR: parameters off the redirect URL

Pushed Authorization Requests (RFC 9126) POST the authorization parameters straight to the IdP, which returns an opaque request_uri. The authorize URL then carries only the client id and that reference.

Two gaps close at once: the parameters never reach browser history or a referer header, and the user cannot tamper with what they are only holding a reference to.

DPoP: tokens bound to a key

DPoP (RFC 9449) binds the access token to a key pair the client holds. Every request carries a JWT signed with that key, and the resource server checks the access token was issued for its thumbprint.

That defeats bearer-token theft. An attacker who lifts the token from logs, a misconfigured proxy or a debugging surface cannot use it without the private key, which never leaves the client.

JARM: signed authorization responses

The IdP returns the authorization response as a signed JWT rather than query parameters, so it cannot be tampered with after issuance. Optional in FAPI 2.0; some implementations use it, some do not.

Short ID token windows

FAPI 2.0 requires nbf to be enforced and the lifetime kept short. Axess defaults to five minutes and refuses an ID token whose nbf is in the future or whose exp is more than five minutes out. That closes replay through stale tokens.

When to reach for FAPI

The honest answer is: when a regulator requires it. FAPI 2.0 was designed for the open-banking ecosystem and similar regulated financial APIs, and adopting it imposes operational complexity (every client needs DPoP key management, every authorize call goes through PAR, every IdP must support the PAR endpoint) that is substantial relative to the security benefit for non-regulated deployments. A consumer-facing SaaS that takes credit card payments through Stripe does not need FAPI; an open-banking application that acts as an account-information service provider does.

The decision is binary: either you need FAPI because someone is asking you for compliance evidence, or you do not. If you do, the mechanisms below are non-negotiable, and axess implements them. If you do not, the baseline OAuth chapter covers what you need.

The mechanisms

The four things FAPI adds over ordinary OAuth.

Configuration

FAPI is enabled per-provider by attaching a FapiConfig to an OAuthProviderConfig:

use axess::federation::oauth::{FapiConfig, SenderConstraint, OAuthProviderConfig};

let fapi_config = FapiConfig {
    sender_constraint: SenderConstraint::DPoP,
    require_jarm: false,
    max_id_token_lifetime_secs: 300,
};

let provider = OAuthProviderConfig::discover(
    "https://idp.example.com/.well-known/openid-configuration",
    client_id,
    client_secret,
    redirect_uri,
)
.await?
.with_fapi(fapi_config);

sender_constraint chooses between DPoP and mTLS for the sender-constrained-tokens requirement. DPoP is the right choice for applications that already manage HTTPS in software; mTLS is the right choice for applications that already manage X.509 certificates for service-to-service authentication. The two cannot be combined on a single provider, but different providers in the same application can use different constraints.

require_jarm toggles JARM enforcement. When true, the authorization response from the IdP must arrive as a signed JWT; the configuration's oidc.discovery.jwks_uri is used to verify the signature. When false, the IdP may return the response as query parameters as in baseline OAuth.

max_id_token_lifetime_secs is the upper bound on ID token validity. The FAPI default is three hundred seconds (five minutes), which is short enough that a captured token expires before most replay attacks can succeed and long enough that clock skew does not cause spurious rejections.

The PAR flow

With FAPI enabled, you start a federated login through the PAR-enhanced auth URL rather than the query-parameter auth URL:

let auth_url = service
    .begin_oauth_login(&session, "fapi-provider", OAuthLoginOptions::default())
    .await?;
// auth_url looks like:
//   https://idp.example.com/authorize?client_id=...&request_uri=urn:ietf:params:oauth:request_uri:...

Internally, begin_oauth_login detects the FAPI configuration and takes the PAR branch. The branch performs a POST to the IdP's PAR endpoint with the full set of authorization parameters (client id, redirect URI, scopes, PKCE challenge, CSRF state, nonce), receives the request_uri and its expires_in, and constructs the shorter authorize URL the user is redirected to.

The PAR exchange happens server-to-server and is authenticated. The authentication is whatever the IdP requires (client secret POST, client secret basic, mTLS, or signed JWT assertion); axess passes through the credential that OAuthProviderConfig was constructed with.

The callback flow on your side is unchanged. The IdP redirects the user back to your callback URL with a code; you call finish_oauth_login with the code and state; axess performs the token exchange and ID token validation.

DPoP key management

DPoP binds each access token to a public key the client controls. The application generates a key pair at session start (or at application start, for some deployments), uses the private key to sign a DPoP proof JWT on each request to a protected resource, and the resource server verifies the proof and matches the JWK thumbprint against the access token's binding.

Axess exposes the proof-generation primitive through OAuthProvider::generate_dpop_proof:

let proof: DpopProof = provider.generate_dpop_proof(
    "GET",                                         // HTTP method
    "https://resource.example.com/data",           // target URL
    Some(&access_token),                           // bind to this access token
    &dpop_key,                                     // your key
)?;

let response = http_client
    .get("https://resource.example.com/data")
    .header("Authorization", format!("DPoP {}", access_token))
    .header("DPoP", &proof.proof_jwt)
    .send()
    .await?;

The proof JWT contains the HTTP method, the target URL, a nonce, a timestamp, and the thumbprint of the binding key. The resource server checks all of these against the access token's cnf (confirmation) claim, which carries the thumbprint at token issuance.

Key lifecycle is the operational concern. A DPoP key pair generated per session is the safest choice (a compromised session is bounded to one key); a key pair generated per application instance is the easiest choice (one key to manage). The trade-off is between blast radius and operational complexity. Most deployments choose per-session keys for high-sensitivity flows and per-instance keys for routine flows.

Token revocation

FAPI 2.0 expects that compromised tokens can be revoked through the IdP's revocation endpoint (RFC 7009). The application calls revocation when the user logs out, when a session is administratively ended, or when token theft is detected. Axess exposes revocation through OAuthProvider::revoke_token:

provider.revoke_token(&access_token, Some("access_token")).await?;
provider.revoke_token(&refresh_token, Some("refresh_token")).await?;

The hint is the RFC 7009 string, passed through verbatim rather than through an enum, because an IdP may accept hints the RFC does not list. Passing None is legal and makes the IdP search both token types. Providers with no revocation endpoint return OAuthError::Config rather than silently succeeding, so a deployment cannot believe it is revoking when it is not.

The revocation endpoint, when present in the discovery document, is called with the token to revoke and an optional type hint. The IdP responds with a 200 regardless of whether the token was actually revoked (intentionally, to defeat token-existence enumeration).

Revoking the refresh token is the more important call. The access token typically has a short lifetime (matching the FAPI ID token bound) and expires on its own; the refresh token has a longer life and an unrevoked one allows continued access through new access tokens. A logout that revokes only the access token leaves the refresh token active, which is rarely what you want.

Testing FAPI flows

There are three useful test modes, picked by what you want to exercise.

For Rust unit and integration tests, the FAPI feature pairs with the local-idp feature. The LocalIdpFixture in axess-core::testing::local_idp mints FAPI-grade tokens with the right nbf/exp bounds and exposes a shared JwkSet handle that a JwtVerifier borrows for signature verification. The fixture is an in-process value, not an HTTP service: PAR and discovery endpoints are not part of its surface. For FAPI flows that need a real PAR exchange, use Keycloak or another OP (see the end-to-end walkthrough below). The pattern for unit tests is to write against an OAuthProvider trait object, parameterise it over fixture and live, and run both in CI. Local IdP covers the fixture in detail.

For an end-to-end browser walkthrough, the examples/fapi/ crate ships with a pre-configured Keycloak realm under examples/fapi/keycloak/. One podman compose up -d brings up Keycloak with PAR required, PKCE S256 required, DPoP-bound tokens enabled, the axess-fapi-client client registered, and a seeded user (alice/alice) ready to log in. The example's OAuthProviderConfig::discover(...) call points at the local Keycloak issuer through env vars, and the same code talks to a real production IdP when those env vars point elsewhere. Docker users can substitute docker compose for podman compose; podman is the documented path.

For compliance certification, the OpenID Foundation runs a free hosted conformance suite at https://www.certification.openid.net/. It acts as a scripted OP that drives an RP through the full FAPI 2.0 test matrix including adversarial cases (missing PAR, bad DPoP, replay, wrong audience). Point it at the example's /auth/callback to produce a certifiable artifact; use Keycloak for everyday development.

Threat model

FAPI 2.0 closes the attacks baseline OAuth leaves open in regulated contexts.

Against authorization-parameter tampering: PAR moves the parameters off the URL, so they cannot be modified by an intermediary.

Against bearer-token theft: DPoP (or mTLS) binds tokens to keys the attacker does not have, so a captured token is unusable.

Against ID token replay through stale tokens: the strict lifetime bound shrinks the replay window to minutes.

The attacks FAPI does not close are the same ones baseline OAuth does not close: a compromised IdP issues compromised tokens regardless of the profile, and a compromised client device gives the attacker access to the DPoP private key alongside everything else.

Troubleshooting

If the PAR exchange fails with invalid_client, your PAR endpoint authentication does not match what the IdP expects. Some IdPs require mTLS authentication on PAR even when the rest of the flow uses client secrets; check the IdP's PAR documentation.

If DPoP verification fails at the resource server, the most common cause is a clock-skew issue between the client and the resource server. The DPoP proof's timestamp is checked within a small window (a few seconds typically); larger skew triggers spurious failures. Synchronise both sides against the same NTP source.

If JARM verification fails, the signing key the IdP uses for JARM may differ from the key used for ID token signing. Some IdPs publish separate JWKS for the two; the discovery document should indicate this, but configurations occasionally miss it. Inspect the discovery document.

Further reading

OAuth 2.0 and OIDC covers the base OAuth machinery this chapter extends. Workload identity overview covers the resolver side of OAuth, where axess is the resource server rather than the client. Local IdP covers the test fixture for FAPI-grade integration testing.

LDAP bind

LDAP bind is the right factor for enterprise deployments where the authoritative user store is Active Directory, OpenLDAP, or a similar directory server. The application does not own user passwords; the directory does. The verification mechanism is a simple bind against the directory with the user's distinguished name and password; if the bind succeeds, the user has authenticated.

The feature flag is ldap (off by default), enabled with features = ["ldap"] on the axess facade.

When LDAP fits

LDAP fits when three conditions hold:

  • The authoritative user identities live in an LDAP directory you can reach.
  • The directory administrators have agreed to allow simple binds from your deployment network.
  • The directory speaks LDAP, rather than a protocol that wraps LDAP semantics. SAML and OIDC route through the OAuth factor instead.

When those conditions hold, LDAP gives you authentication as a service from the directory, and you never store a user password. New employees added to the directory can log in immediately; departed employees removed from the directory lose access immediately. The directory is the source of truth.

When those conditions do not hold (a SaaS deployment where users come from many organisations, a directory you cannot reach over a stable network, an authoritative store that is not LDAP), the right answer is OAuth or OIDC against an IdP that the organisation does support.

Configuration

LdapProviderConfig carries the connection details:

pub struct LdapProviderConfig {
    pub url: String,                          // ldaps://ad.example.com:636
    pub bind_dn_template: String,             // "uid={user},ou=people,dc=example,dc=com"
    pub starttls: bool,                       // upgrade ldap:// to TLS via STARTTLS
    pub connection_timeout: Duration,         // typical 5-10 seconds
    pub group_search: Option<LdapGroupSearch>,
}

url is the directory's URL. The ldaps:// scheme means TLS is established at the transport layer (port 636 by default); the ldap:// scheme means cleartext, possibly upgraded to TLS via STARTTLS. Cleartext without STARTTLS is acceptable only on a private network where the directory traffic does not leave a trusted segment; production deployments use one of the encrypted forms.

bind_dn_template is the pattern axess uses to construct a user's distinguished name from their login identifier. The string {user} in the template is replaced with the identifier the user typed. The example above turns the username alice into the DN uid=alice,ou=people,dc=example,dc=com, which is then used in the bind request.

starttls triggers a STARTTLS upgrade after the initial cleartext connection establishes. The mechanism is widely supported and is the right choice when the directory accepts both cleartext and TLS on the same port (usually 389). When the directory exposes a separate TLS port (usually 636), use ldaps:// instead and leave this false.

connection_timeout bounds how long a bind attempt may take. Five to ten seconds is typical. Longer timeouts admit slow failure modes into the login path; shorter timeouts produce spurious failures when the directory is briefly slow. Tune to match the directory's observed latency.

group_search is optional. When set, after a successful bind axess performs an additional search to enumerate the user's group memberships. The result is returned alongside the bind outcome and can be used to populate the user's authorisation attributes.

pub struct LdapGroupSearch {
    pub base_dn: String,           // "ou=groups,dc=example,dc=com"
    pub filter_template: String,   // "(member={dn})"
    pub group_attr: String,        // "cn" -- attribute identifying the group
}

filter_template interpolates {dn} (the bound user's DN) or {user} (the original identifier) into an LDAP filter. The example filter (member={dn}) matches groups that list the user's DN in their member attribute, which is the OpenLDAP convention. Active Directory typically uses memberOf on the user record itself instead, in which case the group search is unnecessary because the groups are already attributes of the user.

The verification flow

The user submits a username and password to your handler, which calls RequestAuthnService::verify_factor with the LDAP bind credential; axess expands the bind DN template with the username, opens a TLS connection to the directory, performs a simple bind with the constructed DN and the user's password, optionally searches for groups, and unbinds.

A successful bind transitions the session as any factor would: the state machine calls advance_factor, which returns Completed if LDAP was the last required factor or StillAuthenticating if more factors are required. A failed bind returns FactorOutcome::InvalidCredential, and the user sees the standard failed-login message.

The connection model is per-attempt. Each bind opens a fresh TLS connection, performs the bind, and closes. There is no connection pooling. The trade-off is operational simplicity (no pool to size, no idle-connection management) against per-attempt latency (a TLS handshake on each login). For most deployments the latency is acceptable; busy directories with thousands of binds per second benefit from a connection pool at the network layer (HAProxy, nginx) rather than in your process.

Mixing LDAP with other factors

LDAP can be the only factor in a method (the directory's bind is the entire authentication), or it can be one factor in a chain.

A common shape in enterprise deployments is LDAP followed by TOTP. The user enters their LDAP credentials, the directory verifies them, and then axess prompts for the user's TOTP code. The TOTP secret is stored in axess's own factor store (not in LDAP), under the user's scope. The combination gives directory-managed passwords with an application-managed second factor; the directory does not need to know about TOTP, and you do not need to know about the password.

A variation is LDAP followed by AnyOf(vec![Totp, Fido2]), allowing the user to register a passkey alongside or instead of TOTP. The flow is otherwise unchanged.

Threat model

LDAP bind is robust against the same attacks any second-factor method is robust against: credential reuse from other services, local password lists, offline brute-forcing of a stolen hash (the hash never leaves the directory).

It is weak against attacks the directory itself is weak against. A directory that allows anonymous binds is vulnerable to attribute enumeration. A directory whose bind path is misconfigured to accept empty passwords for any DN is catastrophically vulnerable. The defence is operational: configure the directory correctly, audit periodically, and treat the LDAP factor's security as a function of the directory's security posture.

The application also has to be careful about what it logs. The bind password should never appear in application logs at any level, including trace. Axess does not log it; adopters' own login handlers need to make the same guarantee. The standard pattern is to mark the password field as zeroized and to route it directly into the verifier without touching it again.

Troubleshooting

SymptomUsuallyWhat to do
"Invalid credentials" for passwords you know are goodThe bind DN template. Active Directory usually expects userPrincipalName (an email address) or sAMAccountName (a short login name), not a constructed DNTry {user}@example.com in place of uid={user},ou=people,dc=example,dc=com
The connection opens, then the bind times outThe directory is loaded, or a middlebox is inspecting the connection and buffering slowly. The timeout fires and the user sees a generic failureInspect the network path
The group search comes back emptyA wrong filter_template, or a bound user without permission to read group membership. OpenLDAP often wants an explicit ACL for a user to enumerate their own groups; Active Directory usually grants itRun the same search through a known-good LDAP client
TLS fails on certificate validationThe directory's certificate is signed by a private CA your trust store does not carryAdd the CA through SSL_CERT_FILE or SSL_CERT_DIR

Further reading

Factors and methods covers the composition machinery this chapter exercises. Identity store implementation covers how user records referenced by LDAP get provisioned in your identity store (typically just-in-time on first successful LDAP login). Multi-tenancy covers the case where different tenants federate to different directories.

Cedar policy fundamentals

Most application authorisation is the if user.role == "admin" style: a check scattered across handlers, expressed in code, written by whoever happened to be in the file at the time, with no shared schema and no way to review the policy as a whole. The pattern works for small applications and fails for everything else, because the authorisation logic is the part of your system that needs the most review and is also the part most likely to drift.

Cedar is a policy language designed for this exact problem. It is declarative, deny-by-default, statically checkable against a schema, and built to express RBAC, ReBAC, and ABAC in one set of rules. Axess loads a Cedar policy set at startup, validates it against a schema, and exposes per-request evaluation through a small typed interface.

The feature flag is authz (on by default in the axess facade).

The lifecycle

Cedar in axess has three lifecycle phases: load, evaluate, redeploy. Each phase has a specific failure mode, and the design is built so the failures land at the right place.

The load phase happens once at application startup. The application constructs a PolicyStore from one or more policy files, validates the parsed policies against a schema, and produces an AuthzStore that holds the result. A load failure (a malformed policy, a type mismatch against the schema, an action that references an undefined entity) is a startup failure: the process refuses to start. The defence is structural: there is no path to production with a broken policy file, because the process refuses to come up.

The evaluate phase happens once per authorisation check. The application constructs an AuthzSession from the AuthzStore, a Principal (typically extracted from the session or from a workload-identity resolver), an AuthzEntityProvider that supplies your entity graph for this request, and a context (MFA status, IP address, your custom attributes). The session offers two verbs: require (allow or deny, returning an error on deny) and decide (a typed AuthzDecision). The evaluation is cheap, predictable, and deterministic.

The redeploy phase happens when policies change. The application loads a new PolicyStore from the new policy files, swaps it in behind the AuthzStore's Arc, and from the next request onward new evaluations use the new policies. A hot reload of policies is supported; the trade-off is that decisions in flight at swap time see the old policies and decisions started after see the new policies. There is no decision-caching layer in axess for this reason: a cached decision from before a redeploy would survive into the new policy regime and produce wrong answers. The chapter Entity providers and request context expands on what does and does not get cached.

Loading policies

The minimal load is a directory of .cedar files plus a schema.cedarschema file:

use axess::authz::{AuthzStore, PolicyStore};

let policy_store = PolicyStore::load_directory("./policies")?;
let schema = std::fs::read_to_string("./policies/schema.cedarschema")?;
policy_store.validate_against(&schema)?;

let authz_store = AuthzStore::new(policy_store);

The load is recursive: every .cedar file under the directory is parsed and added to the policy set. Cedar policies have no import or namespace mechanism beyond the entity-type namespace; the collection of all files is the policy set, evaluated as one.

validate_against is the call that catches malformed policies before they reach production. The validator checks that every entity type the policies reference is defined in the schema, that every attribute access is on an attribute the schema declares, and that the types align (a policy that asks principal.age > "old" gets caught because the schema declares age as a number and the literal is a string).

The schema is its own discipline. Writing a schema that accurately describes your entities is the hardest part of a Cedar integration. The schema names the principal types (User, Workload, Role, Group), the action types (read, write, administer), the resource types (your domain objects), and the parent relationships (a User is in Groups, which are in Roles, which permit Actions). The Cedar documentation covers schema authoring in detail; the chapter here focuses on what axess does with a schema once it has one.

The per-request evaluator

The AuthzSession is constructed per request and lives only as long as the request:

// One `AuthzStore` for the process, built from the policy set, the
// schema and your entity provider. It is held in an `Arc`.
let session = authz_store.for_user_id_with_context(
    &user_id.to_string(),
    StandardRequestContext::new(mfa_verified, client_ip),
)?;

if session.is_permitted("View", &doc_id).await {
    proceed()
} else {
    render_forbidden()
}

for_user_id binds the caller. It turns the user id into the Cedar User UID your schema declares, and the session carries that UID for every check it makes. Use for_user_id_with_context when policies need request attributes; for_user_id alone gives an empty Cedar context.

The entity provider is bound to the store, not the session, because it is process-wide application code rather than per-request state. It is what loads the relevant entities (the user record, their group memberships, the resource being accessed, its parents) for each evaluation. The next chapter, Entity providers and request context, covers the contract in detail.

StandardRequestContext::new(mfa_verified, ip_address) covers the common context keys. Applications needing more implement BuildRequestContext themselves and pass their own type; the session is generic over it.

The resource is your provider's ResourceId, not a Cedar UID. You pass the id your application already has, and the provider's resource_uid turns it into the UID the policies match on. The action is a plain &str naming the action in your schema.

The session caches entities per (action, resource) for its lifetime, so a handler that checks the same pair twice pays for the provider once.

There are three verbs, and no method returns an error for a denial.

require(action, resource) returns Result<(), AuthzDenied>, so a handler can ? it and let a deny become a 403. AuthzDenied is a unit type: it says access was refused, and deliberately says nothing about why, because the reason is exactly what an attacker probing policies would like to learn. Log the detail on your side of the call.

is_permitted(action, resource) returns a plain bool, for code that needs a non-binary outcome: a UI that hides a button rather than showing it and denying on click, an admin panel listing what this user could do.

batch_check(&[(action, resource)]) evaluates several pairs and returns Vec<(String, AuthzDecision)>, sharing the session's entity cache across them. Use it to answer "which of these may I do?" in one pass instead of a loop of is_permitted.

All three are async because the provider is: loading entities usually means a database round trip.

// require version: handler short-circuits on deny
async fn delete_document(
    session: AuthzSession,
    Path(doc_id): Path<String>,
) -> Result<Json<()>, AppError> {
    session.require("Delete", &doc_id).await?;
    // ... proceed with delete
}

// is_permitted version: branch on the boolean
async fn dashboard(
    session: AuthzSession,
) -> impl IntoResponse {
    let can_create_doc = session.is_permitted("Create", &template_id).await;
    render_dashboard(can_create_doc)
}

The wildcard resource UID in the second example is a Cedar convention for "is the principal allowed to perform this action at all?"; it relies on the policy set being written with that question in mind.

What policies cannot do

Cedar is the right tool for asking "is this allowed?". It is not the right tool for everything that pattern-matches like authorisation but is actually something else.

It is not for rate limiting. Rate limits are stateful (they depend on the rate of past requests, not the content of the current request), expensive to express in declarative terms, and not what Cedar is built for. Use the RateLimitLayer middleware (covered in Rate limiting).

It is not for input validation. A request with an invalid body fails at deserialisation, not at authorisation. Cedar policies that try to enforce body-shape constraints duplicate validation logic and run after the body has already been parsed.

It is not for state transitions. A workflow that allows a transition from Pending to Approved but not from Pending to Closed is a state machine, not a policy. Implement the state machine in code (or in a axess-style typed state machine for the workflow); use Cedar to gate access to the transition operations.

It is not for caching decisions across requests. Policies and entity graphs are mutable; cached decisions are stale by construction. Axess deliberately caches entity graphs (which are much more stable) and not decisions.

The next chapter, Entity providers and request context, covers the entity-graph caching mechanism and the contract between Cedar and your data layer.

Further reading

Entity providers and request context covers the AuthzEntityProvider trait, the StandardRequestContext extension points, and the caching posture. RBAC, ReBAC, and ABAC patterns walks through worked examples of each style and how they compose in one policy set. The principal model covers the principal types the evaluator binds to.

Entity providers and request context

A Cedar policy evaluates against three inputs: a principal, an action, a resource, plus an entity graph that gives the policies the data they need to reason about (which roles the principal is in, which group owns the resource, what the principal's MFA status is). The policy set is loaded once at startup. The principal and action come from the request. The entity graph and the request context come from you, per request, through two interfaces this chapter covers: the AuthzEntityProvider trait and the StandardRequestContext extension surface.

Doing both of these well determines whether the Cedar integration holds up under load. A naive entity provider that loads an entire user's group membership on every request will be the slowest part of the request lifecycle. A request context that omits an attribute a policy expects produces denies that are hard to debug. The shapes below avoid both failure modes.

The contract

What you must supply, and how much of it.

The entity provider contract

AuthzEntityProvider is the trait you implement. The job is to take a request's principal and resource UIDs, and return a Cedar entity graph rich enough that the evaluator can answer the policy questions:

pub trait AuthzEntityProvider: Send + Sync {
    /// How your application names a resource: a `String` id, a typed
    /// key, whatever the domain uses.
    type ResourceId: Send + Sync;
    type Error: std::error::Error + Send + Sync + 'static;

    fn entities_for(
        &self,
        principal: &EntityUid,
        resource_id: &Self::ResourceId,
        action: &EntityUid,
    ) -> impl Future<Output = Result<Entities, Self::Error>> + Send;

    /// Turn one of your resource ids into the Cedar UID policies match on.
    fn resource_uid(&self, id: &Self::ResourceId) -> Result<EntityUid, AuthzError>;
}

The provider receives a Cedar EntityUid for the principal, one resource id, and the action EntityUid. It returns Cedar's own Entities, the typed entity graph the evaluator reads: each entity carries a UID, a map of attributes, and a set of parent UIDs.

Three things about that signature shape an implementation.

The resource is singular, not a slice. One call answers one authorization question, so the provider loads exactly what this decision needs and nothing more.

The principal arrives as an EntityUid, not as a Principal. Its id() is the string you look up, and its type is whatever your schema calls a principal. That keeps the provider a translation from your storage into Cedar's vocabulary, with no branch on human versus workload unless your schema has one.

action is passed so a provider can load less when the action does not need it. Many providers ignore it; _action in the signature is a perfectly good implementation.

The contract is "return enough to answer the policies, no more." An entity set that omits an entity a policy references denies at evaluation time, quietly, because a decision has no error to return. An entity set that includes hundreds of entities the policy never touches wastes the database time. The right shape is the minimum set the policies need for this request.

What "enough" means

The policies that the evaluator runs against the entity set typically need a few categories of data.

The principal's parents. Every role the principal is in, every group they belong to. A policy that says principal in Role::"finance-viewer" needs the principal's parents list to include Role::"finance-viewer" if the principal is in that role. The provider populates this from your role-and-group store.

The principal's attributes. The user's tenant id, MFA status, factors completed, custom attributes the policies use. Many of these are already on the Principal value; the provider attaches them as Cedar attributes on the principal entity.

The resource's parents. The tenant that owns it, the project it belongs to, any logical grouping the policies might match against. A policy that says resource in TenantData::"acme" needs the resource's parents list to include TenantData::"acme" if the resource belongs to that tenant.

The resource's attributes. The owner, the visibility setting, the classification level, anything the policies need. The provider populates these from the resource's row.

The principal's relationships to the resource. A ReBAC policy that matches resource.owner == principal needs the resource's owner attribute to equal the principal's UID. If the resource is shared with the principal through a separate sharing record, the provider either expresses it as an attribute on the resource (a shared_with list) or as a parent (the principal is in a "viewers" group attached to the resource).

The application's data model is the source of truth for all of this; the provider's job is to shape the data into Cedar's vocabulary.

Building one

A concrete implementation, and where to put a cache.

A worked provider

This is the provider from examples/authz, included from the file CI compiles, so what you read here cannot drift from something that builds. It reads its rows from an in-memory map so the example needs no database; a provider backed by Postgres issues the equivalent queries and the shape of what follows is identical.

impl AuthzEntityProvider for DocEntityProvider {
    /// Resources are identified by document ID string.
    type ResourceId = String;
    type Error = ProviderError;

    /// Build the Cedar entity set for a single authorization check.
    ///
    /// Returns:
    /// - The User entity with its Role parents
    /// - The Role entities
    /// - The Document entity with its `owner` attribute
    async fn entities_for(
        &self,
        principal: &EntityUid,
        resource_id: &String,
        _action: &EntityUid,
    ) -> Result<Entities, Self::Error> {
        let mut entities = Vec::new();

        // 1. Extract user ID from the principal UID.
        let user_id = principal.id().as_ref();

        // 2. Build Role entities and collect parent UIDs for the user.
        //    The lookups here read an in-memory map so this example
        //    compiles with no database. A real provider issues the
        //    equivalent queries (`SELECT role_name FROM user_roles WHERE
        //    user_id = $1`) and the shape of what follows is unchanged.
        let role_names = self
            .data
            .user_roles
            .get(user_id)
            .cloned()
            .unwrap_or_default();

        let mut role_uids = HashSet::new();
        for role_name in &role_names {
            let role_uid = self.make_uid("Role", role_name)?;
            let role_entity = Entity::new(role_uid.clone(), HashMap::new(), HashSet::new())?;
            entities.push(role_entity);
            role_uids.insert(role_uid);
        }

        // 3. Build the User entity with Role parents.
        let user_entity = Entity::new(principal.clone(), HashMap::new(), role_uids)?;
        entities.push(user_entity);

        // 4. Build the Document entity with `owner` attribute.
        let doc = self
            .data
            .documents
            .get(resource_id.as_str())
            .ok_or_else(|| ProviderError::NotFound(resource_id.to_string()))?;

        let doc_uid = self.make_uid("Document", &doc.id)?;

        let owner_uid = self.make_uid("User", &doc.owner_id)?;

        let mut doc_attrs = HashMap::new();
        doc_attrs.insert(
            "owner".to_string(),
            RestrictedExpression::new_entity_uid(owner_uid.clone()),
        );

        let doc_entity = Entity::new(doc_uid, doc_attrs, HashSet::new())?;
        entities.push(doc_entity);

        // 5. The owner user entity must also be in the entity set if they
        //    are different from the requesting principal.
        //
        //    An entity a policy dereferences but the provider did not
        //    build is absent, and Cedar reads absent as deny, not error.
        if doc.owner_id != user_id {
            let owner_roles = self
                .data
                .user_roles
                .get(&doc.owner_id)
                .cloned()
                .unwrap_or_default();
            let owner_role_uids: HashSet<EntityUid> = owner_roles
                .iter()
                .filter_map(|r| self.make_uid("Role", r).ok())
                .collect();
            let owner_entity = Entity::new(owner_uid, HashMap::new(), owner_role_uids)?;
            entities.push(owner_entity);
        }

        Ok(Entities::from_entities(entities, None)?)
    }

    /// Build the Cedar entity UID for a document.
    fn resource_uid(&self, id: &String) -> Result<EntityUid, AuthzError> {
        self.make_uid("Document", id)
    }

    fn validate_schema(&self, schema: &Schema) -> Result<(), AuthzError> {
        // Example provider: trusts the loaded schema. Real providers cross-check
        // entity shapes against the schema here.
        tracing::trace!(
            target: "axess::example::authz",
            ?schema,
            "example provider: schema accepted without cross-checks",
        );
        Ok(())
    }
}

The last step is the one that bites. Cedar evaluates against the entities you hand it and nothing else, so an entity a policy dereferences but the provider did not build is simply absent, and the policy that needed it does not match. The failure looks like a too-strict policy rather than a missing row. Build every entity any policy in your set can reach from the principal or the resource.

The shape is uniform: one principal entity (with parents from the role-and-group store), one or more resource entities (each with parents from the tenant model and attributes from the resource's row). Where those rows live is your choice. What matters is that the loads are batched per request, one query for memberships and one or two for the resources, rather than per policy or per entity.

Caching entities, not decisions

The single most important performance choice in a Cedar integration is what to cache. Axess takes the conservative line: entity graphs are cached aggressively, decisions are never cached.

Decisions cannot be cached because they are functions of the entity graph, the policy set, and the context. Any of the three can change between the cache write and the cache read: the entity graph because the database has updated (a role granted, a relationship added), the policy set because a redeploy has happened, the context because the request is different. A cached decision that survives any of these changes produces a wrong answer. The defence is to not cache decisions at all.

Entity graphs can be cached because they are functions of the database state at a known point in time. The cache key is the principal UID plus the resource UIDs; the cache value is the entity set; the cache TTL is a function of how stale the application is willing to tolerate.

Axess provides EntityCache, an LRU-plus-TTL decorator around a RequestEntityProvider, so repeat checks skip the inner provider's entity-build work:

let cached = EntityCache::new(provider)
    .with_capacity(10_000)
    .with_ttl(Duration::from_secs(60));

It is keyed by (principal, tenant, resource, action), and every TTL decision goes through an injected Clock, so a deterministic test can drive expiry without sleeping.

Invalidation is yours. Call EntityCache::invalidate from whatever mutates a principal's roles or a resource's authorization-relevant attributes; axess cannot know when your data changed, so it does not try.

The TTL is the lever. Sixty seconds is fine for a deployment where a role change can take a minute to propagate (most internal admin panels). Anything tighter requires the cache to be invalidated on role changes, which means your role-mutation code calls into the cache to flush the affected entries. The CacheInvalidator trait on EntityCache is the surface for this; applications that need stricter consistency wire the invalidations explicitly.

The chapter Session lifecycle and crypto envelope covers the generic axess-cache machinery the entity cache uses. Operations runbook covers the operational signals for the cache (hit rate, eviction rate, invalidation rate).

Request context

What the evaluator sees beyond the entities.

The standard request context

The context is the third input to a policy evaluation. It carries the per-request attributes that are not on the principal or the resource: the MFA status, the IP address, the time of the request, the custom keys you want to expose to policies.

StandardRequestContext is the built-in implementation:

pub struct StandardRequestContext {
    pub mfa: bool,
    pub ip: Option<IpAddr>,
    pub now: DateTime<Utc>,
    pub custom: BTreeMap<String, serde_json::Value>,
}

impl StandardRequestContext {
    pub fn from_request(req: &Request) -> Self { /* ... */ }

    pub fn with_custom(mut self, k: impl Into<String>, v: serde_json::Value) -> Self {
        self.custom.insert(k.into(), v);
        self
    }
}

The from_request constructor pulls what it can from the request: the IP from the trusted-proxy chain, the MFA status from the session's factors_completed, the time from the clock. The with_custom builder adds application-specific keys.

Policies can match on any of these:

permit (
    principal,
    action == Action::"write",
    resource
) when {
    context.mfa == true
    && context.ip like "10.*"
    && context.custom.region == "eu"
};

The schema declares the context shape:

type Context = {
    mfa: Bool,
    ip: String,
    custom: {
        region?: String,
        ...
    }
};

Required fields are checked at policy load time; optional fields are checked at evaluation time. A policy that uses a required field the request omits produces a startup error (good, caught early). A policy that uses an optional field the request omits denies at runtime, which is the conservative answer but a silent one: the reason is on the axess::authz::decision target, not in a returned error.

When to extend the context

The custom keys exist to bridge application state that does not fit on the principal or the resource. Common cases:

A tenant feature flag. A policy that gates a beta feature on "this tenant has opted in" reads context.custom.beta, which you set from the tenant's feature-flag state.

The request's geographical context. A policy that restricts certain actions to certain regions reads context.custom.region, which you populate from the load balancer's geo-IP information or from an explicit header.

A stepped-up factor not in factors_completed, because it was completed for a different reason. A policy that wants to know "did the user complete a fresh password challenge in the last five minutes" reads context.custom.password_challenge_at, which you populate from a sidecar store of recent challenges.

The pattern across all three: you own the data, the context is the carrier, the policy sees a typed attribute it can match on.

Failure modes and visibility

Two mistakes account for most surprising denies, and neither announces itself as an error: is_authorized returns Allow or Deny and nothing else, so both simply deny.

A policy referencing an entity the provider did not load. The fix is in the provider: load it, or stop referencing it.

A policy written against a context key you do not supply. The fix is in the schema: declare the key optional and handle its absence, or supply it. Building the context can also fail outright, which is AuthzError::Context, raised before evaluation rather than during it.

Visibility comes from tracing, not from an audit row. Every decision emits on the target axess::authz::decision with principal, action, resource, decision, reasons and latency_us; a validation failure emits decision = "deny" with a reason naming what went wrong. Route that target and alert on the deny rate: a spike is usually a policy set that has drifted from the data model rather than users doing anything new.

Fitting Cedar to an arbitrary data model

The provider-and-context contract is what makes Cedar usable against an arbitrary application data model. The schema names the shape; the policies match on the shape; the provider populates the shape from whatever your storage actually looks like. The three layers are independent, which means a database migration that changes how roles are stored does not break the policies (the provider updates; the rest stays), and a policy change does not touch the database (the policy file updates; the rest stays).

The chapter RBAC, ReBAC, and ABAC patterns covers worked examples that show the three styles composed in real policies.

Further reading

Cedar policy fundamentals covers the policy lifecycle and the evaluator surface this chapter feeds. RBAC, ReBAC, and ABAC patterns covers the policy authoring style with concrete examples for each pattern. Identity store implementation covers how the provider's principal-loading queries fit into your identity-store implementation. Audit events covers the decision events the evaluator emits on axess::authz::decision.

RBAC, ReBAC, and ABAC patterns

The three letter-soup acronyms RBAC, ReBAC, and ABAC name the three standard styles of authorisation. Cedar is one of the few policy languages that admits all three in the same set of rules. This chapter walks through each style with worked examples, then shows how to compose them in a single policy set without the rules fighting each other. The examples are concrete enough that you should be able to paste them into a .cedar file and have them type-check against a corresponding schema.

The three styles

Roles, relationships and attributes, and how they combine.

RBAC: roles as groups

Role-based access control assigns users to roles and assigns permissions to roles. The model has been the workhorse of enterprise authorisation since the 1990s and remains the right starting point for most applications.

The schema declares roles and the action permissions they hold:

entity User {
    tenant_id: String,
};

entity Role;

entity Document {
    tenant_id: String,
    owner: User,
};

action read appliesTo {
    principal: [User],
    resource: [Document],
};

action edit appliesTo {
    principal: [User],
    resource: [Document],
};

The policy grants the role-action mappings:

permit (
    principal in Role::"viewer",
    action == Action::"read",
    resource
);

permit (
    principal in Role::"editor",
    action in [Action::"read", Action::"edit"],
    resource
);

The entity provider, on each request, attaches the user's role memberships as parent entities. A user in Role::"viewer" has that role in their parents list; a user in Role::"editor" has that role in their parents list and inherits read permission through the second policy's action set.

The shape works for most applications until two situations arise. The first is when permissions need to depend on the relationship between the principal and the resource (a user can edit their own documents but not others'), which is the ReBAC case below. The second is when permissions need to depend on the request context (MFA must be present for sensitive actions), which is the ABAC case below.

ReBAC: relationships as paths

Relationship-based access control assigns permissions based on the relationship between the principal and the resource, not on a role label. The classic example is ownership: a user can edit a document they own.

The schema does not change much; the relationship is already on the entity:

entity Document {
    tenant_id: String,
    owner: User,
    shared_with: Set<User>,
};

The policy expresses the relationship:

permit (
    principal,
    action == Action::"edit",
    resource
) when {
    resource.owner == principal
};

permit (
    principal,
    action == Action::"read",
    resource
) when {
    resource.owner == principal
    || principal in resource.shared_with
};

The first rule grants edit to the owner. The second rule grants read to the owner or to anyone in the resource's shared_with set. The set membership principal in resource.shared_with is the ReBAC primitive: the principal is in some set on the resource, and the policy matches on that.

More elaborate relationships involve multi-hop paths. Consider a "team" model where a user belongs to a team, the team owns projects, and the projects contain documents. The schema:

entity Team;

entity Project {
    owner_team: Team,
};

entity Document {
    project: Project,
};

entity User in [Team];

The policy that says "anyone in the team that owns the project that contains this document can read the document":

permit (
    principal,
    action == Action::"read",
    resource
) when {
    principal in resource.project.owner_team
};

The in operator follows the entity graph: resource.project yields a Project entity, .owner_team yields a Team entity, and principal in Team checks the principal's parents list. The entity provider populates the graph: the document with its project parent, the project with its owner_team attribute, the user with their team memberships. Cedar walks the graph at evaluation time.

The pattern generalises to any depth, though policies that walk more than two or three hops start to feel hard to review. When the depth gets uncomfortable, extract the relationship into an intermediate entity (a "can_view" set on the document that the application's data layer computes ahead of time) and let the policy match on the simpler shape.

ABAC: attributes as conditions

Attribute-based access control adds context to the decision. The attributes might be on the principal (MFA status, last authentication time), on the resource (sensitivity level), or on the request (IP address, time of day). A policy applies only when the attributes match.

The schema declares the attribute shapes:

entity User {
    tenant_id: String,
    mfa_completed: Bool,
    last_authn_at: Long,  // unix seconds
};

entity Document {
    tenant_id: String,
    classification: String, // "public" | "internal" | "secret"
};

type Context = {
    ip: String,
    now: Long,
};

The policy combines attribute conditions:

permit (
    principal,
    action == Action::"read",
    resource
) when {
    principal.tenant_id == resource.tenant_id
    && (
        resource.classification == "public"
        || (
            resource.classification == "internal"
            && principal.mfa_completed
        )
        || (
            resource.classification == "secret"
            && principal.mfa_completed
            && context.now - principal.last_authn_at < 900  // last 15 min
        )
    )
};

The rule grants read access in three tiers: public documents to anyone in the tenant, internal documents to anyone in the tenant with MFA completed, secret documents to anyone in the tenant with MFA completed in the last fifteen minutes. The attributes drive the gradations; the policy expresses them in one statement.

ABAC is the right tool for time-sensitive, location-sensitive, and context-sensitive policies. It is the wrong tool for static permissions (use RBAC) or for relationship checks (use ReBAC). When in doubt, write the policy and read it back: if the rule says "users in X role can perform Y," it is RBAC; if it says "users with relationship Z to this resource can perform Y," it is ReBAC; if it says "users can perform Y when condition W," it is ABAC.

Composing the three styles

A real production policy set mixes the three. A user who has the editor role (RBAC) can edit any document, but a user who owns a document (ReBAC) can edit it regardless of role, and a user trying to edit a secret document must have MFA completed (ABAC).

// RBAC layer: editors get full access.
permit (
    principal in Role::"editor",
    action in [Action::"read", Action::"edit", Action::"delete"],
    resource
);

// ReBAC layer: owners get full access to their own.
permit (
    principal,
    action in [Action::"read", Action::"edit", Action::"delete"],
    resource
) when {
    resource.owner == principal
};

// ReBAC layer: shared-with users get read access.
permit (
    principal,
    action == Action::"read",
    resource
) when {
    principal in resource.shared_with
};

// ABAC layer: secret documents require fresh MFA, forbid otherwise.
forbid (
    principal,
    action,
    resource
) when {
    resource.classification == "secret"
    && (
        !principal.mfa_completed
        || context.now - principal.last_authn_at > 900
    )
};

The forbid rule overrides any permit that would otherwise match. The structure works because Cedar evaluates all rules: if any permit matches and no forbid matches, the decision is Allow; if any forbid matches, the decision is Deny regardless of what permits also match.

The pattern is to express the broad grants through permit rules in increasing specificity (role, relationship, context), then to express the absolute constraints through forbid rules. The forbid rules are typically about high-sensitivity resources or about high-risk principal states; they are the small set of cases where a positive grant is not enough.

Patterns worth copying

Two that are easier to get right in policy than in Rust.

Tenant isolation as a structural rule

Multi-tenant applications need a structural rule that no policy should ever leak data across tenants. The right shape is a single top-level forbid:

forbid (
    principal,
    action,
    resource
) when {
    principal.tenant_id != resource.tenant_id
};

The rule applies to every action on every resource. Any later permit that would have allowed a cross-tenant access is overridden. The rule is the structural defence against the worst class of authorisation bug a multi-tenant application can have: an operator from tenant A accessing tenant B's data because of a mistake in another policy.

The rule is also the right place to validate that the principal has a tenant id at all. A workload principal might be in a global trust domain (no tenant), in which case the comparison fails the type system and the rule denies. The policy authoring style is to treat tenant id as a required attribute on every multi-tenant entity, and to let this forbid catch any drift.

Step-up as a policy concern

Step-up authentication is the pattern where a user is asked to re-prove identity (or to prove with a stronger factor) before performing a sensitive action. The mechanism is in the state machine (see Factors and methods §"Step-up authentication"); the policy expresses when step-up is required.

The shape:

forbid (
    principal,
    action == Action::"delete-account",
    resource
) when {
    !("Fido2" in principal.factors_completed)
};

The rule denies the account-deletion action unless FIDO2 is in the user's completed factors. The user reaches the action with a password-and-TOTP session, gets denied, and you offer step-up: the user completes the FIDO2 ceremony, the session's factors_completed now includes Fido2, the next request to the delete-account action passes the policy.

The pattern composes with the other styles. A permit rule says who can delete an account (RBAC: the user themselves, ReBAC: the admin who owns the user). The forbid rule adds the contextual requirement (ABAC: FIDO2 in factors_completed). The three rules together produce a policy that says "the user themselves can delete their own account, but only after completing FIDO2 in this session."

Anti-patterns

The two patterns most likely to mislead are worth naming.

Duplicating ReBAC as RBAC. The temptation is to materialise the ownership relationship as a per-resource role ("owner of document 123"), then write an RBAC policy that grants edit to the role. The shape works but produces an explosion of roles (one per resource), is hard to invalidate when ownership changes, and obscures the relationship that the policy is actually expressing. The right shape is to express ownership as an attribute (resource.owner == principal) and write the ReBAC policy directly.

Encoding state machines in policies. A workflow that allows transitions only from certain states is a state machine, not a policy. Writing it as a Cedar rule (permit ... when { resource.state == "draft" && action == "submit" }) admits the rule but makes the policy set the source of truth for what the state machine allows. The right shape is to put the state machine in code (or in a typed state machine of your own), and to use Cedar only for "who can invoke this transition" rather than "which transition is valid right now."

Schema discipline

The most consequential decision in any Cedar integration is the schema. The schema names every entity type, every attribute on every entity, every action that applies to every principal-resource pair, every required and optional context key. Getting the schema right is most of the work; getting the policies right is what follows naturally from a good schema.

Three rules help.

Name entities by their domain meaning, not by the table they live in. User is the right name; usersRow is the wrong one. Policies that read like English are the ones that let reviewers do their job.

Declare an attribute required only when every production deployment guarantees it is present. A required attribute forces the entity provider to return it on every load, which often forces you to add an INSERT default. Optional is the right default; require only when the policy logically depends on it.

Update the schema whenever a policy expression needs an attribute that is not yet declared. The validator catches the inconsistency at load time; the alternative is a runtime deny that is hard to debug. Treat the schema as part of the policy set.

Further reading

Cedar policy fundamentals covers the policy lifecycle and the evaluator surface. Entity providers and request context covers the data-loading contract the policies in this chapter depend on. Audit events covers why a Cedar decision is a tracing event on axess::authz::decision rather than an audit row, and what it carries: the principal, action, resource, the decision, the policy ids that produced it, and how long evaluation took. The Cedar documentation covers the language in full detail and is the authoritative reference for syntax and semantics.

Session lifecycle and crypto envelope

A session in axess is a server-side record that holds the authentication state, the bound principal, and any application data the session carries. The cookie that travels between the browser and the server identifies the session, but the cookie itself does not contain the session data. This separation is what lets the session shape evolve across deployments without invalidating existing cookies, and what lets the data be encrypted at rest with keys the client never sees.

This chapter walks through the lifecycle of a single session from its creation through its expiry, the cookie shape and signing, the AES-256-GCM envelope that encrypts the data at rest, the fingerprint binding that catches cookie replay, and the dirty-flag and write-back machinery that makes the lifecycle invisible to application code.

What a session is made of

Four pieces: one in the browser, three on your side.

The session cookie is small. By default it carries an opaque session id (the SessionId newtype, sixteen bytes of cryptographic randomness from SecureRng, base64-encoded for transport) plus an HMAC signature computed from the id and the deployment's signing key. The whole cookie is well under two hundred bytes.

session=<base64(session_id)>.<base64(hmac_sha256(signing_key, session_id))>

The signature defeats forgery. An attacker who guesses or brute-forces a session id cannot use it without also producing the HMAC, which requires the signing key. The signing key is the operational secret covered in the Getting started chapter: a 32-byte value loaded from a secrets manager, stable across process restarts, rotated on a schedule.

The cookie attributes are conservative by default: HttpOnly (client-side JavaScript cannot read it), SameSite=Lax (it is sent on top-level cross-site navigations but not on cross-site sub-requests), Path=/ (it applies to the whole application), and Secure when configured (it is only sent over HTTPS). The default session lifetime is a function of SessionLayer::with_ttl; the default in the constructor is twenty-four hours.

The cookie is opaque. The session id maps to a row in the session store, and the row carries the actual data. A user who copies the cookie has the session id and the signature, both of which the server already has; nothing on the cookie carries the user's identity, the factors completed, or any other session state.

The session store

The session store is the persistence layer for the data the cookie identifies. Each row in the store carries:

  • The session id (the primary key).
  • The serialised SessionData (covered below).
  • The created-at and updated-at timestamps.
  • The expiry timestamp.
  • The optional fingerprint binding (covered below).

SessionData is the application's view of the session:

pub struct SessionData {
    pub version: u8,                   // see Schema migration
    pub auth_state: AuthState,         // see Part II
    pub fingerprint: Option<String>,   // HMAC of the binding material
    pub device_id: Option<DeviceId>,   // set by the DeviceResolver, if any
    pub custom: serde_json::Value,     // application data
}

custom is a single serde_json::Value, not a map: axess treats it as one opaque blob and never looks inside. Applications that want several independent keys nest an object there and own its shape, including its own versioning.

The auth_state carries the state-machine variant (Guest, Identifying, Authenticating, Authenticated, PendingWorkflow). The fingerprint is the HMAC of whatever the configured SessionBinding extracted, set at the first transition out of Guest and compared on every later request. The device_id is whatever the DeviceResolver returned, if one is configured. The custom value carries application-defined data, capped by SessionLayer::with_max_custom_bytes. The version is the field that lets the data shape evolve.

There is no cached principal on the session. The resolver runs per request against the auth_state, which is what keeps a revoked session from serving a stale identity.

The serialisation format is MessagePack: faster than JSON, more compact, and stable across versions of serde. Backends that support binary blobs persist the bytes directly; backends that require text (some configurations of MySQL, for instance) encode the bytes as base64 first. The format is the same across all backends; switching backends does not require re-serialisation.

The AES-256-GCM envelope

The serialised session bytes are encrypted before storage. The envelope is AES-256-GCM, a standard authenticated-encryption scheme that produces a ciphertext, a tag, and a nonce. The encryption key is a 32-byte value loaded from a secrets manager at process start.

The shape of one envelope:

nonce (12 bytes) | ciphertext (variable) | tag (16 bytes)

The nonce is generated fresh per write through SecureRng. AES-GCM is sensitive to nonce reuse (a reused nonce against the same key catastrophically compromises confidentiality and authenticity); the twelve-byte random nonce gives a collision probability of about one in 2^48 per encryption, which is comfortably safe for any realistic session volume.

The additional authenticated data (AAD) carries the session id. The binding means that an encrypted blob from one session cannot be swapped into another session's row even if an attacker can write to the database. The session id is plaintext in the cookie, so this adds no confidentiality, but it adds integrity: the database is not the source of truth for "which session is this blob from."

Key rotation is the operational lever. SessionCrypto::new(key) constructs an envelope with one current key. .with_previous_key(old_key) keeps the old key available for reads, so sessions encrypted with the old key continue to decrypt while new writes use the new key. After a transition window long enough for every existing session to be rewritten (which happens naturally over the next session write, or can be forced through a background scan), the previous key can be removed.

The chapter Operations runbook covers the rotation sequence and the staged rollout for both the signing key and the envelope key.

The fingerprint binding

A session id alone is not enough to defend against cookie theft. An attacker who captures a session cookie can replay it from a different browser, IP, and operating system, and the session machinery on the server cannot tell the difference without additional signal.

The fingerprint binding is the additional signal. At session creation (typically at first login), the server computes a fingerprint from the user agent header, the IP address (read through the trusted-proxy configuration), and any other coarse features the deployment chooses to include. The fingerprint is HMAC-signed and stored alongside the session id. On every subsequent request, the server recomputes the fingerprint from the incoming request and compares it (constant-time) against the stored value.

The match has three outcomes:

  • Match exactly: the session is allowed to proceed.
  • Match within a tolerance: the session is allowed to proceed, but the divergence is logged.
  • Mismatch beyond tolerance: the session is treated as compromised and one of three responses fires (warn, re-authenticate, full logout), depending on the configured policy.

The tolerance accommodates legitimate change: a user's IP can change when they switch from wifi to cellular; their user agent can update overnight when the browser auto-updates. Strict matching on either signal produces too many false positives. The default is coarse: the IP must remain within the same /24 (for IPv4) or /64 (for IPv6), and the user agent must share its major version.

The chapter Cookies, fingerprinting, hijack detection covers the configuration knobs and the trade-offs in detail.

How it runs

What the layer does per request, and what decides a write.

The Tower layer

The SessionLayer is the Tower middleware that threads the session through every request. The layer's call method is the sequencing centre of the session lifecycle.

The pseudocode of one request:

async fn call(&self, req: Request) -> Response {
    // 1. Extract the cookie (or skip if absent → Guest).
    let cookie = extract_session_cookie(&req);

    // 2. Verify the HMAC, decode the session id.
    let session_id = verify_cookie(&cookie, self.signing_key)
        .map_err(|_| ();  // fall through to a guest session

    // 3. Load the row from the session store.
    let row = self.store.load(&session_id).await;

    // 4. Decrypt the envelope, deserialise the data.
    let data = decrypt_and_deserialize(&row, &self.crypto)?;

    // 5. Verify the fingerprint binding.
    enforce_fingerprint(&data, &req, self.fingerprint_policy)?;

    // 6. Wrap into a SessionHandle, insert into request extensions.
    let handle = SessionHandle::new(session_id, data);
    req.extensions_mut().insert(handle.clone());

    // 7. Run the handler.
    let response = self.inner.call(req).await?;

    // 8. If the handle is dirty, write back.
    if handle.is_dirty() {
        let new_data = handle.into_data();
        let new_envelope = encrypt(&new_data, &self.crypto, &new_session_id);
        self.store.save(&session_id, &new_envelope).await?;
        // Reissue the cookie (with a fresh id if rotation was triggered).
        response.headers_mut().append("Set-Cookie", construct_cookie(...));
    }

    response
}

Three of the eight steps are worth dwelling on.

Step 5 (the fingerprint check) is the gate that catches replay. A mismatched fingerprint causes the handler not to run at all; the session-layer returns a 401 (or the configured response). The choice of response depends on the policy: warn-only deployments log and continue; strict deployments deny.

Step 7 is where the handler actually runs. The handler receives a SessionHandle via AuthSession (the extractor), reads or mutates it, and the mutations are tracked via the dirty flag.

Step 8 is the write-back. The session is saved only when it is dirty, which means a read-only request (the dashboard, a metric endpoint, an idle-page poll) does not write to the session store. The store sees writes proportional to the rate of state changes, not the rate of requests, which is the difference between a manageable database load and a saturated one.

The dirty flag

The dirty flag is the optimisation that makes the session store viable at the read rates a real application produces. The flag is on SessionHandle and is set by any method that mutates the session: set_authenticated, clear, set_custom, and so on.

The flag is checked at step 8 in the lifecycle above. A clean handle is dropped silently; a dirty handle triggers the serialisation, encryption, store-write, and cookie-reissue path.

The trade-off is that a read of mutable state through an immutable borrow does not mark dirty, but the application's pattern for that case is to use the typed accessors (is_authenticated, current_user_id, custom_get) that do not need a mutable borrow. Mutating accessors (clear, set_custom, the orchestrated begin_login and verify_factor paths) all set the flag.

The cookie is reissued only when the session id rotates, not on every write. Identifier rotation happens at two automatic moments (Guest → authenticated to defeat fixation; logout so the new Guest session doesn't share an id with the old) plus explicit re-issuance through AuthSession::regenerate. The routine read-write-read cycle does not rotate.

regenerate exists for the cases the library can't infer on its own: any handler that crosses a privilege boundary should call it before responding.

The canonical list is drawn from OWASP ASVS V3, the OWASP Session Management Cheat Sheet, and NIST SP 800-63B on transitions between authenticator assurance levels. An AAL is that standard's measure of how strongly a session's authentication is evidenced: AAL1 is a single factor, AAL2 adds a second, AAL3 requires a hardware authenticator.

BoundaryRotate session id?Also revoke sibling sessions?
Primary loginautomaticoptional
Logoutautomatic (id invalidated)depends
MFA factor added (TOTP, WebAuthn, recovery codes, …)yesoptional
MFA factor removed or disabled (AAL drops)yesrecommended
Password / primary credential changeyesstrongly recommended
Step-up to a higher assurance levelyes;
Account recovery flow completionyesyes
Impersonation start / stopyes;
Role grant / revoke, scope changeyesdepends on direction
Tenant switch in a multi-tenant deploymentyes;
Profile edit, theme change, factor config tuningno;

Rotating does two things at once: it defeats fixation (any pre-existing id, including one an attacker planted before the boundary, becomes useless), and it caps the blast radius of a captured pre-elevation cookie: one stolen while the session was at AAL1 cannot ride the new AAL2 binding. Sibling-session revocation (SessionRegistry::invalidate_user) is a strictly stronger statement that matters most on credential changes, where any other device holding a stale password-derived session must be cut off.

A library hook on FactorStore::save_factor would catch some of the rows above and miss the rest (un-enrolment, password change, role grants), and would misfire on factor-config tuning that is not a privilege change. The boundary decision is necessarily app-level. Call regenerate at the handler that knows.

Ending a session

Expiry from the inside, and sweeping what is left.

When the session expires

The session has two expiry mechanisms. The first is the cookie's own Max-Age attribute, which the browser enforces: after the configured TTL, the browser stops sending the cookie. The second is the session store's expiry timestamp, which the server enforces: after the timestamp passes, the store returns the row as expired (or the cleanup sweep removes it altogether).

Both are needed. The cookie expiry handles the browser-side case (the user closes the browser, the cookie is forgotten); the server-side expiry handles the case where the cookie outlives the session's intended lifetime (an attacker captures a cookie and replays it after the user's session would have expired).

The expiry is sliding by default: every dirty write updates the expiry timestamp, so an actively-used session keeps refreshing. The maximum lifetime is the configured TTL from the most recent write. A session that goes idle for the TTL expires; a session that gets a single dirty write per TTL window never expires (through ordinary use).

Some deployments want a hard cap: a session expires absolutely at a fixed time after creation, regardless of activity. Axess has no absolute TTL. with_ttl sets the sliding one and there is no second knob beside it, so a session kept warm by ordinary use does not expire on its own.

Where a hard cap matters, put the creation instant in custom and check it in a layer of your own above the session layer, or end the session from the registry on the schedule you want. Both are a few lines, and both keep the decision where the deployment's compliance requirement actually lives.

Session cleanup

Expired sessions need to be removed from the store. The cleanup is the application's responsibility (axess does not run a background task on its own), but the patterns are uniform across backends.

The SQL backends expose a cleanup_expired method that deletes rows whose expiry timestamp has passed. The examples/sqlite/ reference application runs this on a tokio::interval once per hour; the interval is tunable.

The Valkey backend uses Valkey's native TTL: each row is written with an expiry, and Valkey removes it automatically. There is no cleanup task to write because the database does the work.

For deployments with millions of sessions, the cleanup pattern matters operationally. A daily delete-by-range is fine for tens of thousands; for millions, the delete needs to be incremental (a limit clause, looping through batches) to avoid long-running transactions that lock the table.

Invisible to application code

The lifecycle as designed makes session handling invisible to application code. The handler reads AuthSession, mutates it (or does not), and the framework handles the cookie, the serialisation, the encryption, the fingerprint check, the write-back, and the expiry. The application's surface area for session bugs is small: most session-related issues are policy choices (rotate too aggressively, lockout too strict, fingerprint tolerance too tight), not bugs in the lifecycle itself.

The chapter Backends covers the storage backends in detail; the chapter Cookies, fingerprinting, hijack detection covers the fingerprint binding in detail; the chapter Schema migration covers the SessionData::version field and what happens when the data shape changes between deployments.

Further reading

Backends: SQLite, Postgres, MySQL, Valkey covers the four first-party session stores and their feature-flag and dialect notes. Cookies, fingerprinting, hijack detection covers the configuration knobs for the fingerprint and the trusted-proxy configuration that determines how IP is read. Schema migration covers the SessionData::version field. Operations runbook covers signing-key and envelope-key rotation.

Backends: SQLite, Postgres, MySQL, Valkey

Axess ships four first-party session storage backends. The choice between them is the operational decision the deployment makes when it picks a database, not a technical decision your code needs to revisit. This chapter covers the capability matrix, the configuration shape per backend, and the operational notes that have caught real deployments by surprise.

The feature flags are sqlite, postgres, mysql, and valkey, all off by default. Enable the one your deployment uses.

What the backends actually do

A session storage backend implements the SessionStore trait. The trait is small and on purpose: it offers a key-value-with-TTL surface plus a handful of session-specific verbs the typical application needs.

pub trait SessionStore: Send + Sync + Clone + 'static {
    type Error: std::error::Error + Send + Sync + 'static;

    fn load(
        &self,
        id: &SessionId,
    ) -> impl Future<Output = Result<Option<SessionData>, Self::Error>> + Send;

    fn save(
        &self,
        id: &SessionId,
        data: &SessionData,
        ttl: Duration,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    fn delete(
        &self,
        id: &SessionId,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    /// Atomically delete the old row and store the data under the new id.
    fn cycle(
        &self,
        old_id: &SessionId,
        new_id: &SessionId,
        data: &SessionData,
        ttl: Duration,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    fn prune_expired(&self) -> impl Future<Output = Result<u64, Self::Error>> + Send;

    /// Defaults to `Ok(vec![])`; override where the backend can index it.
    fn find_sessions_for_user(
        &self,
        user_id: &UserId,
        limit: usize,
    ) -> impl Future<Output = Result<Vec<(SessionId, SessionData)>, Self::Error>> + Send;
}

Four things about that surface shape an implementation.

Clone is a supertrait. The layer holds the store by value and clones it per request, so the type must be cheap to clone: a connection pool or an Arc<DashMap<..>> behind the struct, never the data itself.

The error is your associated Self::Error, not a type axess defines.

There is no row type. load and save deal in SessionData directly, and the TTL is a save argument rather than a field, because expiry is the store's to enforce however its backend does it: a column plus prune_expired for SQL, a native key TTL for Valkey.

cycle is the session-fixation defence, and its contract is stricter than it looks. It is a write as well as a move: the data and TTL come with it, so the rotation is one statement rather than a delete followed by a save. The new id is supplied by the caller rather than minted by the store, so that handler-side code can register the post-rotation id with the SessionRegistry before the layer persists the rotation. Implementations must do the delete and the insert atomically, in one transaction, so a crash mid-cycle can never leave the user holding both rows or neither.

The verbs map to operations the lifecycle in the previous chapter exercises. load retrieves a session by id. save writes a dirty session. delete removes a session on logout. cycle atomically rotates the session id (used at the Guest to Authenticated transition, and at sensitive step-up points). cleanup_expired removes rows whose expiry has passed. find_sessions_for_user is the verb behind "log this user out of all sessions" admin operations.

The implementations differ in how they store the rows and how they implement the verbs, but the surface is the same.

Capability matrix

CapabilityMemorySQLitePostgresMySQLValkey
Required featurealways-onsqlitepostgresmysqlvalkey
Encryption at restnoneoptional (AES-GCM)optional (AES-GCM)optional (AES-GCM)optional (AES-GCM)
Cluster-safenowith careyesyesyes
Native TTLn/amanual sweepmanual sweepmanual sweepyes
Session registry supportyesadopteradopteradopteryes
Schema migrationn/asqlx-migratesqlx-migratesqlx-migratenone needed

The encryption-at-rest column is the AES-256-GCM envelope from the previous chapter. The application configures it with a 32-byte key; the backend wraps the envelope around the serialised session data before writing. The envelope is optional because some deployments accept the unencrypted at-rest store (when the database is itself encrypted, when the threat model does not require it), and decrypting on every read costs a few microseconds per session. The recommendation for production is to enable encryption unless the deployment has a specific reason not to.

The cluster-safe column says whether multiple application instances can share the same backend without coordination issues. SQLite is single-writer; a deployment with one application instance behind a load balancer is fine, but multiple instances need to share the SQLite file over a filesystem the database supports (which is operating-system-dependent and risky). Postgres, MySQL, and Valkey are cluster-safe out of the box.

The native TTL column says whether the database has a native mechanism for removing expired rows. SQLite, Postgres, and MySQL do not, so you run a periodic cleanup task. Valkey expires keys automatically as they age past their TTL, which means the cleanup task is unnecessary.

The backends

What each one is good at, and what it costs you.

SQLite

The SQLite backend is right for development, for tests, for single-instance production deployments, and for embedded-style applications where the database lives on the same machine as the application.

Configuration:

use axess::backends::sqlite::SessionStore;
use axess::session::SessionCrypto;

let pool = sqlx::SqlitePoolOptions::new()
    .max_connections(5)
    .connect("sqlite:axess.db")
    .await?;

let crypto = SessionCrypto::new(envelope_key);  // optional encryption
let store = SessionStore::new(pool.clone(), crypto);
store.init_schema().await?;

init_schema creates the sessions table and the indexes the backend needs. It is idempotent; calling it on a database that already has the table is a no-op.

The cleanup pattern is a background task that runs store.cleanup_expired on an interval, typically once per hour. The examples/sqlite/ reference application demonstrates this in main.rs.

The operational notes:

  • SQLite locks on writes. The max_connections setting on the pool determines how many concurrent writes the database admits, and WAL mode (configured in the connection string) is what enables concurrent reads alongside writes. Use WAL mode for any deployment that has more than one request at a time.

  • The schema migration story is sqlx::migrate!: the migrations directory under your process is the source of truth, and the pool runs them at startup. Axess does not include its own migrations; init_schema is enough.

  • Backups: a SQLite session store can be backed up with the standard sqlite3 .backup command, which works against a live database. The session data is encrypted at rest if the envelope is configured, so a backup carries the same security posture as the live data.

Postgres

Postgres is the right backend for most production deployments. It is cluster-safe, has good concurrency, supports JSONB if a deployment wants to index into the session's custom map, and is the most-tested backend in axess after SQLite.

Configuration:

use axess::backends::postgres::SessionStore;

let pool = sqlx::PgPoolOptions::new()
    .max_connections(20)
    .connect("postgres://app@db:5432/axess")
    .await?;

let store = SessionStore::new(pool.clone(), SessionCrypto::new(envelope_key));
store.init_schema().await?;

The pool sizing depends on your request rate; twenty is a reasonable starting point for a single application instance, multiplied by the number of instances and tuned against the database's max_connections setting.

The operational notes:

  • The init_schema call creates the sessions table with an index on the expiry timestamp (for cleanup_expired) and on the user id plus tenant id (for find_sessions_for_user). The indexes are essential at any meaningful scale; do not remove them.

  • CockroachDB is wire-compatible with Postgres and works against this backend with one caveat: Cockroach's lock semantics differ in edge cases (a SELECT ... FOR UPDATE pattern that works on Postgres can produce different behaviour on Cockroach). The axess CI runs the Postgres integration suite against Cockroach to catch divergence; the failures that have surfaced are noted in this chapter when they affect adopter code.

  • Postgres extensions: pgcrypto can be used as an alternative to the AES-GCM envelope, but the axess envelope is faster (the encryption happens in your process before the network write, not on the database side) and uses the same key as other axess encryption. Stick with the envelope unless a specific deployment reason argues for pgcrypto.

MySQL

The MySQL backend is right for deployments where MySQL is the already-deployed database. The capability surface is the same as Postgres, with a handful of dialect differences that affect the implementation but not your code.

Configuration:

use axess::backends::mysql::SessionStore;

let pool = sqlx::MySqlPoolOptions::new()
    .max_connections(20)
    .connect("mysql://app@db:3306/axess")
    .await?;

let store = SessionStore::new(pool.clone(), SessionCrypto::new(envelope_key));
store.init_schema().await?;

The operational notes:

  • The dialect differences from Postgres are mostly invisible: ON CONFLICT DO UPDATE becomes ON DUPLICATE KEY UPDATE, the placeholder syntax shifts from $1 to ?, datetime precision defaults to seconds rather than microseconds. Axess handles all three internally; your code is identical.

  • MariaDB 10.x and later versions are compatible with the same schema and the same SQL. The CI runs against both MySQL 8.x and MariaDB 10.x.

  • Timezone handling differs. MySQL stores DATETIME values as naive timestamps in the server's timezone; the backend serialises expiries as UTC and reads them back as UTC, sidestepping the implicit-conversion trap.

  • Connection options: pool sizing is the same as Postgres. MySQL has a default wait_timeout of eight hours, after which idle connections are closed; the sqlx pool handles reconnection automatically, but be aware of the setting if connection-state matters to your application.

Valkey

The Valkey backend is right for deployments where a Redis-style key-value store is already present in the architecture, or for deployments where the session-store load is high enough that the overhead of a relational database is undesirable. Valkey's TTL mechanic makes session expiry automatic: the cleanup task is not needed.

Configuration:

use axess::backends::valkey::SessionStore;
use fred::prelude::*;

// The client is `fred`'s, not the `redis` crate's.
let client = Client::new(Config::from_url("redis://valkey:6379")?, None, None, None);
client.init().await?;

// The key is the AES-256-GCM envelope key: encryption at rest is on,
// not opt-in. `with_prefix` overrides the default `axess` namespace,
// which is how one Valkey serves several deployments.
let store = SessionStore::new(client, envelope_key).with_prefix("axess");

The Valkey backend does not need a schema initialisation; the keys are written directly with TTLs.

The operational notes:

  • Cluster mode: fred is built here with i-cluster already on, so there is no feature for you to add. The keys axess writes are prefixed and typed (axess:sess:<id>, axess:reg:<user>, axess:revoked-session:<id>, axess:revoked-user:<id>), so sharding by key works without conflict.

  • Persistence: Valkey can be configured for in-memory only, for RDB snapshots, or for AOF (append-only file) durability. The axess session store is fine on any of the three; the choice trades latency against durability. For sessions specifically, in-memory is acceptable if the deployment tolerates losing all active sessions on a Valkey restart; AOF is the standard choice when sessions matter.

  • The session registry: Valkey is the only first-party backend with a session registry (the verb behind "list all sessions for a user", which the SQL backends require an adopter to wire up). The registry uses a sorted set keyed by user id, with the session ids as members and the expiry timestamp as the score. Operations on the registry are O(log n) in the number of sessions per user.

  • Eviction policy: Valkey under memory pressure can evict keys. If the eviction policy is allkeys-lru, the session store can lose sessions before their TTL fires. The recommendation is to configure Valkey with volatile-lru (only TTL'd keys are candidates for eviction), and to monitor the eviction rate. If evictions happen at all, the Valkey instance is undersized; scale up before the user-visible behaviour becomes painful.

Choosing between them

The decision tree is short.

If the deployment already has a database, use the matching backend. Postgres for Postgres, MySQL for MySQL, Valkey for Redis or Valkey.

If the deployment is starting fresh and your service is single-instance, SQLite is the simplest choice and works fine for small-to-medium scale.

If the deployment is multi-instance and starting fresh, Postgres is the conservative default. The operational tooling for Postgres is mature, the backup story is well understood, and the schema flexibility leaves room for future extensions.

If the deployment expects very high session throughput (tens of thousands of writes per second, or pathologically high read rates), Valkey is the choice. The latency is the lowest of the four, the TTL mechanic removes the cleanup task, and the cluster scaling is proven.

The choice is not irreversible. The session storage backend is behind a trait, the data shape is uniform across backends, and migrating between backends is a matter of reading from the old store and writing to the new one (during a deploy window where both are active, or with a one-time migration script that runs against a paused application). No data shape changes; the migration is purely operational.

Cross-backend Store<K, V> access

All four backends also implement the generic axess_core::store::Store<SessionId, SessionData> trait. This matters for adopters who want backend-agnostic access (test doubles, ops endpoints that work against any deployment, code that needs to switch backends at runtime).

Store is one of the few things the facade does not re-export, so this needs a direct axess-core dependency alongside axess:

use axess_core::store::Store;
use std::sync::Arc;

async fn dump_session(
    store: Arc<dyn Store<SessionId, SessionData>>,
    id: &SessionId,
) -> Option<SessionData> {
    store.get(id).await.ok().flatten()
}

The generic trait omits session-domain operations (cycle, find_sessions_for_user). Code that needs those operations uses the concrete SessionStore trait directly; code that only needs key-value-with-TTL semantics uses Store.

The duplication is deliberate. The generic trait is the common denominator across backends; the specific trait carries the session vocabulary. Mixing them lets each callsite use the narrowest surface it needs.

Further reading

Session lifecycle and crypto envelope covers the full lifecycle that exercises these backends. Cookies, fingerprinting, hijack detection covers the cookie attributes and the fingerprint binding the backends store. Schema migration covers what happens when the session data shape changes between deployments. Operations runbook covers signing-key and envelope-key rotation across all four backends.

Cookies, fingerprinting, hijack detection

The session cookie is the credential a browser presents on every request. If an attacker captures it, they can act as the user until the session expires or is revoked. The defences are layered: cookie attributes constrain how the browser handles the cookie, HMAC signing detects tampering, fingerprint binding catches replay from a different browser, and trusted-proxy configuration controls how the application reads the request's IP. This chapter covers each layer.

The session cookie carries five attributes the deployment cares about. Most have defaults that are right for production; one (Secure) needs to be set explicitly.

Path=/ makes the cookie apply to the whole application. The alternative (a narrower path) is occasionally useful for embedded deployments where the application lives under a sub-path of a larger site; for most deployments, the root path is right.

HttpOnly prevents client-side JavaScript from reading the cookie. The attribute defeats one class of cross-site scripting attack: an attacker who injects JavaScript into the page cannot read the session cookie through document.cookie and exfiltrate it. The attribute is on by default and there is rarely a reason to turn it off.

SameSite controls when the browser sends the cookie on cross-origin requests. There are three values:

  • Strict means the cookie is sent only on same-site requests. A link from an external site to your application produces a guest-state request even if the user is logged in; the user must navigate from within your site for the session to be recognised.
  • Lax (the default) means the cookie is sent on top-level cross-site navigations (a link click) but not on cross-site sub-requests (an embedded image, an XHR). The combination defeats most CSRF attacks while preserving the user experience of "click an external link, arrive logged in."
  • None means the cookie is sent on every cross-site request. This is the right setting when the application is embedded in iframes on third-party sites; it is the wrong setting otherwise.

The recommendation is Lax for most deployments. Switch to Strict for the highest-sensitivity actions; the cost is the user-experience friction of cross-site link arrivals not being logged in.

Secure requires HTTPS. The cookie is sent only on TLS-protected connections; a misconfigured load balancer that accepts cleartext HTTP does not see the cookie. The attribute is non-negotiable for production but breaks localhost development against http://, which is why SessionLayer::with_secure(false) exists as a development concession.

The Max-Age (the cookie's lifetime in seconds) matches the session TTL from SessionLayer::with_ttl. The browser stops sending the cookie after the lifetime expires; the server-side session has its own expiry that the lifecycle layer also enforces.

HMAC signing

The cookie carries an HMAC signature computed from the session id and the deployment's signing key. The format is:

<base64(session_id)>.<base64(hmac_sha256(signing_key, session_id))>

The signature defeats forgery and tampering. An attacker who guesses a session id (or who tries to mutate an existing cookie) cannot produce a valid signature without the signing key. The server rejects any cookie whose signature does not validate; the session is not loaded and the request proceeds as Guest.

The HMAC verification is constant-time. The constant-time comparison defeats a timing attack where an attacker could distinguish "valid signature for invalid id" from "invalid signature for valid id" by measuring response latency.

The signing key rotation is the operational lever for replacing the signing key without invalidating active sessions. The pattern is covered in Operations runbook. The short version is: SessionLayer::with_previous_signing_key accepts the old key, sessions signed with it continue to validate, and the key passed to SessionLayer::new signs everything new. After enough time for all old cookies to expire, the previous key is removed.

The fingerprint binding

The fingerprint is the additional signal that catches session replay from a different browser. The mechanism takes a few coarse features of the request (the user agent, the IP address, sometimes the accept-language), HMACs them together with a deployment-level pepper, and stores the result alongside the session.

pub trait SessionBinding: Send + Sync + 'static {
    /// The raw binding material. `None` means the signal is absent for
    /// this request, and binding is skipped rather than failed.
    fn extract(&self, req: &Request<Body>) -> Option<Vec<u8>>;
}

// The one that ships:
let layer = SessionLayer::new(store, signing_key)
    .with_binding(UserAgentBinding);

UserAgentBinding returns the User-Agent header. The layer HMAC-SHA256s whatever extract returns, keyed with the session signing key, and stores the digest on the session. There is no separate pepper to configure: the signing key is the secret, which is what stops an attacker who can read session rows out of the store from recomputing a valid fingerprint.

That key choice has one consequence worth knowing. Because the fingerprint is keyed, rotating the signing key changes every fingerprint, so the layer computes the current-key and previous-key values together and accepts either during a rotation window.

Implement the trait yourself for anything richer: the user agent combined with an IP prefix, a TLS channel-binding value, a client hint your front end sets. The contract is small on purpose: return the raw material, and let the layer do the keying and the constant-time compare.

What a mismatch does

There is no policy to configure, and no tolerance to tune. The fingerprint either matches or it does not, and a mismatch resets the session to Guest. The user is logged out; their other sessions are untouched, because the fingerprint lives on the session, not on the user.

That is a deliberately blunt instrument, and it is why the binding signal should be one that does not change under a legitimate user. User-Agent qualifies: it survives a network change, a wifi-to-cellular switch, and a page reload, and it changes on a browser update, which logs the user out once, at an unsurprising moment. An IP-derived binding does not qualify on a mobile network, which is why nothing ships with one.

If you want a softer response, the place to put it is your own SessionBinding implementation: return None when the signal is absent or when you would rather not judge, and binding is skipped for that request.

When the check runs

The fingerprint is recomputed and compared once per HTTP request, at SessionLayer entry. It is set at the earliest transition out of Guest: set_identifying when the username is submitted, begin_authenticating when a multi-factor flow starts, set_authenticated when authentication completes. So even a pre-MFA session cannot be replayed from another device. Once set it is never overwritten.

Once per request is the whole of it, and the gap is persistent connections. A WebSocket or an SSE stream is checked at the upgrade and never again, so a connection that outlives the binding's validity is not re-examined. Where that matters, re-check on the messages themselves rather than relying on the layer.

Reading the client IP

Nothing in the session layer reads a forwarded header, because nothing in the default binding uses the IP. Your own code does, though: a Cedar policy conditioned on ip_address, a rate-limit key, an audit row. That is where the spoofing risk lives, and since 0.7.0 the answer is to resolve once with client_ip::layer and read ClientIp everywhere instead of asking each consumer to work it out.

Reading X-Real-IP, or the first entry of X-Forwarded-For, means taking a value any client can set. Doing that on an internet-facing service means an attacker chooses the IP your policies see, and the name says so at every call site.

The same applies to your audit trail, and there it matters more. extract_audit_context takes the client IP as an argument for the same reason. Hand it an address you resolved yourself, or None: a null ip_address is honest and a forged one is not. The header-reading form is extract_audit_context_untrusted, and the name is the warning.

This matters more than it used to. A failed audit write now fails the login, so those rows are guaranteed to exist, which makes a forged address in them worse rather than better: evidence written by the subject of the evidence.

The defence is to require that the request's actual peer be a proxy you trust before believing anything it forwarded:

use axess::client_ip::{self, TrustedProxies};

// Addresses, CIDR ranges, or both. `TrustedProxies::loopback_only()`
// covers a same-pod sidecar like Envoy or NGINX.
let trusted = TrustedProxies::from_cidrs(["10.0.0.0/8"])?
    .with_cidrs(["2001:db8::/32"])?;

// Resolve once, outside every route, where the peer is still known.
let app = client_ip::layer(app, trusted);

Handlers then take a ClientIp, and the audit context takes itself:

async fn login_route(session: AuthSession, audit: AuditContext, ip: ClientIp) {
    let service = state.authn.with_audit_context(audit);
    // `ip.get()` where a policy or a key needs the address.
}

An empty TrustedProxies trusts nothing and always returns the peer, which is the right default for a service with no proxy in front of it.

The extraction walks X-Forwarded-For from the right, and the reason is worth understanding, because the obvious alternative is broken. The header is append-only: each hop adds the address it saw. So a client can send its own value and a correctly configured proxy will faithfully append the real one after it:

client sends:  X-Forwarded-For: 192.0.2.5
proxy appends:                  192.0.2.5, 203.0.113.9
                                ^^^^^^^^^  ^^^^^^^^^^^
                                attacker   real client

Reading the leftmost entry hands back whatever the attacker chose, through a proxy that did its job. So the walk starts at the right, skips hops that are themselves trusted proxies, and returns the first address that is not. Everything to the left of that is client-supplied and discarded.

Two edge cases follow from the same reasoning. A malformed entry stops the walk and yields the peer, because once one hop's contribution cannot be read, which hop wrote what is no longer knowable. And if every entry is a trusted proxy, the request originated inside your perimeter, so the peer is the answer.

X-Real-IP is consulted only when X-Forwarded-For is absent. It is a single value with no chain to audit, so a proxy that forwards a client-supplied one is indistinguishable from a proxy that set it, and it is honoured only when exactly one such header line is present. More than one means something appended rather than overwrote, which makes the first of them whatever the client sent.

Which kind your proxy is, is worth checking rather than assuming, because the header is not one a proxy sets by default. nginx sets it only where the configuration says proxy_set_header X-Real-IP $remote_addr; without that line a client's value passes through. Caddy never sets it at all, so behind Caddy the value is always the client's. That costs nothing there, because Caddy does set X-Forwarded-For on every proxied request and this walk prefers it, leaving the X-Real-IP branch unreachable. It costs something behind a proxy that sets neither, or that sets X-Real-IP alone: name that proxy in the trusted set and its forwarded value is believed. If you cannot say which your proxy does, strip the header at the edge and let the walk use the chain.

Every X-Forwarded-For line is joined before the walk, in order, for the same reason. RFC 9110 §5.3 makes repeated field lines equivalent to one comma-joined list, but reading only the first would let a client that sends its own header, to a proxy that adds a separate line rather than appending, put the entire walk on ground it controls.

A CIDR with bits set below its prefix is rejected rather than widened. 10.0.0.5/8 reads like one host and means sixteen million, and this is the list that decides whose headers are believed; write 10.0.0.0/8 or 10.0.0.5/32.

Getting the address onto the event

Resolving the address is half of it. The event still has to carry it, and since 0.7.0 that is an extractor rather than something the route assembles:

async fn login_route(session: AuthSession, audit: AuditContext, /* ... */) {
    // The request-scoped handle. Cheap: the collaborators are shared,
    // only the context differs.
    let service = state.authn.with_audit_context(audit);
    service.begin_login(&identifier, tenant, &session).await?;
    service.verify_factor(&credential, &session).await?;
}

AuditContext reads the address the layer resolved, plus the user-agent, request id and session id from the request it is already looking at. There is no argument through which a header-derived address could reach it.

Both calls go through service because there is no other way to make them. begin_login and verify_factor are on RequestAuthnService, the type with_audit_context returns, and not on the AuthnService in application state. Until 0.7.0 they were on both, and a handler that derived the handle and then reached back to state.authn for verify_factor left the failed-password rows blank, which are the rows a brute-force query counts.

RequestAuthnService derefs to the service, so the application-scoped calls (check_session, the revocation methods, the capability predicates) are reachable through the one handle.

Derive it per request and let it die with the request. Holding one in application state pins a single client's address onto every later event, which is worse than a blank one: the rows look complete.

A blank address is still possible, and still honest, where the client-IP layer is not installed or the transport has no address to offer. It is not silent: the row records ip_source = 'unknown', and Audit events has the query that finds them. Where the tenant has an IP policy it is not merely recorded but refused, because a policy that cannot be evaluated has not been satisfied.

Defending against XSS

The cookie's HttpOnly attribute defeats one class of XSS attack (reading the cookie). It does not defeat all of them.

An attacker with JavaScript execution in the page can:

  • Submit requests on the user's behalf (the browser sends the cookie automatically). The defence is CSRF protection: most axess deployments use tower-http's CSRF middleware, which requires a CSRF token on state-changing requests, and the token is not readable from JavaScript.

  • Manipulate the page the user sees to phish credentials or to trick the user into actions. The defence is Content Security Policy (CSP) headers, which constrain what JavaScript the page can load and execute. CSP is an application-side concern, not a session-layer concern, but it composes with the session layer's defences.

The session layer's role is to constrain the cookie. The application's role is to constrain what JavaScript can do in the page. Both layers are needed; the session layer alone does not defend against XSS.

CSRF defences

The session cookie is sent on cross-origin top-level navigations because SameSite=Lax allows it. An attacker can craft a link that, when clicked from an external site, triggers a state change in the user's session (the classic CSRF attack).

The SameSite=Lax default narrows the attack: it works only on top-level GETs and on the Form element, not on XHR or fetch calls. The defences against the remaining surface:

  • Use POST (or PUT, DELETE, PATCH) for state-changing requests. GET requests should be safe.
  • Mount [axess_core::middleware::csrf::CsrfLayer] inside the session layer. It implements the signed double-submit cookie pattern: the token is HMAC-bound to the current session id, so a token minted under one session fails validation once the session regenerates (on login, MFA add, tenant switch). The middleware accepts the token from the X-CSRF-Token header (AJAX) or the _csrf form field (HTML forms: application/x-www-form-urlencoded only; JS-driven multipart uploads should use the header). Adopters who need cross-origin/deferred use cases can layer tower-http's middleware instead.
  • Enable Origin/Referer validation as defence in depth via [CsrfConfig::require_origin]. Off by default (would break server-to-server bearer-token clients hitting browser routes); on when configured, state-changing requests must present an Origin (or Referer-derived) that matches one of the allowed origins, AND pass the token check.
  • For applications that need cross-origin embedded use, SameSite=None plus a strict CSRF token check is the combination. SameSite=None requires Secure, so the combination is only deployable on HTTPS.

What goes wrong, and how to tell

Three failure modes recur during initial deployment.

A cookie the browser refuses to send. The symptom is sessions that disappear between requests; the cause is almost always either Secure=true on an http:// connection (the browser refuses to send), SameSite=Strict on a cross-site navigation that should have been recognised, or a Path that does not match the request URL. Inspect the cookie's attributes in the browser's dev tools.

A fingerprint that diverges for the legitimate user. The symptom is a Warn log every few sessions or a Reauth that fires on every wifi-to-cellular switch. The cause is usually the tolerance being too strict; widen the IP prefix or relax the user-agent match. The right tolerance is the smallest one that does not produce noise on legitimate traffic.

A trusted-proxy configuration yielding the wrong IP. The symptom is a fingerprint that matches when it should not (an attacker successfully replaying a cookie), or that diverges when it should match (a legitimate user being asked to re-authenticate). The cause is either an unintentionally trusted source (a debug endpoint left open, a VPN allowed to spoof the header) or an unintentionally untrusted proxy (the deployment forgot to add a new proxy's IP to the trusted list).

The pattern across all three: turn on the diagnostic logs, let the deployment run for a week, look at the warning rate, calibrate.

Further reading

Session lifecycle and crypto envelope covers the cookie shape and the orchestration that issues it. Backends covers the storage backends that persist the fingerprint alongside the session. Security posture covers the production crypto requirements that apply to the session layer, including the signing-key length and the FIPS-routing notes. Operations runbook covers signing-key, envelope-key, and fingerprint-pepper rotation.

Schema migration

The SessionData struct can change between axess versions. New fields get added, old fields get renamed or removed, the auth state machine gains a new variant. Existing sessions in the store carry the old shape; new code reads them and needs to produce the new shape. The mechanism that bridges the two is the schema migration on read.

This is a short chapter because the mechanism is small. The mechanism is small because the design pushes the version field into the data itself rather than into the store.

The version field

SessionData::version is a u8 set at construction and serialised with the rest of the data. The current value is the SESSION_DATA_VERSION constant. At read time the deserialiser fills in whatever a newer field's #[serde(default)] supplies, and migrate then walks the row forward one version step at a time.

pub const SESSION_DATA_VERSION: u8 = 2;

pub struct SessionData {
    #[serde(default = "default_version")]
    pub version: u8,
    pub auth_state: AuthState,
    pub fingerprint: Option<String>,
    #[serde(default)]
    pub device_id: Option<DeviceId>,
    pub custom: serde_json::Value,
}

impl SessionData {
    /// Returns whether anything changed, so the caller knows to re-save.
    pub fn migrate(&mut self) -> bool {
        if self.version >= SESSION_DATA_VERSION {
            return false;
        }
        // v1 -> v2: added `device_id`. The field's serde default already
        // supplied `None`, so the only work is bumping the version so a
        // re-save records the current schema.
        // ... one arm per step, each bumping `self.version` itself.
        true
    }
}

Three properties of that shape are worth stating.

It migrates in place, &mut self, and returns whether anything changed. That boolean is the signal to persist: a row already at the current version is not rewritten, so a deploy does not rewrite every session in the store on first read.

Each version step bumps self.version itself, and there is no unconditional assignment to SESSION_DATA_VERSION at the end. The trailing assignment is tempting and wrong: it makes every step look correct from the outside, because the version ends up right whether or not the step ran. Bumping per step keeps each one observable, which is what lets a mutation test tell "this branch runs" from "this branch is dead".

Most steps do nothing but bump. A field added with #[serde(default)] is already correct in memory by the time migrate sees it; the migration exists to record that the row has been read under the new schema. Real transformation work only appears when a field changes meaning rather than merely appearing.

The migration functions are pure transformations. They take the old shape (which serde has parsed against an older SessionData definition, possibly with the version-bumped fields defaulted) and produce the new shape. Each migration handles one version step; chained migrations are run in sequence to bridge multiple version gaps.

The version is bumped every time the shape changes in a way that older code would not handle correctly. Adding an optional field with a Default impl typically does not bump the version (older code reads None, which is fine). Removing or renaming a field does. Changing the meaning of a field does.

What migrations cannot do

A migration is a pure function on the serialised bytes. It cannot talk to a database, cannot consult the user store, cannot make network calls. The version of the data is determined entirely by what is in the cookie's session record at the moment of read.

The implication: if a new shape needs information that the old shape did not carry, the migration cannot synthesise it. The options are to default the field (set it to None, or to a known placeholder), to discard the session (the migration returns an error, the layer treats the session as invalid and starts a fresh one), or to defer the population (the field is set later in the request lifecycle from your own stores).

The first option is the standard pattern. New fields get sensible defaults, the session continues to work with the new shape, and you populate the real value on the next dirty write.

When the session is invalidated

Sometimes the shape change is breaking in a way that no migration can bridge. The session's data refers to a user who has been deleted, the auth state references a tenant that no longer exists, the factor list contains a kind that the new version has removed. The migration's right response is to error, and the layer's right response is to treat the session as invalid.

The mechanism is the SessionData::deserialize path returning Err. The session layer catches the error, deletes the session row (or marks it expired), and treats the request as a fresh Guest. The user's cookie is still valid; the next request sets a new session, the user logs in again.

The pattern is the right one because the alternative (the layer falling through to a degraded state, leaving the session in an inconsistent shape) lets bugs persist for the lifetime of the session. Invalidating eagerly converts the bug into a one-time user-facing event (re-login) that is fixable in one round-trip, rather than a long-tail bug that surfaces sporadically.

Adding a custom field

Adopters who add their own fields to SessionData::custom follow the same pattern in their own code. The custom value is JSON-shaped; each application-owned key is independently versioned by you.

The common pattern is to wrap the custom value in a small struct with its own version field:

#[derive(Serialize, Deserialize)]
struct MyAppSessionData {
    schema_version: u32,
    preferences: UserPreferences,
    feature_flags: Vec<String>,
    draft_form_state: Option<DraftForm>,
}

fn read_app_data(session: &SessionData) -> MyAppSessionData {
    session
        .custom
        .get("my_app")
        .and_then(|v| serde_json::from_value::<MyAppSessionData>(v.clone()).ok())
        .map(|d| d.migrate_if_needed())
        .unwrap_or_default()
}

The application's own version field is independent of axess's. The two evolve on different cadences, and your own version field captures your changes.

When to reach for a different mechanism

The schema migration is the right tool for evolutions of the session data shape. It is the wrong tool for migrations between storage backends (use the cross-backend Store<K, V> trait or a one-off copy script) or for changes to the encryption envelope (the key-rotation mechanism, covered in Operations runbook).

It is also the wrong tool for application-level data migrations that touch the database. A migration that says "every user gains a new field on their user record" runs against the user store (via sqlx::migrate! or whatever migration tool you use), not against the session store. The session machinery does not interact with the user table.

The mechanism's scope is narrow on purpose. Each piece of state has its own evolution mechanism, and conflating them produces migrations that have to consider too many cases at once.

Further reading

Session lifecycle and crypto envelope covers the lifecycle that the migration runs as part of. Backends covers the storage backends and their own (database-level) migration mechanisms. Migration guide in Part VIII covers the cross-axess-version migrations that bump the SESSION_DATA_VERSION constant.

The principal model

A Principal in axess is the answer to "who is making this request?" The unusual choice, and the one this chapter explains, is that the same type answers the question for human users and for service-to-service workloads. A signed-in employee opening a page and a CI job calling an API are both principals, with different variants but the same trait surface, the same authorisation contract, and the same place in the audit trail.

The alternative, two parallel authentication stacks, was rejected, and the reasoning is below.

The type

Principal lives in axess-identity:

pub enum Principal {
    Human(HumanPrincipal),
    Workload(WorkloadPrincipal),
}

pub struct HumanPrincipal {
    pub user_id: UserId,
    pub tenant_id: TenantId,
    pub session_id: Option<SessionId>,
    pub attributes: BTreeMap<String, serde_json::Value>,
}

pub struct WorkloadPrincipal {
    pub workload_id: WorkloadId,
    pub trust_domain: TrustDomain,
    pub issuer: Issuer,
    pub tenant_id: TenantId,
    pub tenant_slug: String,
    pub service_name: String,
    pub attributes: BTreeMap<String, serde_json::Value>,
}

The two variants are intentionally not symmetric. They carry the data each principal kind actually has. A human has a user_id and is optionally inside a session (some flows act on behalf of a user without a live HTTP session, which is why the field is Option). A workload has a workload_id (a SPIFFE-format URI), a trust domain, and an issuer that says how the principal was authenticated (which OIDC provider, which JWKS, which SPIFFE control plane).

Both variants carry a tenant_id (because every request happens in the context of a tenant, whether the caller is human or not) and an open attributes map (because policies need to ask questions that the fixed fields cannot answer). The attribute map is JSON-valued so that custom attributes (a hardware-key serial, a CI build hash, a regulator classification) can be carried without changing the type.

Where each variant comes from

The two variants are constructed by two different resolvers. The split is what keeps the human and workload sides from contaminating each other.

A HumanPrincipal is constructed by a SessionResolver from an AuthSession. The resolver reads the session's AuthState, returns None if the state is not Authenticated, and otherwise reads user_id, tenant_id, and the session id off the variant. The attributes map is populated from the resolved user's stored profile data (which fields depend on the application's identity store). Construction is synchronous and cheap because everything the resolver needs is already on the session.

A WorkloadPrincipal is constructed by a PrincipalResolver from an inbound credential (a bearer JWT, an mTLS client certificate, a projected Kubernetes service-account token, a GitHub Actions OIDC token). The resolver does the verification work (signature, audience, expiry, sometimes a token-exchange against a control plane) and on success returns a WorkloadPrincipal with the validated identity. The work is async because verifying tokens typically involves fetching the issuer's signing keys (its JWKS, the JSON Web Key Set published at a well-known URL) or a round-trip to a Security Token Service (STS), which exchanges one credential for another. The chapter Workload identity overview covers the resolver landscape end-to-end.

The two resolvers are independent. An application that has no workloads (a customer-facing SaaS, say) never wires a PrincipalResolver and never sees a Workload variant. An application that has only workloads (an internal data-pipeline API, say) never wires a SessionResolver and never sees a Human variant. An application that mixes both wires both resolvers and a small piece of glue that decides which to consult given the incoming request shape.

Why one type

The natural alternative is two types and two stacks: a User for humans, a Service for workloads, a different middleware for each, a different authorisation contract for each, two parallel audit trails. That shape is what most libraries ship, and it is what axess deliberately rejects.

The argument for one type is straightforward when you start to write the authorisation policy. A request to a billing endpoint might be made by a finance staff member during office hours, or by a scheduled job running the monthly invoicing batch. The policy that decides whether the request is allowed is the same in both cases: this caller, in this tenant, has the right to read this resource. With one Principal type, the policy is one rule. With two types, the policy either duplicates the rule (and the duplicates drift) or branches on the caller kind (and the branches obscure the intent).

The same applies to the audit trail. A regulatory audit log that records "principal X performed action Y against resource Z at time T" works uniformly across human and workload callers when the principal type is unified. The downstream rules in a SIEM, the security information and event management system that collects these logs ("alert on any principal making more than N requests per minute to the high-sensitivity endpoint") fire on both human attacks and runaway workloads, without separate detection logic.

The unification has a cost. The Principal enum must accommodate both variants, which makes its memory footprint larger than either variant alone, and pattern-matching code has to handle both arms even when the application only uses one. The cost is paid mostly in code that loads the principal (one match per request), and not in policy evaluation or audit emission (which see the trait surface). On balance, the unification pays for itself by simplifying the policy layer.

SPIFFE shape for workloads

The WorkloadPrincipal is shaped after SPIFFE because SPIFFE is the right shape for workload identity even when the underlying credential is not literally a SVID.

A SPIFFE identity is a URI of the form spiffe://<trust_domain>/<path>. The trust domain is the federation's namespace (prod.example.com, say), and the path identifies a specific workload within that domain (/svc/billing/tenant-acme). The combination uniquely names the workload, the trust domain parameterises the verification (each domain has its own signing keys), and the path is structured enough for policies to match on patterns ("any workload under /svc/billing/*") without inventing parallel identity stacks.

Axess's workload identity layer uses this shape even when the inbound credential is a Kubernetes service-account token (which is an OIDC token, not a SVID) or a GitHub Actions OIDC token (which is also not a SVID). The relevant resolver constructs a SPIFFE-format WorkloadId from the inbound claims; downstream code sees a uniform identity. Workload identity overview covers the construction rules for each resolver.

The trust domain and issuer fields on WorkloadPrincipal are the part that policies can use to discriminate between identity sources. A policy that says "only workloads issued by our production control plane may write to the production database" reads the issuer and matches against a fixed list. A policy that says "any workload in the finance trust domain may read the audit log" reads the trust domain.

The Cedar bridge

Cedar policies take principals as entities. Axess implements ToCedarEntity for both HumanPrincipal and WorkloadPrincipal, producing entities with the canonical shape Cedar expects.

A HumanPrincipal becomes a Cedar entity with UID User::"<user_id>", attributes including tenant_id, factors_completed, and authn_time, and parent entities for the tenant and any groups the user belongs to (which the application provides through AuthzEntityProvider, covered in Entity providers and request context).

A WorkloadPrincipal becomes a Cedar entity with UID Workload::"<spiffe-uri>", attributes including trust_domain, issuer, and tenant_id, and parent entities for the trust domain and the tenant. Policies that want to match all workloads in a trust domain write principal in TrustDomain::"prod.example.com"; policies that want to match a specific workload pattern write principal.workload_id like "spiffe://prod.example.com/svc/billing/*".

The bridge is what makes one type into one policy. A Cedar policy that says

permit (
  principal,
  action == Action::"read",
  resource in TenantData::"acme"
) when {
  principal.tenant_id == "acme"
};

allows both a human user in tenant acme and a workload bound to tenant acme. The principal type does not appear in the rule because it does not need to. If the policy later needs to discriminate (say, to require MFA for humans but not for workloads), the rule that expresses the discrimination is local and readable.

When the type is empty

Some flows operate without a principal: a health check, a metrics endpoint, the login page itself. Axess models this by representing the request as Option<Principal>. The resolver returns None, the authorisation layer either short-circuits (for unauthenticated endpoints) or evaluates against principal == Principal::None (for endpoints that take a deny-by-default position toward unauthenticated callers).

The pattern matters for one specific reason. A misconfigured resolver that returns a stub principal for unauthenticated requests, instead of None, silently widens the authorisation surface. The Cedar policy evaluates against the stub and may allow actions that should require authentication. Treating "no principal" as the absence of a value, rather than as a kind of value, makes the policy author's life harder in the short term and easier in the long term: a policy that does not explicitly admit None denies it by default.

What the unified type saves you

The unified principal type is what makes the rest of the workload identity story (Part VII) and the Cedar authorisation story (Part IV) short. A handler reads Principal, the authorisation layer evaluates policies against it, and the audit pipeline emits events keyed by it. None of these layers need to know whether the caller is a human or a workload, because the type carries both possibilities and the policy author resolves the discrimination where it actually matters.

Further reading

Workload identity overview covers the resolvers that produce WorkloadPrincipal values: SPIFFE JWT-SVID, SPIFFE mTLS, Kubernetes ServiceAccount tokens, GitHub Actions OIDC, generic OAuth-RS, and cloud STS exchange. Cedar policy fundamentals covers the AuthzSession::require and AuthzSession::is_permitted calls that take a Principal and return an AuthzDecision. Audit events covers the log emitted for each authentication and authorisation decision, including the principal serialisation.

Device identity

A device in axess is a typed aggregate, not a string in a column. A user has zero or more devices; each device has a stable identifier, a fingerprint that the session layer can match against, an assurance level on a three-stage ladder, and a relationship to the refresh tokens issued against it. The combination is the machinery behind "this device was lost, revoke its access" and "this is a new device, require step-up before we trust it." The mechanism is opt-in but on by default in the axess facade because most adopters benefit from it without specifically asking.

The feature flag is device (on by default).

The model

What a device is to axess, and what makes its identifier unguessable.

The three-stage ladder

A device occupies one of four states. The first three form an assurance ladder; the fourth is terminal.

Unknown is the default for a new device. The session layer has seen this fingerprint for the first time, the user has not yet confirmed it, and no commitment has been made about trust. An unknown device can still authenticate (the user enters their password and second factor as usual), but step-up policies may require additional friction (a second confirmation email, a recovery code) before high-sensitivity actions become available.

Seen is the second state. The device has authenticated successfully at least once; the user has implicitly accepted it by continuing through the login. A seen device retains the fingerprint binding from the session layer but does not yet carry explicit trust. It is the right state for a device that the user might log in from again but has not explicitly registered.

Trusted is the third state and the steady state for primary devices. The user (or the application's administrative flow) explicitly trusted this device. The device's fingerprint binding applies; the device is the bound carrier for refresh tokens; the device can perform high-sensitivity actions without additional step-up.

Revoked is the terminal state. The device was lost, the user removed it, the security team forced a revocation, or the system detected compromise. Tokens bound to the device are revoked, sessions bound to it are deleted, and further authentication attempts from the fingerprint are blocked until the user explicitly re-establishes the device.

The transitions move strictly forward through the ladder. Unknown becomes Seen on first successful login. Seen becomes Trusted on explicit user action or after an application-configurable trust period. Any state becomes Revoked on revocation. Revoked is terminal; a device that was revoked and is later re-encountered registers as a new Unknown device.

The device record

The Device struct carries the per-device state:

pub struct Device {
    pub device_id: DeviceId,
    pub user_id: UserId,
    pub tenant_id: TenantId,
    pub trust_level: DeviceTrustLevel,  // Unknown | Seen | Trusted | Revoked
    pub fingerprint_hash: String,        // HMAC against the per-tenant pepper
    pub display_name: Option<String>,   // user-set ("My laptop")
    pub first_seen_at: DateTime<Utc>,
    pub last_seen_at: DateTime<Utc>,
    pub trusted_at: Option<DateTime<Utc>>,
    pub revoked_at: Option<DateTime<Utc>>,
}

The device_id is a stable identifier minted at first sight. It is what refresh tokens bind to (see Refresh tokens and session continuity), what Cedar policies can reference, and what the admin UI lists when the user inspects their registered devices.

The fingerprint_hash is the HMAC of the device's fingerprint features against a per-tenant pepper. The hash, not the raw fingerprint, lives in the database; the raw features are computed per request and matched constant-time. Storing the hash defends against database breach: an attacker who reads every row of the device store does not learn the underlying fingerprint features of any user.

The display_name is for the user. When the device transitions from Seen to Trusted the application typically asks the user to name it ("My laptop", "iPhone 15 Pro"); the name appears in the user's device-management UI. It is not used for authentication.

The per-tenant pepper

The fingerprint pepper is the secret the HMAC uses. Two design choices matter.

The pepper is per-tenant, not global. Each tenant has its own pepper, stored alongside the tenant record. The choice means that a fingerprint hash from tenant A cannot be matched against tenant B's hashes; a breach that leaks one tenant's pepper compromises only that tenant's fingerprint hashes.

The pepper is rotated when the tenant is suspended or when the deployment chooses to invalidate all device records. Rotation invalidates every device record under the tenant (their fingerprint hashes no longer match the new pepper); existing sessions remain valid (they do not depend on the device record), but new logins re-register devices from scratch.

The chapter Operations runbook covers the rotation sequence and the staged rollout.

How devices are used

Refresh tokens, step-up decisions, and recognising a returning device.

How devices interact with refresh tokens

The cascade between devices and refresh tokens is bidirectional and is what makes "revoke this device" actually mean "revoke every session this device can refresh."

In one direction: when a device is revoked, every refresh token that carries device_id = revoked_device is invalidated. The next attempt to use any of those tokens fails. The application's session layer detects this on the next refresh and treats the session as expired.

In the other direction: when a refresh token family is invalidated through reuse detection (the family-revoke mechanism covered in Refresh tokens), the cascade marks the bound devices as compromised. The compromise is the shortcut from Trusted (or Seen) to Revoked without an intermediate state.

The cascade is what makes the system robust against both operator-initiated revocation ("the device was lost") and attack-driven revocation ("a token was stolen"). The two cases converge on the same revocation primitive; both directions of cascade fire from the same code path.

Step-up policies

The trust level becomes interesting at the Cedar policy layer. A policy that wants to require a Trusted device for sensitive actions reads principal.device.trust_level == "Trusted":

forbid (
    principal,
    action == Action::"transfer-funds",
    resource
) when {
    principal.device.trust_level != "Trusted"
};

The rule denies fund transfers from any device that is not Trusted. A user on a new (Unknown or Seen) device is prompted to trust the device first, typically by completing an additional verification step (a second-factor challenge, a confirmation email, a step-up to FIDO2).

The pattern composes with the other authorisation styles. A policy that requires both FIDO2 and a Trusted device is the two constraints together; a policy that allows any of three different ways to clear the bar is the disjunction in one rule.

Identifying a device

Each request needs to be associated with a device. The mapping runs through the DeviceResolver trait:

pub trait DeviceResolver: Send + Sync + 'static {
    type Error: std::error::Error + Send + Sync + 'static;

    fn resolve(
        &self,
        parts: &Parts,
    ) -> impl Future<Output = Result<Option<DeviceId>, Self::Error>> + Send;
}

Three things in that signature decide how device tracking behaves.

It takes axum::http::request::Parts, not the whole request, because axum::body::Body is !Sync and a Send future may not hold it across an await. The session layer splits the request before calling and reassembles it afterwards, so a resolver sees headers, extensions and the URI but never the body.

It returns Option<DeviceId> rather than a match enum. There is no "existing versus new" distinction at this seam: whether a device row was found or minted is the resolver's business, and the layer only wants the id to attach. Ok(None) is the ordinary "no device" answer, not a failure: a request with no User-Agent, or one arriving before the tenant is known, resolves to nothing at all.

The error type is the implementor's associated Error, typically the DeviceStore::Error underneath. Resolution is best-effort: the session layer logs an Err(_) and continues without a device rather than failing the request. A device store outage degrades device tracking; it does not take authentication down with it.

Two implementations ship. NoopDeviceResolver always answers Ok(None), and is the default plug when the device feature is on but nothing has been configured. LifecycleDeviceResolver is the turn-key one: it wires a DeviceFingerprintExtractor to a DeviceLifecycleService, computing the fingerprint from request features and either matching an existing device or minting one with trust_level = Unknown.

LifecycleDeviceResolver has four hooks, because those are the four things that differ between deployments:

  • tenant_fn, defaulting to a TenantId in the request extensions.
  • client_ip_fn, defaulting to None, because reading it means either ConnectInfo or a trusted X-Forwarded-For.
  • user_fn, defaulting to None, because the resolver runs before authentication.
  • new_id_fn, defaulting to a v4 UUID, overridden in DST tests for determinism.

If tenant_fn yields None the resolver short-circuits to Ok(None): there is no meaningful device without a tenant scope, for the cross-tenant-correlation reason this chapter covers below. A fingerprint the extractor cannot compute short-circuits the same way.

Applications with a stronger device signal (a long-lived hardware key, a mobile app's installation id, a device certificate) implement DeviceResolver themselves, consult the stronger signal first, and fall back to the fingerprint match.

Performance and privacy

Keeping the lookup cheap without keeping the fingerprint.

Caching

The device record is read on most requests (every authenticated request that involves a Cedar evaluation reads the device). A naive lookup against the device store would be the hottest read in the application.

The CachedDeviceStore decorator wraps any DeviceStore with an LRU+TTL cache. The cache key is (tenant_id, device_id); the cache value is the Device record. The TTL is short (a few seconds) so revocations propagate quickly; the LRU bound constrains memory under fan-out scenarios.

Revoking a device is DeviceStore::set_trust_level(tenant_id, id, DeviceTrustLevel::Revoked, now). There is no revoke verb, and there is no cache invalidation hook: the TTL is the only thing that expires a cached Device, so a revocation is visible to other readers within a few seconds rather than immediately. Keep the TTL short for that reason, and call set_trust_level on the same instance you read through if you need the change to be visible to yourself at once.

delete is the harder form, removing the record outright. Prefer Revoked where the audit trail matters: a deleted device leaves no evidence that it was ever trusted.

The pattern is the same one Entity providers and request context covers for the Cedar entity cache. Cache the data, not the decision; invalidate eagerly on mutation; let TTLs catch the cases the invalidation missed.

PII tokenisation and GDPR

The device record carries personally-identifiable information. The fingerprint features include the IP address (which is PII under GDPR), the user agent (which can carry identifying details about the user's setup), and the timestamps (which together can identify the user's working patterns).

The defence is twofold.

The first is that the device store holds hashes, not the raw features. The fingerprint hash is the HMAC against the per-tenant pepper; an attacker who reads the store sees the hash, not the IP or user agent.

The second is the sweep. DeviceStore::sweep(tenant_id, now) ages devices down through their trust levels and eventually removes them, returning a SweepCounts of what it did:

pub struct SweepCounts {
    pub trusted_to_seen: u64,
    pub seen_to_revoked: u64,
    pub revoked_purged: u64,
}

Those three numbers are the three transitions, and they are the shape of the retention story: a trusted device that has not been seen in a while drops to Seen, a Seen device that keeps not being seen is revoked, and a revoked device is purged after a grace period. Only the last one deletes anything, so a device does not vanish the moment it goes quiet.

The thresholds are a SweepConfig, not a tenant column: trusted_idle (ninety days by default), seen_idle (thirty) and revoked_grace. A deployment that needs different retention per tenant holds its own config per tenant and passes the right one. Axess does not store that mapping, and Tenant carries no retention field to hold it.

The sweep takes now rather than reading a clock, so a test can drive it to any instant and a scheduled job can run it deterministically. Nothing calls it for you: wire it to whatever runs your periodic work.

The chapter Multi-tenancy covers the per-tenant configuration mechanism. Security posture covers the GDPR and SOC2 touch-points.

Storage backends and writing your own

axess ships five DeviceStore implementations:

BackendFeatureNotes
MemoryDeviceStorememoryDashMap + clock-driven sweep. Dev and tests.
SqliteDeviceStoresqliteSQLx pool, INSERT … ON CONFLICT, schema in init_schema().
PostgresDeviceStorepostgresSQLx pool, same surface as the sqlite backend with the Postgres dialect.
MysqlDeviceStoremysqlSQLx pool, MySQL dialect (? binds, ON DUPLICATE KEY UPDATE, VARBINARY(32)). Compatible with MySQL 8.x and MariaDB 10.5+.
ValkeyDeviceStorevalkeyHash-per-device + per-tenant fingerprint index. Server-side EXPIRE handles purge.

All five SQL/Valkey backends share the same trait surface; switching between them requires only the init_schema call against the new pool and a different constructor at startup.

Writing an adopter-supplied store

Any storage technology can back devices as long as it can answer the ten methods on axess_core::device::DeviceStore. The shipped backends (memory, sqlite, postgres, valkey) are the reference implementations to read alongside the trait docstring; the recipe below names the contracts that aren't obvious from method signatures.

Type and Error. Implement the trait on a Clone + Send + Sync + 'static struct (typically Arc<...> around your connection pool / client). Pick a single `type Error: std::error::Error + Send + Sync

  • 'static; the existing backends use a thiserrorenum that wraps their driver error + a "missing row" variant. Don't conflate driver errors with domain errors (aNotFoundreturned by your driver should not surface asSome(Device)inload; map it to Ok(None)`).

Tenant scoping is mandatory. Every method that takes a TenantId must filter on it in the query. The peppered FingerprintHash is already keyed per-tenant, but the trait contract documents the scoping requirement explicitly to prevent cross-tenant leakage on a backend whose primary index might otherwise be only by hash. Read the docstring on find_by_fingerprint for the rationale.

save must be atomic. save is documented as idempotent upsert. Implementations that do SELECT + INSERT racy-checks must wrap them in a transaction or use the dialect's native upsert (ON CONFLICT, ON DUPLICATE KEY UPDATE, MERGE, or SETNX for KV stores). A non-atomic save produces lost updates under concurrent device-promotion calls.

record_sighting is hot-path. Every authenticated request touches this. Implement it as a single UPDATE … SET last_seen_at = ? rather than a load-modify-save round trip. The shipped backends are a guide. The CachedDeviceStore decorator (see caching, above) shields the underlying store from read pressure but the write path runs through every request.

sweep is required, not defaulted. A backend that doesn't implement sweep cannot age devices through the three-stage ladder, and the documented retention posture (90d trusted / 30d seen / 7d revoked grace) silently breaks. The trait deliberately omits a default impl so backends must answer the question, even if the answer is Err(_) with a "sweep not yet implemented" sentinel during initial development.

Sighting timestamps come from a Clock. Methods that need "now" (record_sighting, set_trust_level, sweep) accept now: DateTime<Utc> as a parameter. Callers thread clock.now() through; backends never call Utc::now() themselves. This preserves DST determinism for adopter integration tests.

Mirror the per-backend test layout. Each shipped backend has its own test module exercising the trait surface end-to-end (load round-trip, fingerprint lookup, refresh-family fan-out, retention sweep); device/storage/sqlite/tests.rs is the most complete template. Copy that suite, adapt the harness setup to your backend, and run it to catch the non-obvious contract violations (tenant-scoping leaks, non-atomic save races, sweep counts off-by- one).

Reach for CachedDeviceStore over reinventing. If your gap is "my backend is slow on load", wrap your store in CachedDeviceStore before optimising the implementation. The decorator gives you bounded-size LRU + clock-driven TTL eviction for free, with revocation propagating through set_trust_level.

The connective tissue

Device identity is the connective tissue between the user, the sessions they hold, the refresh tokens those sessions issue, and the authorisation decisions the application makes about them. A user with a known device gets a smoother experience: the fingerprint binding holds, the refresh tokens roll, the policies default to trust. A user with an unknown device gets friction exactly when it makes sense: a step-up before sensitive actions, a confirmation before high-trust operations. A user with a revoked device gets nothing, immediately.

The mechanism is small (a handful of types, one ladder, one cascade) but its reach is wide (every refresh, every policy evaluation, every audit event). Once you have the device aggregate in mind, the rest of the security model falls into place around it.

Further reading

Refresh tokens and session continuity covers the binding between devices and tokens, including the cascade in both directions. Cedar policy fundamentals covers how policies read the device's trust level. Multi-tenancy covers the per-tenant fingerprint pepper and retention configuration. Security posture covers the GDPR and SOC2 implications of device data.

Multi-tenancy

A tenant in axess is the unit of isolation. Users, factor configurations, sessions, devices, policies, and audit events all carry a TenantId, and the library refuses to leak data across tenants by construction.

The mechanism is on by default, and no feature flag exists to toggle tenancy; the TenantId field is present on every relevant record. A single-tenant deployment uses one well-known TenantId ("default" is the convention) and effectively gets the multi-tenant machinery for free, ready to expand when a second tenant is added.

The model

What a tenant is, and the boundary the type system enforces.

The tenant record

The Tenant struct lives in axess-core and is deliberately thin:

pub struct Tenant {
    pub id: TenantId,
    pub identifier: Arc<str>,      // slug or domain used for lookup
    pub display_name: Arc<str>,
    pub status: EntityState,       // same lifecycle enum a user's status uses
    pub created_by: UserId,
    pub created_at: DateTime<Utc>,
    pub updated_by: UserId,
    pub updated_at: DateTime<Utc>,
}

Note what is not on it. There is no per-tenant lockout policy field, no fingerprint pepper, no retention setting. Per-tenant policy is resolved through IdentityLookup, not stored on the struct: lockout_policy_for_tenant, password_rules_for_tenant and ip_policy_for_tenant are trait methods with defaults, and a deployment that sells a stricter tier overrides them against its own table. That keeps Tenant a row an adopter can map onto whatever they already have, rather than a schema axess imposes.

status is EntityState (Guest, Candidate, Pending, Active, Suspended, and the closed state), shared with users rather than a tenant-specific enum, which is what makes "is this principal usable" one question at both levels.

created_by and updated_by are UserIds, so every tenant row names the actor behind it. For an operator-onboarded tenant that is UserId::system(); for a self-service signup it is typically the first admin.

The TenantId is a typed UUID (the convention in axess-identity). The status carries the tenant's lifecycle state, covered below. The fingerprint_pepper is the per-tenant device pepper from Device identity. The lockout_policy is the tenant-scoped override of the global lockout configuration, covered in the Three-lever lockout section below. The device_retention_days is the per-tenant GDPR-shaped retention period for device records.

Cross-tenant refusal as a structural rule

Every operation in axess that touches a user, a session, a device, a factor, or an event carries a tenant scope. The library checks the scope before performing the operation, and refuses any operation where the scopes do not align.

The pattern is uniform across the API. A begin_login call takes a tenant id; the user lookup is scoped to that tenant; a user with the same username in a different tenant is not returned. A verify_factor call works against the session's tenant id; a factor configuration registered in a different tenant is not consulted. A find_sessions_for_user call takes both user id and tenant id; sessions in other tenants are not returned.

The structural defence is what lets a multi-tenant deployment make the strongest possible authorisation claim: not only does the application not leak across tenants, the library underneath cannot. The Cedar policy layer can then add a top-level forbid rule that catches the rare case of an application bug that tries to authorise across tenants:

forbid (
    principal,
    action,
    resource
) when {
    principal.tenant_id != resource.tenant_id
};

The rule applies to every action on every resource, and the combination of "library refuses cross-tenant lookups" and "policy denies cross-tenant decisions" produces a deployment where a cross-tenant access is structurally impossible.

Atomic provisioning

A tenant comes into existence through create_tenant, the verb behind any "sign up a new organisation" or "administrator provisions a new tenant" flow. It is a free function rather than an AuthnService method, for the reason given below: it needs both stores.

use axess_core::authn::provisioning::{TenantBootstrap, create_tenant};

let tenant = Tenant::new(
    tenant_id,
    "acme",                      // lookup identifier
    "Acme Inc.",                 // display name
    UserId::system(),            // created_by
    clock.now(),
)?;

let (tenant, method) = create_tenant(
    &identity_store,
    &factor_store,
    TenantBootstrap {
        tenant,
        // Which factors this tenant may use. `default_catalog()` is the
        // shipped set; filter or extend it per tenant.
        factors: default_catalog(),
        // `None` takes a method derived from the factors.
        method: Some(AuthMethod {
            name: "password-then-totp".into(),
            steps: vec![
                FactorStep::Required(FactorKind::Password),
                FactorStep::Required(FactorKind::Totp),
            ],
        }),
    },
)
.await?;

create_tenant is a free function over an IdentityStore and a FactorStore, not a method on AuthnService, because provisioning touches both and belongs to neither. It returns the tenant and the AuthMethod it installed.

The bootstrap creates no administrator. Creating the first user is a separate create_user call, which means a caller who wants "tenant plus admin, or neither" wraps both in their own transaction. Bootstrapping with an empty factors list is refused outright (ProvisioningError::NoFactorsSpecified), because a tenant whose users cannot present any factor cannot be logged into.

The atomicity matters because a partially-provisioned tenant is a landmine. A tenant that exists in the tenant table but has no configured method admits any user with the system default method, which may not be what the new tenant wants. A tenant with a method but no factor configurations for the admin user produces an immediate lockout. A tenant with an admin user but no factor secret for them is worse: the user record exists, the admin cannot log in, and there is no path to recovery without an out-of-band intervention.

The bootstrap struct is the contract that says "a tenant exists only after every one of these has succeeded." The implementation runs the create-tenant, create-user, create-factor-config, create-method, set-fingerprint-pepper, set-lockout-policy operations in a single transaction. On any failure the transaction rolls back; nothing is persisted; the call returns an error.

A subtler invariant in the bootstrap: every tenant must have at least one factor and one enabled method, and the admin user must have a factor configuration for every factor the method requires. The bootstrap checks both at construction; a misshapen bootstrap fails before the transaction starts.

The three-lever lockout

Lockout is the mechanism that prevents an attacker from brute-forcing credentials. Axess has three levers, applied at three scopes, that compose.

The first lever is per-user lockout. After a configurable number of failed factor verifications against the same user account, that account is locked for a configurable interval. The default is three failed attempts followed by a fifteen-minute lockout with exponential backoff on repeated failure.

The second lever is per-tenant lockout. After a configurable number of failed factor verifications across any user in the tenant within a short window, the tenant's login surface as a whole is throttled. The default is high enough that legitimate traffic does not trigger it; the lever exists to catch distributed brute-forcing across many accounts in the same tenant.

The third lever is per-IP lockout. After a configurable number of failed verifications from the same source IP within a short window, that IP is throttled or blocked outright. The default is ten attempts per minute, beyond which the requests are rejected without engaging the factor verifier. The lever catches a single attacker source attempting many accounts.

The three levers compose multiplicatively. A successful attack needs to dodge all three: stay below the per-user threshold, stay below the per-tenant threshold, and either spread across many source IPs or stay below the per-IP threshold. The cost of the attack grows as a product of the three.

The lockout configuration is in LockoutPolicy, and it is one scale, not three:

pub struct LockoutPolicy {
    pub max_attempts: u32,             // default 5
    pub duration: Option<Duration>,    // default 15 min; None = indefinite
    pub attempt_window: Duration,      // default 1 hour
    pub on_counter_unavailable: CounterUnavailable,   // default Lock
}

max_attempts is compared against the count IdentityAuthnLog::record_failed_attempt returns. duration is how long the lock lasts, and None means it does not expire on its own: an administrator has to clear it. attempt_window is how far back failures count, so five failures spread over two hours do not lock an account whose window is one hour.

The scoping is per user only. Per-tenant and per-IP lockout scales do not exist on this type, and adding one would be the wrong place for it: a per-IP threshold that locks accounts is a denial-of-service tool in an attacker's hands. Rate-limit by IP instead, at the middleware layer, where the response is a 429 rather than a locked account (Rate limiting covers KeyExtractor::LoginIdentifier, which is the per-account half of that defence).

on_counter_unavailable decides what happens when the counter store itself is down. That case is not hypothetical: record_failed_attempt is a write, and the read-replica split this library encourages puts reads on a replica and writes on the primary, so a primary outage leaves logins working and the counter dead. While that lasts the count never rises and max_attempts is never reached.

CounterUnavailable::Lock is the default and treats the attempt as locked, so brute force stays bounded while the counter is dead. A user who mistypes is told they are locked and retries after duration. CounterUnavailable::Allow keeps those users logging in and disables lockout until the counter returns, which is an unbounded brute-force window at exactly the moment monitoring is degraded. Choose it only with a compensating control, such as a KeyExtractor::LoginIdentifier rate limiter in front of the route.

One interaction to watch: Lock together with duration: None means a persistently broken counter store needs an administrator to clear each affected account. Deployments running indefinite lockouts should alert on AuthnMetrics::factor_counter_store_outage, which fires on exactly this path, or pick Allow knowingly.

Neither setting changes what an attacker sees for an identifier that does not exist. Those are refused at begin_login with timing equalization and never reach the counter.

The policy is resolved per tenant through IdentityLookup::lockout_policy_for_tenant, which defaults to lockout_policy(), which defaults to LockoutPolicy::default(). Override either where your tenants differ.

Tenant lifecycle

Suspending and deleting, and what each does to live sessions.

Tenant suspension

Tenant carries status: EntityState, the same type a user's status uses, so a suspended tenant is representable. Axess ships no operation to suspend one. IdentityStore has suspend_user and activate_user and no tenant equivalent, the session registry invalidates by user and by session and not by tenant, and there is no tenant lifecycle event in the audit vocabulary.

What exists today is per-user: suspend_user_in_tenant sets the status, invalidates that user's sessions through the registry, and emits AccountSuspended attributed to the actor who did it.

If you need "this tenant has not paid" or "this tenant is under compliance review" now, it is yours to build: set the tenant's status through your own IdentityStore implementation, and invalidate the sessions of its users yourself. Doing it inside axess would mean new required methods on both IdentityStore and the session registry, which every adopter would have to implement, so it is a deliberate decision rather than an oversight to leave it out.

Whatever drives the status, a tenant that is not Active refuses its users before factor verification. That is a state the application may want to render specifically ("your organisation is suspended, contact support") rather than as the generic invalid-credentials page, so check the tenant's status rather than inferring it from the login outcome.

Tenant deletion

The same gap as suspension, one step further along. EntityState has a closed state, so a deleted tenant is representable, and axess ships no operation to delete one and no cascade to run. IdentityAdmin has delete_user, which is the GDPR erasure primitive for a single user; there is no tenant equivalent.

What a customer exit or a tenant-wide erasure request needs, you build: enumerate the tenant's users, call delete_user for each, and remove the tenant row. Two details from that verb carry over. Its contract says what must be gone afterwards (the user row, the factor configs, the refresh tokens, the sessions, the password history), and it leaves audit events in place, to be retained under an independent lawful basis with identifying columns pseudonymised. A tenant-level erasure inherits both.

If you build it, two things a naive cascade will not do:

  • Mark the tenant Suspended first, then remove. The cascade is expensive on a large tenant, and the gap between the two is the only window in which an accidental deletion is recoverable without a backup restore.
  • Write your own audit row, naming the operator, the instant and the counts. Axess has no tenant lifecycle event to emit, and the deletion is exactly the thing you will later be asked to defend.

Configuration and conventions

There is no tenant trait, and the consolidation is deliberate.

Per-tenant configuration storage

There is no TenantStore trait. Tenant reads and writes live on the identity tiers alongside everything else: IdentityLookup::find_tenant and default_tenant read, IdentityAdmin::create_tenant writes, and the three *_for_tenant policy methods resolve per-tenant configuration from wherever you keep it.

That is a deliberate consolidation rather than a gap. A separate tenant trait would be a second surface every adopter has to implement, against the same database, with its own error type and its own transaction boundary. Provisioning a tenant already has to touch users and factors, so it could not stay inside that boundary anyway.

The practical consequence is that per-tenant policy has no prescribed schema. A deployment with one policy for everyone implements nothing and takes the defaults. A deployment that varies policy per tenant adds a column or a table of its own and returns it from lockout_policy_for_tenant and friends. Axess never reads that storage directly, which is why it cannot dictate its shape.

Reserved principals

A handful of principals are reserved across all tenants. The system() principal is the one axess uses for its own internal operations (retention sweeps, scheduled rotations, audit pipeline ingestion). The principal carries no TenantId; its actions are attributed to the system itself, not to any tenant or user.

The reservation prevents an application from creating a user named "system" and inadvertently granting that user the permissions axess reserves for its background work. UserId::is_system and TenantId::is_system are the predicates, and ensure_user_id_not_reserved(user_id, tenant_id) is the guard, returning IdError::Reserved for either.

Nothing calls that guard for you on the IdentityAdmin::create_user path, because the row is built in your code before the call. Put it at the top of your create_user implementation, which is what its documentation asks for. Tenant provisioning does check: create_tenant refuses a bootstrap whose tenant id is the reserved one.

The set of reserved principals is small and stable. The chapter Audit events lists them.

What a SaaS gets from this

Multi-tenancy in axess is what lets a SaaS application provision new organisations without restructuring the data model, suspend problematic ones without affecting the rest, and delete departed ones cleanly with an audit trail. The fingerprint pepper rotates per-tenant; the lockout policy varies per-tenant; the device retention complies per-tenant; the policies scope per-tenant. The multi-tenant deployment is the single-tenant deployment with N>1.

Further reading

Scope hierarchy covers the three-tier (System, Tenant, User) resolution mechanism that determines which configuration applies to which user. Device identity covers the per-tenant fingerprint pepper and the GDPR-shaped retention sweep. Identity store implementation covers the storage layer for the tenant record and the user records under it. Cedar policy fundamentals covers the cross-tenant forbid rule and the policy-scoping pattern.

Identity store implementation

Most of axess works against traits, and the identity store is the most consequential of them. The library does not prescribe a user schema, a tenant schema, or a factor schema; it prescribes a set of trait methods you implement against whatever schema it already has. The three-tier split is by privilege, so an adopter implements only the tier it needs, down to the read-replica-and-fixtures variant that the NoopAuthnLog adapter enables.

The traits

Split by privilege, so an adopter implements only the tier it needs.

The three tiers

The identity store is split into three trait tiers, in order of increasing privilege. An adopter that needs only read access implements the narrowest tier; an adopter that needs write access for audit purposes implements the middle tier; an adopter that needs full administrative control implements the widest tier.

// Tier 1: read-only.
pub trait IdentityLookup: Send + Sync + 'static {
    /// Your error type, not ours. Every method below returns it.
    type Error: std::error::Error + Send + Sync + 'static;

    fn find_user(
        &self,
        identifier: &str,
        tenant_id: &TenantId,
    ) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;

    fn get_user(
        &self,
        user_id: &UserId,
    ) -> impl Future<Output = Result<Option<User>, Self::Error>> + Send;

    fn find_tenant(
        &self,
        identifier: &str,
    ) -> impl Future<Output = Result<Option<Tenant>, Self::Error>> + Send;

    fn account_status(
        &self,
        user_id: &UserId,
    ) -> impl Future<Output = Result<EntityState, Self::Error>> + Send;

    // `get_user_in_tenant`, `default_tenant`, and the per-tenant policy
    // getters (`lockout_policy_for_tenant`, `password_rules_for_tenant`,
    // `ip_policy_for_tenant`) have defaults you can take as given.
}

// Tier 2: read + the writes the login flow makes as it runs.
pub trait IdentityAuthnLog: IdentityLookup {
    fn record_event(
        &self,
        event: AuthEvent,
    ) -> impl Future<Output = Result<AuditOutcome, Self::Error>> + Send;

    /// Returns the running count *after* this failure, which is what
    /// the lockout policy compares against its threshold.
    fn record_failed_attempt(
        &self,
        user_id: &UserId,
    ) -> impl Future<Output = Result<u32, Self::Error>> + Send;

    fn reset_failed_attempts(
        &self,
        user_id: &UserId,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    // `record_last_login` defaults to a no-op.
}

// Tier 3: read + audit + administrative writes.
pub trait IdentityAdmin: IdentityAuthnLog {
    fn create_tenant(
        &self,
        tenant: Tenant,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    fn create_user(
        &self,
        user: User,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    fn activate_user(
        &self,
        user_id: &UserId,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    // Plus the suspension verbs. `delete_user` is the one method here
    // that still defaults to `unimplemented!()`; nothing in axess calls
    // it, so the panic fires only if you call it yourself. See below.
}

// The umbrella for production: all three tiers.
pub trait IdentityStore: IdentityAdmin {}
impl<T: IdentityAdmin> IdentityStore for T {}

// Separate, and required rather than defaulted. Only the password-reset
// flow asks for it, and only a store that implements it can call that
// flow.
pub trait IdentityPasswordReset: IdentityLookup {
    fn store_reset_token(
        &self,
        user_id: &UserId,
        token_hash: &str,
        expires_at: DateTime<Utc>,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    fn verify_reset_token(
        &self,
        user_id: &UserId,
        token_hash: &str,
    ) -> impl Future<Output = Result<bool, Self::Error>> + Send;
}

Four things about that surface are worth stating plainly, because each one shapes an implementation.

The futures are native impl Future, not #[async_trait] boxes. Your methods are ordinary async fn bodies and cost no allocation per call.

The error is an associated Self::Error, declared once on IdentityLookup and inherited by the other two tiers. Axess never asks you to convert into an error type of its own; sqlx::Error is a perfectly good Self::Error.

There is no AttemptRecord, LockoutRecord or NewUser. The audit tier writes a single flat AuthEvent through record_event, and failure counting is a pair of numeric verbs rather than a record type. record_failed_attempt returns the count after the increment, which is the number the lockout policy compares against its threshold. Lockout state is derived from that count and the policy, not stored as its own row. create_user takes a fully-formed User, so identifier generation and defaulting happen in your code, before the call.

Four methods that once had unimplemented!() defaults on IdentityAdmin no longer do. They live on two capability traits with no default bodies, and the flows that need them are bounded on those traits, so a store that has not implemented them cannot reach the flow and the omission is a compile error rather than a panic.

IdentityPasswordReset holds store_reset_token and verify_reset_token. The reason is the shape of a mistake rather than a preference. begin_password_reset answers Ok(None) for an identifier it cannot find and reaches the store only for one it can, so a panicking default answered an unauthenticated "forgot password" request with a 200 for an address that is not registered and a 500 for one that is: a user-enumeration oracle, in the function that equalizes its own timing specifically to avoid leaking that. A silently-succeeding default would have been worse, since recovery would appear to work while no token was ever stored.

IdentityPasswordHistory holds record_password_hash and password_history, for a plainer reason. record_password_hash is called on every password change with no guard, so a backend that had not overridden it panicked the first time any user changed their password, on a method that looked optional because a defaulted trait method does.

A deployment with no password-reuse policy implements neither trait and loses nothing.

The hierarchy reads from narrowest to widest. An IdentityAuthnLog is an IdentityLookup plus the audit writes. An IdentityAdmin is an IdentityAuthnLog plus the administrative writes. The umbrella IdentityStore is the all-three-tiers shape that production backends implement.

Why three tiers

The split is the answer to two adopter situations the library has seen often enough to model explicitly.

The first situation is a read-replica deployment. A high-traffic application runs the login flow against a read-replica of the user database for latency reasons. The replica cannot accept writes, so you need the read verbs without the write verbs. The IdentityLookup tier covers this. The application implements IdentityLookup against the replica and IdentityAuthnLog (which needs writes) against the primary.

The second situation is a fixture deployment. A test or an embedded usage of axess does not have a real database; the application uses an in-memory backend for the read verbs and does not care about the audit writes. The NoopAuthnLog adapter wraps an IdentityLookup and provides no-op implementations of the IdentityAuthnLog write verbs. The fixture has the trait surface it needs without writing an audit-table mock.

The third situation, less common, is a deployment with a separation between the code that handles login and the administrative code that creates users. The application implements IdentityAuthnLog; the admin code separately implements IdentityAdmin. The split prevents the login path code from accidentally calling delete_user or suspend_user because it never has the trait method in scope.

What the verbs actually do

The verbs split cleanly across the tiers.

IdentityLookup is reads. get_user is a primary-key lookup by UserId. find_user is a credentials-side lookup by identifier and tenant: the user typed alice@example.com, and you needs to know if this is a real user in this tenant. Other read verbs cover the variants: looking up a user by email when email is separately indexed, looking up a user by a federated identity key when you support federated login, listing the users in a tenant for admin tooling.

IdentityAuthnLog is the writes the login flow makes as it runs. record_event takes one flat AuthEvent, carrying the event type, the status, the timestamp in epoch microseconds, and whatever attribution was resolvable: user, tenant, session, factor kind, the client address as an IpAddr, user agent, request id, and the failure reason as an AuthFailureReason rather than free text. It is called throughout the flow, not only after a factor check, and its argument is the same type the audit query surface returns.

Three of those fields deserve a nullable column and usually get a surprised implementer: the address, user agent and request id are None on every event unless the request path called AuthnService::with_audit_context. Axess cannot resolve a trustworthy client address on its own, since that needs your TCP peer and your trusted-proxy set, so it records nothing rather than something forgeable. Cookies, fingerprinting, hijack detection covers the wiring.

What you return decides whether the login succeeds. Err fails the authentication: an authentication that leaves no evidence has not, for evidence purposes, happened. Reserve it for a sink that is genuinely broken, and expect logins to fail while yours is. Ok(Shed) says you dropped the event deliberately under load, and the flow continues.

Shed on something identifier-independent, a global rate or a queue depth or a disk watermark. A shed decision derived from which identifier was tried is observable per identifier, and rebuilds the user-enumeration oracle that writing unattributable rejections exists to close. record_failed_attempt increments the user's failure counter and returns the new count, which is the number the LockoutPolicy compares against its threshold; reset_failed_attempts zeroes it on a successful login or an administrative clear. Lockout keeps no record of its own: the locked state is derived from the counter and the policy.

IdentityAdmin is the privileged writes. create_tenant and create_user are the provisioning verbs, and create_user takes an already-built User, so identifier generation and defaulting happen in your code, not behind the trait. activate_user moves a user out of the pending state. The rest of the tier is suspension and deletion. Password history and reset tokens used to live here; they are capability traits of their own now, for the reasons above.

Implementing them

Against SQL, and the two places the contract is easy to get wrong.

Implementing against SQL

The typical implementation against a SQL database is verbose but mechanical. The pattern is to implement each verb as one query (or one transaction), with the right indexes on the user table to keep the reads fast.

A reference implementation against PostgreSQL is in examples/sqlite/ (the SQLite version of the pattern). The shape:

struct OurBackend {
    pool: SqlitePool,
}

impl IdentityLookup for OurBackend {
    type Error = BackendError;

    async fn get_user(&self, user_id: &UserId) -> Result<Option<User>, Self::Error> {
        let row = sqlx::query(
            "SELECT id, tenant_id, identifier, display_name, status,
                    failed_attempts, locked_until, created_at, updated_at
             FROM users
             WHERE id = ?1",
        )
        .bind(user_id.to_string())
        .fetch_optional(self.pool())
        .await?;

        Ok(row.map(|r| user_from_row(self.clock(), &r)))
    }

    async fn find_user(
        &self,
        identifier: &str,
        tenant_id: &TenantId,
    ) -> Result<Option<User>, Self::Error> {
        let row = sqlx::query(
            "SELECT id, tenant_id, identifier, display_name, status,
                    failed_attempts, locked_until, created_at, updated_at
             FROM users
             WHERE tenant_id = ?1 AND identifier = ?2",
        )
        .bind(tenant_id.to_string())
        .bind(identifier)
        .fetch_optional(self.pool())
        .await?;

        Ok(row.map(|r| user_from_row(self.clock(), &r)))
    }
    // ... find_tenant, default_tenant, account_status
}

The patterns to note:

The tenant scope is on every query. find_user filters by both identifier and tenant id; the same identifier in a different tenant is not returned. The discipline is what enforces cross-tenant refusal at the storage layer.

The identifier comparison is whatever the deployment chose. The example treats the identifier as case-sensitive; deployments that want case-insensitive matching apply LOWER() to both sides (and index on LOWER(identifier)). The trait does not opinionate; the implementation decides.

The error type is the implementation's own, declared once as type Error = BackendError on IdentityLookup and inherited by the other two tiers. ? on a sqlx call works because BackendError implements From<sqlx::Error>; nothing converts into an axess type. Keep the kind of failure (connection, query, constraint violation) distinguishable in that enum, because your own callers will want to act on it even though axess only logs it.

Both read verbs return Option<User>. A missing user is Ok(None), not an error: the login flow treats "no such user" and "wrong password" identically, on purpose, so that a failed login does not reveal which identifiers exist.

Implementing the audit writes

IdentityAuthnLog is the layer that requires care. Its verbs fire on every login attempt, so a slow implementation is the bottleneck of the whole authentication flow.

There are three of them, and they divide neatly. record_event appends one flat AuthEvent row. record_failed_attempt and reset_failed_attempts maintain a single counter on the user.

impl axess::authn::IdentityAuthnLog for OurBackend {
    async fn record_event(&self, event: AuthEvent) -> Result<AuditOutcome, Self::Error> {
        let event_id = Uuid::new_v4().to_string();
        // Unresolved attribution (pre-auth failures, malformed OAuth claims)
        // persists as NULL so audit queries can distinguish "we don't know"
        // from a real principal. The schema's `user_id` / `tenant_id`
        // columns allow NULL for this reason.
        let user_id: Option<String> = event.user_id.as_ref().map(|u| u.to_string());
        let tenant_id: Option<String> = event.tenant_id.as_ref().map(|t| t.to_string());
        let session_id = event.session_id.map(|sid| sid.to_string());
        let event_type = event.event_type.to_string();
        let event_status = event.event_status.to_string();
        let event_time = DateTime::<Utc>::from_timestamp_micros(event.event_time)
            .expect("event_time micros in range")
            .to_rfc3339();
        let factor_kind = event.factor_kind.as_ref().map(|k| k.as_str().to_string());
        let ip_address = event.ip_address.map(|ip| ip.to_string());
        let ip_source = event.ip_source.as_str();
        let user_agent = event.user_agent.as_deref().map(|s| s.to_string());
        let request_id = event.request_id.as_deref().map(|s| s.to_string());
        let trace_id = event.trace_id.as_deref().map(|s| s.to_string());
        let geo_country = event.geo_country.as_deref().map(|s| s.to_string());
        let error = event.error.as_ref().map(|r| r.as_str().to_string());

        sqlx::query(
            "INSERT INTO auth_events
             (id, user_id, tenant_id, session_id, event_type, event_status, event_time,
              factor_kind, ip_address, ip_source, user_agent, request_id, trace_id,
              geo_country, error)
             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
        )
        .bind(&event_id)
        .bind(&user_id)
        .bind(&tenant_id)
        .bind(&session_id)
        .bind(&event_type)
        .bind(&event_status)
        .bind(&event_time)
        .bind(&factor_kind)
        .bind(&ip_address)
        .bind(ip_source)
        .bind(&user_agent)
        .bind(&request_id)
        .bind(&trace_id)
        .bind(&geo_country)
        .bind(&error)
        .execute(self.pool())
        .await?;

        debug!(
            event_type = %event_type,
            event_status = %event_status,
            user_id = user_id.as_deref().unwrap_or("<unattributed>"),
            "auth event recorded"
        );
        // This example writes every event. A sink that needs to protect
        // its storage under a flood of unauthenticated login attempts
        // returns `AuditOutcome::Shed` instead, on a criterion that does
        // not depend on the identifier.
        Ok(AuditOutcome::Recorded)
    }

    async fn record_failed_attempt(&self, user_id: &UserId) -> Result<u32, Self::Error> {
        sqlx::query("UPDATE users SET failed_attempts = failed_attempts + 1 WHERE id = ?1")
            .bind(user_id.to_string())
            .execute(self.pool())
            .await?;

        let row = sqlx::query("SELECT failed_attempts FROM users WHERE id = ?1")
            .bind(user_id.to_string())
            .fetch_one(self.pool())
            .await?;

        let count: i64 = row.get("failed_attempts");
        Ok(count as u32)
    }

    async fn reset_failed_attempts(&self, user_id: &UserId) -> Result<(), Self::Error> {
        sqlx::query("UPDATE users SET failed_attempts = 0 WHERE id = ?1")
            .bind(user_id.to_string())
            .execute(self.pool())
            .await?;
        Ok(())
    }
}

Two properties of that shape matter operationally.

auth_events is append-only and flat. Every event lands in that one table, and no join to reconstruct an attempt; every event carries its own event_type, event_status and nullable attribution. Index it on (user_id, tenant_id, event_time DESC) for the "what happened to this account" query, and on (tenant_id, event_time DESC) for the tenant-wide one.

The failure counter is a column on users, not a row count over auth_events. Lockout is derived from that counter and the LockoutPolicy, so there is no lockout table to upsert and no clear_lockout verb: reset_failed_attempts is the whole of it. The increment-then-read above is two statements; under contention, prefer whatever single-statement form your database offers (UPDATE ... RETURNING failed_attempts on PostgreSQL) so two concurrent failures cannot both read the same count.

The audit read is the hottest query in this layer. The index (user_id, tenant_id, event_time DESC) makes it cheap; without the index, the query degrades to a table scan and the login flow slows under load.

The append-only attempts table grows. The retention story for it is in Audit pipeline: typically a hot/cold split where recent attempts (the ones the lockout policy consults) stay in the attempts table and older attempts archive to a cold store.

The NoopAuthnLog adapter

NoopAuthnLog<L> wraps an IdentityLookup and provides no-op implementations of the IdentityAuthnLog write verbs. The wrapper exists for two cases.

The first is fixtures. A test uses MockIdentityStore (implementing IdentityLookup), and verify_factor needs IdentityAuthnLog. The test wraps the mock in NoopAuthnLog, satisfies the trait, and runs without recording anything.

The second is read-replica deployments where the audit writes go through a different code path (an out-of-band log shipper, a Kafka topic, an external SIEM). The application implements IdentityLookup against the replica, wraps in NoopAuthnLog, and routes the audit writes through the side channel.

The trade-off is that the lockout policy will not function correctly under NoopAuthnLog. The policy decides from the count record_failed_attempt returns, and the noop discards the increment, so the count never rises and the threshold is never reached. Deployments that use NoopAuthnLog for the read-replica case must accept a degraded lockout policy unless they implement an alternative.

The chapter warns about this in the docstring of NoopAuthnLog; the warning is worth repeating: do not use NoopAuthnLog in production without an alternative lockout source.

Fitting an existing system

Where axess bends to a schema you already run.

What about workload identities

Workloads have their own identity surface, not the same one humans use. The IdentityStore traits do not cover workloads; the workload identity resolvers (Workload identity overview) have their own machinery.

The split is deliberate. Humans live in a user table; workloads live in a workload table (or do not live anywhere durable, when they are short-lived service-to-service callers). The audit events for workloads route differently from human events. The lockout policy does not apply to workloads at all. Trying to unify the two would produce a trait that does too many jobs.

The same is true for the principal model: the Principal enum has two variants, the read paths for the two variants go through two different stores. The application implements both stores and the resolver code routes appropriately.

Schema migration

The identity store is the part of your system most likely to need migrations over time: a new factor adds a column to the factor configurations table, a regulatory change requires a new field on the audit-attempts table, a refactor renames a column.

The migration mechanism is yours, not axess's. sqlx::migrate! is the standard pattern; alternative migration tools (Diesel migrations, Atlas, custom SQL) work the same way. Axess does not need to know about the migrations; the implementation just needs to keep satisfying the trait against the new schema.

The pattern in examples/sqlite/ is the reference. The migrations/ directory carries the SQL files; the main.rs runs them at startup; the implementation queries against the latest schema.

Fitting into a schema you already have

The trait split is what lets axess fit into existing applications without forcing a schema rewrite. The library knows nothing about the user table; it knows only that there is a trait it can call to look up users. The application's data model is the source of truth, and the trait surface is the bridge.

The three tiers and the noop adapter give you enough flexibility to fit the awkward shapes (read replicas, fixtures, split admin) without forcing every adopter to implement the full set of verbs.

Further reading

Multi-tenancy covers the per-tenant configuration that the identity store reads and writes. Audit events covers the AuthEvent variants the audit-log verbs emit. Audit pipeline covers the hot/cold retention story for the attempts table. Migration guide covers the cross-version migrations that affect the user table.

Workload identity overview

A workload in axess is a non-human caller: a service in your service mesh, a Kubernetes pod, a CI/CD runner, a batch job, a serverless function. Workloads need to authenticate against your application the same way users do, but the credentials, the lifetimes, and the operational characteristics are different. This part of the book covers how axess models workload identity, how it resolves credentials into a typed Principal::Workload, and how the Cedar policy layer authorises workloads through the same rules it uses for human users.

The unifying claim is the one The principal model in Part II already made: humans and workloads are the same type. A Cedar policy that says resource.tenant_id == principal.tenant_id works for a logged-in user and for a SPIFFE-identified payment service without branching. The chapters in this part cover the specific resolvers that turn each credential kind into a Principal::Workload.

The cookbook chapters are siblings of this overview. Read them in the order that matches your deployment: SPIFFE-based deployments read Inbound: JWT-SVID and Inbound: mTLS-SVID; cloud-platform deployments read Inbound: federation and Cloud STS exchange; applications that call downstream services on a workload's behalf read Outbound: OAuth and Outbound: mTLS.

The model

One resolver trait, and why humans and workloads share a type.

The resolver model

Every inbound request that carries a workload credential runs through a PrincipalResolver. The resolver inspects the credential (a bearer JWT in a header, a client certificate from the TLS handshake, a projected service-account token), validates it, and returns a Principal::Workload if the validation succeeds. The same trait is implemented for every credential kind axess supports, and applications wire only the resolvers their deployment needs.

        ┌──────────────────────┐
        │  Inbound request     │
        └──────────┬───────────┘
                   │
                   ▼
        ┌──────────────────────┐
        │ PrincipalResolver    │
        │ (per-feature impls)  │
        └──────────┬───────────┘
                   │
      ┌────────────┴───────────┐
      │                        │
      ▼                        ▼
Principal::Human         Principal::Workload
(session + factors)      (with Issuer + WorkloadId)
                                │
      ┌─────────────────────────┘
      │
      ▼
ToCedarEntity bridge
      │
      ▼
Cedar evaluation

Two SPIFFE resolvers ship, plus a generic third for everything else:

  • JwtSvidResolver, for SPIFFE JWT-SVIDs. Spec-bound, with a mandatory spiffe:// URI in sub.
  • MtlsResolver, for SPIFFE X.509-SVIDs over mTLS.
  • WorkloadResolver, the generic JWT-bearer resolver. It covers every non-SPIFFE workload-identity flow: Kubernetes projected service-account tokens, GitHub Actions and GitLab CI OIDC, Okta, Azure AD, Auth0, axess's own LocalIdP, custom internal JWT formats.

For the generic one the adopter supplies a small claim parser and mapping closure per issuer they care about; examples/workload-identity/ has ready-made recipes for GitHub Actions and Kubernetes service accounts. The human side has its own SessionResolver covered in Part II. A MockResolver is available for DST tests. Each resolver implements the same trait and produces the same Principal shape.

Why one type covers both

A traditional auth library treats human and workload identity as two independent stacks. The session layer handles users; a separate JWT-validation middleware handles services. Neither composes with the other. A policy that has to apply to both, say "only callers in the finance tenant may read this resource", ends up written twice: once for users, in code that knows about sessions, and once for workloads, in code that knows about tokens. The two drift apart as the application evolves.

Unifying on Principal removes the duplication. The Cedar policy quoted above works for a human and a workload because the policy matches on a tenant id, which both variants carry. If the policy later needs to discriminate between the two (a rule that demands human-completed MFA for an action, but admits any workload), the discrimination is expressed in one rule:

permit (
    principal,
    action == Action::"transfer-funds",
    resource
) when {
    resource.tenant_id == principal.tenant_id
    && (
        principal has Workload  // workloads bypass MFA requirement
        || (
            principal has Human
            && "Fido2" in principal.factors_completed
        )
    )
};

The discrimination is local, readable, and lives in the policy file rather than scattered across handlers.

The credential kinds

SPIFFE's vocabulary first, because everything after it assumes the terms.

SPIFFE and SVIDs

SPIFFE is the industry-standard model for workload identity, and the chapters that follow assume the vocabulary. The two terms worth knowing up front:

A SPIFFE ID is a URI of the form spiffe://<trust_domain>/<path>. The trust domain is the federation namespace (prod.example.com, say); the path identifies a specific workload within that domain (/svc/billing/tenant-acme). The combination uniquely names the workload across the federation.

An SVID (SPIFFE Verifiable Identity Document) is the credential that carries the SPIFFE ID. SVIDs come in two formats: JWT-SVID (a JWT signed by the trust domain's issuing authority) and X.509-SVID (a leaf certificate with the SPIFFE ID in a Subject Alternative Name URI). Both are covered in their own cookbook chapters.

SPIRE is the reference SPIFFE implementation. It handles workload attestation (verifying that a process running on a host is the workload it claims to be), SVID issuance, key rotation, and trust-domain federation. Axess does not replace SPIRE; SPIRE issues, axess validates. The two are designed to compose.

A SPIRE Workload API client is on the ROADMAP under feature spire: it would talk to a local SPIRE agent socket directly, fetching fresh SVIDs on demand and maintaining their rotation, rather than relying on adopters to mount them into the filesystem. It lands when an adopter needs an axess-shaped wrapper around it. Until then, adopters mount short-lived SVIDs into pod filesystems and configure axess against them, or drive the upstream spire-workload / spire-api crates directly; Inbound: JWT-SVID has the fetch-side recipe.

Federation

A trust domain is a unit of issuance. A workload in trust domain A is identified by an A-issued SVID, validated against A's signing keys. When a workload in domain A needs to call a service in domain B, federation is the mechanism that lets B accept A's identity.

Three federation patterns appear in axess.

Same-domain is the simple case. The resolver validates the SVID against the local trust-domain bundle (the JWKS for JWT-SVIDs, the CA bundle for X.509-SVIDs). The SVID carries the local trust domain; the resolver knows where to fetch the keys.

Federated is the cross-domain case, and axess ships no policy type for it. A resolver is constructed against one expected trust domain and refuses anything else, so accepting a second domain means deciding, in your own code, which domains you admit, which path prefixes within each, and how a foreign identity maps into your namespace. That decision is deployment configuration; axess validates, the deployment decides the rules.

External-issuer is the non-SPIFFE case. The credential is not a SVID at all (a Kubernetes service-account token, a GitHub Actions OIDC token, an Azure AD workload token). All of these go through the single generic WorkloadResolver: the adopter supplies a claim parser + mapping closure that synthesises a SPIFFE-shape WorkloadId from whichever claims the issuer's JWT carries. The synthesis is what lets the rest of the system work uniformly: by the time the policy evaluator sees it, the external workload looks like any other workload, and the audit rows you write about it carry the same shape of identity. Axess writes none of those rows itself on these paths; Inbound: federation says what that leaves you.

Cloud STS exchange

A workload that needs to call AWS, GCP, or Azure APIs can exchange its workload identity for short-lived cloud credentials. The mechanism is implemented by all three cloud providers under similar names (AWS STS AssumeRoleWithWebIdentity, GCP Workload Identity Federation, Azure Federated Identity Credentials), and axess provides adapters that bridge a validated workload identity to each of them.

The chapter Cloud STS exchange covers the configuration and the credential lifecycle. The benefit is that no long-lived cloud keys ever live on the workload's filesystem; the credentials are minted on demand from the workload identity, used briefly, and discarded.

Outbound

Axess is not only an inbound authenticator. When a service authenticates to a downstream service, it uses the same identity shape it would accept inbound. The chapters Outbound: OAuth and Outbound: mTLS cover the two ways this works: the workload presents an mTLS client certificate to the downstream's TLS server, or the workload exchanges its identity for a bearer token through an OAuth flow.

The pattern matters because it lets one identity (the workload's SVID, or its federated equivalent) carry through an entire chain of service calls, so an audit trail written at each hop names the same identity throughout, and revocation at the issuing authority propagates to every call that was about to use it.

Feature flags

The resolvers are individually feature-gated so a deployment only pays the compile cost for the credential kinds it actually uses.

FeatureResolverPurpose
jwt-svidJwtSvidResolverInbound SPIFFE JWT-SVID (spec-bound)
mtlsMtlsResolverInbound SPIFFE X.509-SVID via mTLS
jwt (pulled by jwt-svid etc.; name jwt-aws-lc or jwt-rust-crypto beside it; the module is axess::jwt since 0.7.0, not axess::federation::jwt)WorkloadResolverGeneric JWT-bearer workload identity for every non-SPIFFE issuer (GitHub Actions, k8s SA, GitLab CI, Okta, Azure AD, Auth0, LocalIdP, …) via adopter-supplied claim parser + mapping closure. No per-company features; see examples/workload-identity/
outbound-mtls(client side)Outbound mTLS with workload SVID
outbound-oauth(client side)Outbound OAuth client
aws-sts, gcp-wif, azure-fic(cloud STS)Exchange workload identity for cloud credentials
workload-idumbrellaSPIFFE adapters + outbound + mTLS bundle

What this part does not cover

Three concerns are intentionally outside scope.

The SPIRE Agent and Server implementations are not part of axess. Axess validates SVIDs; SPIRE issues them. The two are designed to be independent so deployments can use any SPIFFE-compliant issuer (SPIRE, an in-house implementation, a managed service like AWS IAM Roles Anywhere) without changing the axess side.

Trust domain bootstrap and root-of-trust ceremonies are out of scope. Operators manage the trust-domain bundle through SPIRE's federation API or an equivalent mechanism. Axess consumes the bundle; it does not establish it.

Service mesh integration is out of scope. Istio, Linkerd, and Consul handle mesh-level identity at the proxy layer. Axess works at the application layer above the mesh. When the mesh terminates mTLS and forwards a verified identity in a header, a custom PrincipalResolver can pick it up and produce a Principal::Workload the rest of the system understands.

Further reading

The cookbook chapters in this part each cover one resolver in detail. Start with the one matching the credential kind your deployment uses; the others are useful background for the federation and outbound scenarios. Cedar policy fundamentals covers how the policy engine handles workload principals. The principal model in Part II covers the unified Principal type that all of this resolves to.

Inbound: SPIFFE JWT-SVID

A JWT-SVID is a JWT carrying a SPIFFE identity. It is the right credential for service-to-service authentication where mTLS is impractical (the network path crosses a load balancer that does not preserve client certificates, the calling service speaks a protocol that does not support TLS client auth, the deployment favours the simplicity of bearer tokens). The JwtSvidResolver is the axess resolver that validates these tokens and produces a Principal::Workload.

The feature flag is jwt-svid (off by default).

The credential shape

A SPIFFE JWT-SVID is an ordinary JWT with two specific claim requirements. The subject (sub) claim is the SPIFFE ID, formatted as spiffe://<trust_domain>/<path>. The audience (aud) claim names the intended recipient: when your application validates the token, the audience must match a configured value.

{
  "iss": "https://spire.prod.example.com",
  "sub": "spiffe://prod.example.com/svc/billing",
  "aud": ["https://api.example.com"],
  "exp": 1735689600,
  "iat": 1735686000,
  "jti": "f47ac10b-58cc-4372-a567-0e02b2c3d479"
}

The signature is over the standard JWT body plus header, using keys published by the trust domain's issuing authority through a JWKS endpoint. The signing algorithm is RS256 or ES256 in production deployments; SPIFFE does not standardise the algorithm, but the keys advertised in the JWKS specify it.

Wiring it up

The resolver takes a verifier, a trust domain and a token, and nothing else.

Configuration

The resolver takes a verifier, the trust domain it accepts, and the token, and nothing else:

use std::sync::{Arc, RwLock};

use axess_factors::jwt::{svid::JwtSvidResolver, verifier::JwtVerifier};

let verifier = Arc::new(
    JwtVerifier::new(Arc::new(RwLock::new(jwks)))
        .with_issuer("https://spire.prod.example.com")
        .with_audience("https://api.example.com")
        .with_clock_skew(Duration::from_secs(30)),
);
let resolver = JwtSvidResolver::new(
    verifier,
    "prod.example.com".parse()?,
    token,
);
let principal = resolver.resolve().await?;

Everything about how a token is validated (issuer, audience, clock skew, which algorithms, whether a jti may be replayed) belongs to the JwtVerifier you hand it, and is configured there. The resolver adds the SPIFFE rules on top: the trust domain, and the shape of the identity.

Where the key set comes from is likewise yours: a JwkSet read from a file the deployment mirrors, or JwksCache (feature oidc) if you want axess to fetch it. The resolver never fetches.

trust_domain is the trust domain the resolver accepts SVIDs from. A token whose sub SPIFFE ID names a different trust domain is rejected. The defence is the trust-domain isolation that SPIFFE is built around.

expected_audiences and the clock (with_audience and with_clock_skew on the verifier) are configured there rather than here; see above. A token whose aud does not match is rejected by the verifier before the resolver sees it.

There is no max_token_age. An iat upper bound is not implemented: a token is accepted until its exp, and bounding issuance age is the issuer's to do through a short lifetime. If you need it, check VerifiedClaims::iat yourself after verify.

Wiring the resolver

There is no JwtSvidLayer. axess ships no Tower middleware for SVIDs; the resolver is a plain call you make where you like, which is what lets a daemon with no Axum router use it at all:

use axess_identity::PrincipalResolver;

// `bearer_from` and its error are yours; axess has no opinion on how a
// request without a credential is refused.
let token = bearer_from(&headers).ok_or(MyError::NoCredential)?;
let resolver = JwtSvidResolver::new(verifier.clone(), trust_domain.clone(), token);
let principal = resolver.resolve().await?;   // Principal::Workload

In an Axum application, call it in a middleware of your own and insert the Principal into the request extensions; the bearer feature's BearerTokenLayer is the shipped example of that shape, for plain bearers rather than SVIDs.

What it checks, and what you get

Every rejection returns the same error, deliberately.

Validation details

The validation runs through six checks in order. The order matters because cheaper checks come first: a malformed token fails parsing without ever fetching JWKS keys; an expired token is rejected without engaging the signature check.

The first check is parsing. The token must be a well-formed JWT with header, payload, and signature segments. Malformed input is rejected without further work.

One thing to know before reading the rest: resolve reports every one of these failures as IdentityError, because that is what PrincipalResolver returns. A failed SPIFFE-ID decomposition or a trust-domain mismatch surfaces as IdentityError::InvalidSpiffeId with a message naming the problem; everything else collapses to IdentityError::NotAuthenticated, with the underlying JWT error logged at debug for operators. JwtSvidError does not exist. The collapse is deliberate: a caller presenting a bad token learns only that it was refused.

The second check is the header. The alg field must be one of the configured allowed algorithms (RS256 or ES256 by default; deployments that need others configure them explicitly). The kid field must be present so the resolver can look up the right key.

The third check is the claims. The sub claim must be a valid SPIFFE URI under the configured trust domain. The aud claim must contain at least one of the configured expected audiences. The exp and iat claims must be present and within the clock skew and max age bounds. Which claim failed appears in the debug log, not in the returned error.

The fourth check is the signature. The resolver looks up the key matching the token's kid in the cached JWKS, verifies the signature, and falls through on success. A signature failure triggers a JWKS cache refresh (subject to the debouncing) and a retry against the fresh keys; a failure after refresh is final.

The fifth check is the nbf (not-before) claim when present. SPIRE typically issues tokens with nbf slightly in the future to allow for clock skew on the receiver side. The check uses the same clock-skew tolerance.

The sixth check is the duplicate-jti check, when configured. SPIFFE recommends a jti on each token so a receiver can detect replay; a deployment that wants it implements JtiReplayStore and hands it to the verifier with with_replay_store: a HashSet behind a mutex for one process, a small Valkey cache for a fleet. axess ships the trait and NoReplay, not a backend. With a store configured, a token carrying no jti is rejected rather than admitted unchecked, and entries expire with the token's own exp.

What the principal looks like

A successful validation produces a Principal::Workload:

Principal::Workload(WorkloadPrincipal {
    workload_id: WorkloadId::new("spiffe://prod.example.com/svc/billing"),
    trust_domain: TrustDomain::new("prod.example.com"),
    issuer: Issuer::JwtSvid {
        jwks_url: "https://spire.prod.example.com/keys".parse().unwrap(),
    },
    tenant_id: derive_tenant_from_path(...),
    tenant_slug: derive_slug_from_path(...),
    service_name: derive_service_from_path(...),
    attributes: {
        "exp": 1735689600,
        "iat": 1735686000,
        "jti": "f47ac10b-...",
    },
})

The workload_id is the parsed SPIFFE URI. The trust_domain mirrors the configured trust domain. The issuer records that the principal came through the JWT-SVID path with the specific JWKS URL. The tenant and service derivation depends on the deployment's SPIFFE path convention (the example above expects paths like /svc/<service>/<tenant>); the resolver's path-parsing logic is configurable, and examples/local_idp/ demonstrates the pattern.

The attributes map carries the rest of the token's claims, so Cedar policies can match on them if needed (a policy that demands a specific issuer signature, for instance, reads principal.attributes.iss).

Threat model

The JWT-SVID flow is robust against the standard attacks when the validation is complete.

Against token forgery: the signature check defeats it. An attacker without the issuing authority's signing key cannot mint a valid SVID.

Against token theft: the audience check defeats most of it. A token stolen from one service cannot be used against another service whose audience does not match.

Against token replay: the token's own lifetime is the window, so a short exp at the issuer is the control, since no issuance-age bound is implemented here. With a JtiReplayStore configured, replay is detected explicitly rather than merely bounded.

Against trust-domain confusion: the trust-domain match defeats cross-domain attacks. A token from a different trust domain is rejected without further consideration.

The remaining attack surface is the issuing authority itself. A compromised SPIRE control plane can mint compromised SVIDs, and no client-side check catches that. The defence is operational: secure the SPIRE control plane, monitor its audit log, rotate keys on a schedule.

Troubleshooting

Every rejection looks the same to the caller. resolve returns IdentityError::NotAuthenticated whichever check failed: a bad signature, an unknown key, a wrong audience, an expired token, a replayed jti, a missing or malformed sub. That is deliberate, and it mirrors the user-enumeration discipline the Authn surface follows: a caller learns that it was refused, not what to change to get past. Do not branch on the variant, and do not expect one that names the cause, because there isn't one.

The cause goes to the log instead, at debug on the axess_factors::jwt target. Turn that on and the rejected verification prints the underlying JwtError:

RUST_LOG=axess_factors::jwt=debug

A key the JWKS does not advertise is the common one during SPIRE rotation, where the cache debounce can hide a fresh key briefly; force a refresh or wait out the TTL. An audience rejection means the issuer mints a different aud than expected_audiences lists. The token payload is base64 and readable, so decode it and compare rather than guessing.

If the log shows the SPIFFE ID parsing or the trust domain, a workload from a different domain is calling your service. If this is intentional, configure federation (the next chapter, Inbound: federation, covers the mechanism). If it is not intentional, the workload is misconfigured.

Fetching SVIDs from a local SPIRE agent

JwtSvidResolver is the verifying side; it consumes an SVID presented in an HTTP request and validates it against the trust domain's JWKS. The issuing side; fetching fresh SVIDs from a local SPIRE agent socket for outbound calls; is a separate concern.

For deployments that need to fetch SVIDs at runtime, two adopter-direct options exist on crates.io today:

  • spire-workload; higher-level wrapper around the SPIRE Workload API gRPC, including JWT-SVID fetch with auto-rotation. Most adopters reach for this first.
  • spire-api; lower-level generated gRPC client when finer control is needed.

axess does not currently wrap either crate; the SPIRE Workload API client on the ROADMAP (feature spire) lands when an adopter needs an axess-shaped surface (e.g. integration with axess-clock for rotation timing, axess-rng for ceremony nonces, or the Principal::Workload shape on the fetch result for symmetry with the verifier). Until then, the recommended path is:

  1. Use spire-workload directly in your application to fetch JWT-SVIDs against a configured audience.
  2. Present the fetched SVID on outbound calls via your HTTP client.
  3. On the receiving service, validate the SVID with JwtSvidResolver as documented above. The presenting and verifying sides interoperate without axess wrapping the fetch side.

If your deployment forces the issue (e.g. fetch-side rotation needs to drive axess-clock-pinned tests), open a tracking issue; that's exactly the adopter-demand signal the ROADMAP entry waits for.

Further reading

Workload identity overview covers the SPIFFE model and the unified Principal type this resolver produces. Inbound: mTLS-SVID covers the X.509 variant for deployments where mTLS is practical. Inbound: federation covers the cross-trust-domain patterns. Cedar policy fundamentals covers how policies match on the workload's claims through principal.attributes.

Inbound: SPIFFE X.509-SVID via mTLS

A workload authenticates over mTLS by presenting a leaf X.509 certificate that carries its SPIFFE identity in a Subject Alternative Name URI. The TLS handshake validates the certificate against the trust-domain CA bundle, the application reads the SPIFFE URI from the SAN, and the resulting identity becomes a Principal::Workload. The mechanism is the right choice for service-to-service traffic where mTLS is already in place (a service mesh, a load balancer that preserves client certs, a direct VPC peering).

The feature flag is mtls (off by default).

The credential

An ordinary X.509 certificate, with the identity in one SAN entry.

The credential shape

An X.509-SVID is an ordinary X.509 leaf certificate with one specific requirement: the Subject Alternative Name extension contains a URI of the form spiffe://<trust_domain>/<path>. The certificate is otherwise standard; deployments may put additional information in the subject DN, the other SAN entries, or X.509 extensions, but the SPIFFE URI is the identity the resolver reads.

The certificate chain is signed by the trust domain's CA. The chain validates the certificate's authenticity; the SAN URI identifies the workload within the trust domain.

Where the certificate comes from

Axess does not handle the TLS handshake. The handshake happens where TLS terminates (rustls in the application process, a sidecar proxy in a service mesh, a load balancer in front of the application). The terminator validates the certificate chain against the configured CA bundle, accepts or rejects the connection, and on acceptance makes the certificate available to the application.

The mechanism for making the certificate available depends on the terminator. For rustls in process, the certificate is available through axum_server::tls_rustls::RustlsConnectInfo or an equivalent connector callback, which the resolver wires through directly. For a sidecar proxy (Istio, Linkerd, Envoy in a service mesh), the proxy forwards the certificate as a header (Istio uses X-Forwarded-Client-Cert, Linkerd uses l5d-client-id), and the resolver wires through a small adapter that parses the header into a certificate. For a load balancer in passthrough TLS mode, rustls handles the validation in-process; for a load balancer in mTLS-terminating mode (AWS ALB with mTLS, Cloudflare with client-cert auth, nginx with ssl_verify_client), the load balancer forwards the certificate in a header whose name and format depend on the product.

The application's job is to extract the certificate chain from wherever the terminator put it, wrap it in PeerCertChain, and insert it into the request extensions before the resolver runs.

use axess::federation::mtls::PeerCertChain;

async fn mtls_middleware<B>(
    mut req: Request<B>,
    next: Next<B>,
) -> Response {
    // `chain` is a `Vec<CertificateDer<'static>>`, leaf first.
    if let Some(chain) = extract_cert_from_terminator(&req) {
        req.extensions_mut().insert(PeerCertChain::new(chain));
    }
    next.run(req).await
}

The critical detail: the extraction must trust only sources the deployment trusts. A request that arrives directly to the application with a forged X-Forwarded-Client-Cert header must not be accepted. Either run the application on a socket the terminator owns and reject direct connections at the network layer, or gate the header on a token the terminator injects alongside the certificate.

Wiring it up

Reading the tenant out of the leaf comes first, and that read validates nothing.

The resolver

MtlsResolver reads the SPIFFE URI out of the leaf certificate, checks it against the configured trust domain, and produces a Principal::Workload.

use axess::federation::mtls::{MtlsResolver, PeerCertChain, peek_spiffe};
use axess_identity::PrincipalResolver;

// Which tenant this is, from the SPIFFE ID, before the resolver is
// built: `peek_spiffe` is a plain function over the leaf certificate
// and does no validation beyond parsing the SAN URI.
let leaf = chain.leaf().ok_or(MtlsError::EmptyChain)?;
let components = peek_spiffe(leaf)?;
let tenant_id = my_directory.tenant_for(&components.tenant_slug)?;

let resolver = MtlsResolver::from_chain(
    &chain,
    "prod.example.com".parse()?,
    tenant_id,
)?;
let principal = resolver.resolve().await?;   // Principal::Workload

from_chain takes the leaf and returns MtlsError::EmptyChain if there is none. Where you already hold a leaf, from a terminator that hands you one certificate rather than a chain, MtlsResolver::new takes it directly and is infallible.

Either way the resolver holds the leaf, the trust domain it accepts, and the tenant. There is no configuration struct and no tenant-resolver trait: mapping a SPIFFE path to a TenantId is the adopter's, done before construction with peek_spiffe, because the convention is the deployment's.

The work is small because most of the validation already happened: the terminator validated the chain, and the resolver parses the SAN URI and checks the trust domain.

The validation flow

Two error types are in play, and which one you see depends on where you are standing.

peek_spiffe is the parsing step, and it reports MtlsError. It parses the leaf's DER, where a failure is MtlsError::CertParse, then reads the Subject Alternative Name extension. A certificate with no SAN yields MtlsError::NoSan; one whose SAN carries no spiffe:// URI yields MtlsError::NoSpiffeUri. The URI is then parsed as a SPIFFE ID and decomposed into spiffe://<trust_domain>/<service>/<tenant_slug>; a URI that is malformed, or whose path does not match that shape, yields MtlsError::Identity, which carries the underlying IdentityError. MtlsError::EmptyChain comes from MtlsResolver::from_chain rather than from parsing, and means the chain held no leaf at all.

resolve is the PrincipalResolver step, and it reports IdentityError, because that is what the trait returns for every resolver. It re-runs peek_spiffe on the leaf, cheaply and deliberately, so the cryptographic claim flows through one path rather than through whatever the middleware peeked at earlier, then compares the presented trust domain against the configured one. A mismatch is IdentityError::InvalidSpiffeId, naming both domains, and is logged at warn. Every other parse failure collapses to IdentityError::NotAuthenticated, with the specific MtlsError logged at debug.

That collapse is deliberate: a caller presenting a bad certificate learns only that it was rejected, while the operator reading the logs learns which of CertParse, NoSan or NoSpiffeUri it was. When you want the distinction in your own code, call peek_spiffe yourself, which the tenant lookup means you are doing anyway.

The resolver does not resolve tenants. The tenant is decided before it is built, and resolve copies the TenantId it was given into the principal.

What the principal looks like

A successful validation produces:

Principal::Workload(WorkloadPrincipal {
    workload_id: WorkloadId::new("spiffe://prod.example.com/svc/billing/tenant-acme"),
    trust_domain: TrustDomain::new("prod.example.com"),
    issuer: Issuer::Mtls,
    tenant_id: TenantId::parse("acme").unwrap(),
    tenant_slug: "acme".into(),
    service_name: "billing".into(),
    attributes: { /* X.509 fields the deployment exposes */ },
})

attributes is always empty here. MtlsResolver puts nothing in it, and the certificate is not carried on the principal, so X.509 detail you want downstream (the serial number for audit, the expiry for short-lived-cert tracking, a custom extension) has to be read from the leaf in your own middleware and carried in your own request extension. The field exists on WorkloadPrincipal for resolvers that do populate it from claims.

Combining with other resolvers

A common shape is mTLS as the transport-level proof of identity plus a session cookie or a JWT as the application-level proof of who the user behind the workload is. The two layers compose: the mTLS resolver runs first and establishes the workload's identity; the session or JWT layer runs second and establishes the human's identity inside the workload. Cedar policies can match on both.

The composition is what gives a deployment "the calling service is authenticated AND the user inside the call is authenticated", which is the right shape for delegated workflows. Delegated and OBO access covers the pattern from the OBO side.

Threat model

mTLS is robust against the standard attacks when the issuing CA is secure.

Against token theft: there is no token. The credential is a private key the workload holds; an attacker without the key cannot present the certificate.

Against in-flight tampering: the TLS layer protects against it. The certificate is bound to the TLS session; an attacker on the wire cannot substitute a different certificate without breaking the handshake.

Against replay: the certificate is short-lived (SPIRE typically rotates SVIDs every few hours) and bound to a TLS session. Replay across sessions requires the private key, which the attacker does not have.

The remaining attack surface is the issuing CA. A compromised CA can issue compromised certificates, and the validation cannot detect it. The defence is operational: secure the issuing CA, monitor the issuance log, rotate the CA's signing key on a schedule.

The other remaining surface is the workload's private-key storage. A workload that stores its key in a file on disk is vulnerable to file-system compromise; a workload that stores its key in a hardware enclave (TPM, HSM, KMS) is much harder to compromise. SPIRE supports both shapes through its workload-API attestation; the choice is the deployment's.

Troubleshooting

If the chain is empty (EmptyChain, or PeerCertChain::leaf returning None) for connections that should work, the terminator is not requesting a client certificate, or your middleware is not recording the one it received. Inspect what the terminator reports before the resolver runs.

If resolve returns IdentityError::NotAuthenticated, the debug log carries the real reason. NoSan or NoSpiffeUri means the certificate does not carry a SPIFFE URI in its Subject Alternative Name. Inspect it with openssl x509 -in cert.pem -text to see what SAN entries are present, and update the issuer's configuration to include the SPIFFE URI. CertParse means the bytes are not a certificate at all, which usually means the middleware picked up the wrong header or forwarded a PEM where DER was expected.

If resolve returns IdentityError::InvalidSpiffeId, read the message. "trust domain mismatch" means a workload from another trust domain connected; if that is intentional, see Inbound: federation. Anything else means the SPIFFE path does not have the /<service>/<tenant_slug> shape axess decomposes, and the issuer's path convention needs to change, because axess does not make the shape configurable.

If peek_spiffe succeeds but your own tenant lookup then fails, the tenant_slug in the path is not one your directory knows. That is your mapping to fix, not axess's; the resolver never sees it.

Further reading

Workload identity overview covers the SPIFFE model and the unified Principal type. Inbound: JWT-SVID covers the bearer token variant for deployments where mTLS is impractical. Inbound: federation covers cross-trust-domain patterns. mTLS-based authentication in Part III covers mTLS for human authentication; the validation mechanics are the same, but the interpretation of the certificate differs.

Inbound: federation

Federation is the pattern where workloads authenticate against your application using credentials issued by a third party your deployment trusts. The federating issuer typically lives outside the trust domain your own services use: Kubernetes issues service-account tokens for pods, GitHub issues OIDC tokens for Actions runs, an enterprise IdP issues tokens for cross-organisation service calls. None of these are SPIFFE issuers, but axess provides a generic resolver that bridges any JWT-bearer issuer into the unified workload-principal shape.

This chapter covers WorkloadResolver, the single resolver that handles every non-SPIFFE federation. It is gated on the jwt feature, which jwt-svid and the rest of the workload-identity bundle pull in. Before 0.7.0 the facade put this module under federation and gated it on oauth, so features = ["jwt-svid"] compiled the resolver and left no path to it.

What federation means here

The unifying claim of federation in axess is that an external issuer's token, after validation, produces a Principal::Workload with the same shape as a SPIFFE workload. The trust domain and the SPIFFE-style path are synthesised from the issuer's claims; the issuer field on the principal records which federation produced it (Issuer::OAuth for the generic case, or one of Issuer::custom("github_actions") / Issuer::custom("kubernetes") / Issuer::custom("gitlab_ci") when audit logs need finer granularity).

The synthesis matters because the rest of the system stays uniform. A Cedar policy that says "any workload in the finance tenant may read this resource" works for a SPIFFE-identified service and for a Kubernetes pod and for a GitHub Actions run, without branching. The audit pipeline logs the same principal shape for all three. The application's code does not need to know which federation produced the request.

One resolver, many issuers

axess deliberately ships no per-issuer adapters. Each IdP's JWT claim shape is small (~20 lines for a #[derive(Deserialize)] struct, ~30 lines for a mapping closure) and adopters care about their specific IdP's exact claim semantics, not a generic average. Hard-coding wif-github, wif-k8s, wif-gitlab features in the library invites endless additions without reuse benefit.

Instead: one WorkloadResolver<C, F, R> is generic over

  • C; the adopter's #[derive(Deserialize)] claim struct
  • F; the closure mapping verified claims to WorkloadMapping
  • R; JTI replay-store type (defaults to NoReplay)

The library handles JWT verification (signature against JWKS, iss/aud/exp/nbf/alg checks), trust-domain pinning, and Principal construction. The closure handles claim → identity-components.

Ready-made recipes

examples/workload-identity/ ships claim parsers + mappers for two common issuers. Adopters copy the recipe that matches their IdP into their codebase (recommended for production) or depend on the crate directly (useful for prototypes and tests).

Kubernetes service accounts

Kubernetes mints OIDC-style tokens for pods through the TokenRequest API. A pod requests a token bound to a specific audience (the URL of your application, say), and the cluster's control plane returns a signed JWT carrying the pod's service-account identity. The token's iss is the cluster's OIDC issuer URL; the kubernetes.io.{namespace,serviceaccount.name} custom claim block carries the pod's identity.

use axess_example_workload_identity::kubernetes::{
    k8s_sa_mapper, K8sCustomClaims,
};
use axess_factors::federation::workload::WorkloadResolver;
use axess_factors::jwt::verifier::JwtVerifier;
use axess_identity::{Issuer, TrustDomain};
use std::sync::Arc;

// Startup wiring (cache the verifier; reuse across requests):
let verifier = Arc::new(
    JwtVerifier::new(cluster_jwks_handle)
        .with_issuer("https://kubernetes.default.svc.cluster.local")
        .with_audience("axess-platform"),
);
let trust_domain = TrustDomain::new("cluster.local").unwrap();

// Per request: adopter middleware peeks at the token to look up
// tenant_id from the namespace, then constructs the resolver.
let resolver = WorkloadResolver::<K8sCustomClaims, _, _>::new(
    verifier.clone(),
    trust_domain.clone(),
    tenant_id,
    Issuer::custom("kubernetes").unwrap(),
    bearer_token,
    k8s_sa_mapper(trust_domain),
);
let principal = resolver.resolve().await?;

The recipe synthesises a SPIFFE-shape workload id of the form spiffe://cluster.local/<sa_name>/<namespace>. Adjust the recipe's path layout if your trust-domain convention differs.

GitHub Actions OIDC

GitHub Actions can issue OIDC tokens for workflow runs. The token carries claims naming the repository, the workflow, the branch, the run id, and the actor. Combined with a trust-domain mapping, the token authenticates a specific workflow run from your organisation against your application.

use axess_example_workload_identity::github_actions::{
    github_actions_mapper, GitHubActionsClaims,
};
use axess_factors::federation::workload::WorkloadResolver;
use axess_factors::jwt::verifier::JwtVerifier;
use axess_identity::{Issuer, TrustDomain};
use std::sync::Arc;

let verifier = Arc::new(
    JwtVerifier::new(github_jwks_handle)
        .with_issuer("https://token.actions.githubusercontent.com")
        .with_audience("axess-platform"),
);
let trust_domain = TrustDomain::new("github.actions").unwrap();

let resolver = WorkloadResolver::<GitHubActionsClaims, _, _>::new(
    verifier.clone(),
    trust_domain.clone(),
    tenant_id,
    Issuer::custom("github_actions").unwrap(),
    bearer_token,
    github_actions_mapper(trust_domain),
);
let principal = resolver.resolve().await?;

The recipe synthesises spiffe://github.actions/<repo>/<owner> and preserves actor, workflow, ref, sha, event_name as Cedar attributes for policy use (allow only deploys from the default branch, require a specific workflow file, etc.).

Other issuers (GitLab CI, Okta, Azure AD, Auth0, …)

Write your own recipe. For any new IdP:

  1. Decode a sample JWT to identify which claims carry the workload identity (project_path? namespace_id? a custom service?).
  2. Define a #[derive(Deserialize)] struct YourClaims { ... } with only the fields you care about. JwtVerifier ignores unknown claims, so you don't have to enumerate everything the issuer sends.
  3. Write a mapper closure Fn(&VerifiedClaims<YourClaims>) -> Result<WorkloadMapping, IdentityError> that produces the (workload_id, service_name, tenant_slug, attributes) shape.
  4. Wire as above, with Issuer::custom("your_idp_label").unwrap() for audit-log attribution (the constructor validates the label format: [a-z0-9_]{1,32}).

WorkloadId::build renders spiffe://<trust_domain>/<service>/<tenant_slug>, and each of those two segments is one path component: [A-Za-z0-9._~-], non-empty, with the whole URI capped at 2048 characters. A claim that is already a path does not pass through. GitLab's project_path is group/subgroup/project, and handing it over whole fails on the first slash, so decide what the segments mean for your issuer and build them rather than forwarding a claim verbatim.

The two shipped recipes are the templates; read their source, adapt as needed.

Where federation fits

Federation is the right answer when the deployment cannot or does not want to issue its own workload identities. A Kubernetes-based deployment that wants to use the pods' service-account tokens directly fits cleanly; an open-source CI integration that accepts tokens from any GitHub Actions run fits cleanly; an enterprise deployment that integrates with a partner's Okta tenant fits cleanly.

Federation is the wrong answer when the deployment runs SPIRE (or another SPIFFE issuer) and can mint its own SVIDs. In that case the SPIFFE-native resolvers (Inbound: JWT-SVID, Inbound: mTLS-SVID) are simpler, the trust model is tighter, and the federation indirection adds nothing.

Multi-resolver deployments are common. The same application typically accepts SPIFFE-native traffic from its own services and federated traffic from external collaborators; the resolvers wire side by side, each with its own router or middleware path, and the unified principal shape lets the policies stay the same across the sources.

Threat model

The federation flows share the threat model of the underlying issuer. A Kubernetes-issued token is as secure as the cluster's OIDC issuer; a GitHub Actions token is as secure as GitHub's issuance pipeline; an OIDC IdP-issued token is as secure as the IdP.

The defences that axess adds are the standard ones: signature verification against the issuer's JWKS, iss match, aud match, expiry check, optional clock-skew and max-age bounds, trust-domain pinning at the resolver layer, and the adopter's claim-mapper closure (which decides which subject paths the application admits).

The remaining attack surfaces are the issuer-specific ones. A compromised Kubernetes control plane mints compromised tokens. A misconfigured GitHub Actions workflow leaks the OIDC token. A compromised OIDC IdP issues tokens for arbitrary identities. The defences are operational: secure each issuer, monitor for unusual issuance patterns, rotate keys on a schedule.

Nothing is audited for you here. WorkloadResolver::resolve verifies the token and hands back a Principal; it writes no AuthEvent, and AuthEventType has no variant for workload authentication, so a SIEM rule watching the catalogue for these will wait forever. Axess emits from its service layer, which holds an IdentityAuthnLog to write through, and the workload resolvers sit outside it.

Record it at your call site if you want it: the issuer label and the synthesised WorkloadId are both on the principal the resolver returns, which is the attribution an issuer-level anomaly rule needs.

Troubleshooting

resolve() returnsUsuallyWhat to do
NotAuthenticatedThe JWT failed verification: wrong issuer, wrong audience, expired, bad signature, or a custom-claim that would not deserialiseTurn on tracing::debug! for axess_factors::federation::workload to see which step rejected it
InvalidSpiffeIdThe token verified, but the trust domain in the synthesised WorkloadId is not the one the resolver pinned. Usually the mapper closure built the id under the wrong trust domainCheck which trust_domain the recipe captured
InvalidComponent(...)The claim mapper rejected the verified claims, or a path segment carried a character outside [A-Za-z0-9._~-]. The message names whichDecode the payload (base64 -d of the JWT's middle segment) and compare it against what the mapper expects

Further reading

Workload identity overview covers the SPIFFE model the federation resolver maps into. Cloud STS exchange covers the next step for many federated tokens: exchanging a workload identity for short-lived cloud credentials. OAuth 2.0 and OIDC in Part III covers the underlying OIDC machinery that the JwtVerifier builds on.

Cloud STS exchange

A workload that has been authenticated through one of the inbound resolvers may need to call AWS, GCP, or Azure APIs on the workload's behalf. The cloud-native pattern for this is to exchange the workload's identity for short-lived cloud credentials through the cloud provider's Security Token Service. The mechanism is supported by all three major clouds under similar names (AWS STS AssumeRoleWithWebIdentity, GCP Workload Identity Federation, Azure Federated Identity Credentials), and axess provides adapters for each.

The feature flags are aws-sts, gcp-wif, and azure-fic, plus an umbrella cloud-sts that enables all three. All are off by default.

The pattern

The pattern is uniform across clouds. The application has a validated workload identity (a JWT-SVID, a federated OIDC token, a GitHub Actions OIDC token). The application wants to call a cloud API on the workload's behalf. Instead of giving the workload a long-lived cloud key, you exchange the workload's identity at the cloud's STS endpoint for a short-lived credential bound to a specific cloud role.

   workload identity      STS exchange       short-lived cloud credential
        token        ───>      ───>          (15 minutes, role-scoped)
                                                    │
                                                    ▼
                                              cloud API call

The exchange happens at the application layer, server-side. The workload's identity token never leaves your process; the short-lived cloud credential is what makes the actual cloud API call. The benefit is that no long-lived cloud key ever sits on the workload's filesystem, and revocation of the workload's identity (at the issuer) propagates to the cloud access without any cloud-side action.

The three clouds

The same exchange, spelled three ways.

AWS STS

The AWS adapter calls AssumeRoleWithWebIdentity, the STS API for identity federation. The configuration:

use axess_core::workload::outbound::cloud_sts::aws::{
    AssumeRoleWithWebIdentityRequest, AwsStsClient,
};

// Defaults to the global endpoint; `with_endpoint` pins a regional one
// (or LocalStack), `with_http_client` supplies timeouts or outbound mTLS.
let client = AwsStsClient::new();

let request = AssumeRoleWithWebIdentityRequest {
    role_arn: "arn:aws:iam::123456789012:role/billing-api-prod".into(),
    role_session_name: "billing-api".into(),
    web_identity_token: token,          // the workload's own JWT
    duration_seconds: Some(900),        // 15 minutes
    ..Default::default()
};

The role_arn is the AWS role the credential will assume. The role's trust policy specifies which web-identity tokens may assume it; the policy is configured on the AWS side, and the application's workload-identity issuer must match what the policy allows.

The session_duration is the lifetime of the resulting credential. AWS allows between 15 minutes and 12 hours (configurable per role). Fifteen minutes is the recommended default; a longer duration trades off some defence against credential theft against the overhead of re-exchanging.

The role_session_name_strategy controls how the resulting session is named in CloudTrail and AWS audit logs. Naming the session after the workload identity (WorkloadId) makes the audit trail readable; alternative strategies are available for deployments with specific compliance requirements.

async fn call_aws(
    client: &AwsStsClient,
    principal: &Principal,
) -> Result<(), Error> {
    let creds = client
        .assume_role_with_web_identity(&request_for(principal))
        .await?;

    let s3_client = aws_sdk_s3::Client::from_conf(
        aws_sdk_s3::Config::builder()
            .credentials_provider(creds)
            .build()
    );
    s3_client.list_buckets().send().await?;
    Ok(())
}

GCP Workload Identity Federation

The GCP adapter calls Google Cloud's federated-credentials endpoint, which exchanges a token from an external identity provider for a Google Cloud access token. The configuration:

use axess_core::workload::outbound::cloud_sts::gcp::{
    GcpStsClient, WorkloadIdentityPoolProvider,
};

let provider = WorkloadIdentityPoolProvider::new(
    "123",                  // project number
    "global",               // location
    "axess",                // pool id
    "external-oidc",        // provider id
);
let client = GcpStsClient::new();
let federated = client.exchange_token(&provider, token).await?;

The workload_identity_pool and workload_identity_provider name the GCP-side configuration that maps external identities to GCP identities. The pool and provider are configured on the GCP side through the gcloud CLI or Terraform; your adapter references them by name.

The target_principal is the GCP service account the exchange impersonates. The service account's IAM bindings determine which GCP resources the resulting credential can access.

The scopes list bounds what the credential can be used for. The narrowest possible scope is the recommendation; cloud-platform is the broadest and should be used only when you genuinely needs unrestricted access.

Azure Federated Identity Credentials

The Azure adapter exchanges an external identity for an Azure AD access token through the FIC (Federated Identity Credential) mechanism. The configuration:

use axess_core::workload::outbound::cloud_sts::azure::{
    AzureFicClient, AzureFicRequest,
};

let client = AzureFicClient::new(
    "00000000-0000-0000-0000-000000000000",   // Azure AD tenant
    "11111111-1111-1111-1111-111111111111",   // managed identity / app id
);

let request = AzureFicRequest::new(token)
    .scopes(["https://storage.azure.com/.default"]);
let response = client.acquire_token(&request).await?;

The tenant_id is the Azure AD tenant. The client_id is the managed identity or application registration in that tenant that the exchange will authenticate as; the FIC binding on the managed identity determines which external tokens may exchange for it.

The scope is the Azure AD resource the resulting token is bound to. Azure tokens are audience-scoped; a token for storage cannot be used against Key Vault. List the scopes you need; use the .default suffix to inherit the managed identity's configured permissions.

Credential lifecycle

The short-lived credentials returned by all three STS endpoints have explicit expiry. The application's call path needs to respect the expiry:

The simple shape is one exchange per cloud call. The application exchanges, makes the call, discards the credential. The latency overhead is one STS round-trip per call (typically 50 to 200 ms depending on the cloud), which is acceptable for one-off operations.

The optimised shape is to cache the exchanged credential for the duration of its validity. The application exchanges once, caches the credential, uses it for subsequent calls until it nears expiry, then re-exchanges. The cache key is the workload identity plus the target role; the cache value is the credential plus its expiry.

The right shape depends on the call rate. Below a few calls per minute, the simple shape is fine. Above that, the optimised shape with a per-workload cache (a ClockTtlCache from axess-cache) eliminates the per-call STS round-trip.

The expiry handling needs care. A credential that expires mid-call produces an authentication error from the cloud SDK, which you catch and translate into a re-exchange. The cache wraps the expiry check; calls that get a near-expired credential refresh proactively.

Multi-cloud deployments

A deployment that uses multiple clouds (a workload that calls both AWS and GCP, say) configures one exchanger per cloud. The two are independent; they share the workload identity as input but produce cloud-specific credentials as output.

The pattern composes cleanly. The application has a workload principal; it has an AwsStsClient, a GcpStsClient and an AzureFicClient as it needs them; calls to each cloud go through that cloud's client. No cross-cloud coupling.

Threat model

Cloud STS exchange is robust against credential theft because the short-lived credentials it produces are time-bounded. A stolen credential expires within minutes regardless of the attacker's actions.

The remaining attack surfaces:

The workload identity itself. A compromised workload identity can be exchanged for fresh cloud credentials at any time. The defence is to keep the workload identity short-lived (SPIRE rotates SVIDs every few hours, GitHub OIDC tokens are single-use), so a compromised identity has a bounded lifetime.

The STS endpoint. A compromised STS issues compromised credentials. The defence is operational: the cloud provider secures their STS; you validate the returned credentials by their structure (signature, format) but cannot independently verify that the STS itself is honest.

The role's trust policy. A misconfigured trust policy allows any workload to assume the role, defeating the identity-based restriction. The defence is to review trust policies carefully at deployment time; the principle of least privilege applies.

Audit

Each exchange produces a cloud-side audit event: CloudTrail for AWS, Cloud Audit Logs for GCP, Activity Log for Azure. Axess emits nothing of its own here. These clients are primitives you call directly, with no audit sink on the call path, and the event vocabulary has no name reserved for a token exchange. If you want the axess-side half of the picture, record it yourself where you perform the exchange, so you have: what identity was exchanged, when, for what role, and what cloud actions the resulting credential performed.

The retention configuration is in Audit pipeline. The recommendation is longer retention for STS-exchange events than for ordinary authentication events, because the events defend against future compliance review of cross-cloud actions.

Troubleshooting

If the exchange returns AccessDenied from AWS STS, the role's trust policy does not admit the token. Check the policy's Principal.Federated and Condition blocks; the most common issues are a wrong issuer URL, a wrong audience, or a missing required claim.

If the exchange returns INVALID_ARGUMENT from GCP, the workload identity pool or provider name is wrong, or the token's shape does not match what the provider expects. Inspect the provider configuration through gcloud iam workload-identity-pools providers describe.

If the exchange returns AADSTS70021 from Azure, the FIC binding on the managed identity does not match the token's subject claim. Update the FIC configuration to match what the workload identity emits.

Further reading

Inbound: JWT-SVID, Inbound: federation cover the resolvers that produce the workload identity that gets exchanged here. Outbound: OAuth covers OAuth-based outbound credentials, which are an alternative to cloud STS for some non-cloud downstreams. Audit pipeline covers the retention configuration for cross-cloud audit events.

Outbound: OAuth

Your application authenticates itself as a workload against a downstream OAuth-protected service. The application is the OAuth client; the downstream is the resource server. The credential is an access token you acquire through one of the OAuth client flows (client credentials, token exchange, or refresh of a stored token).

The chapter pairs with Inbound: federation and Cloud STS exchange: those cover the inbound case where you accepts workload tokens; this covers the outbound case where the application presents them.

The feature flag is outbound-oauth (off by default).

When to use it

Three patterns lead to outbound OAuth.

A service-to-service call between two services your deployment owns, where the receiving service authenticates inbound OAuth (typically through the generic WorkloadResolver from Inbound: federation). The application's outbound configuration mints a fresh token through the client-credentials grant, sends it on the request, and the receiving service validates it.

A call to a SaaS service that requires OAuth (Slack, Stripe, Twilio, an enterprise CRM). The application is registered as an OAuth client at the SaaS, holds a client id and secret, and mints tokens to call the SaaS's API.

A call on a user's behalf to a downstream service, where the credential is a token exchanged from the user's session or from a stored refresh token. This is the OBO case, covered in Delegated and OBO access; the outbound-oauth machinery in this chapter is what delegated-stored and delegated-exchange use under the hood.

Configuration

OutboundOAuthClient fetches tokens through the client-credentials grant. The configuration:

use axess_core::ZeroizedString;
use axess_core::workload::outbound::oauth_client::{
    ClientAuthMethod, OutboundOAuthClient,
};

let client = OutboundOAuthClient::new(
    "https://idp.example.com/oauth/token".parse()?,
    ClientAuthMethod::ClientSecretBasic {
        client_id: "billing-api-prod".into(),
        client_secret: ZeroizedString::new(secret),
    },
)
.with_scopes(["https://api.downstream.example/.default"]);

The first argument is the OAuth server's token endpoint. It typically comes from the server's discovery document; the configuration is the resolved URL.

The client authenticates to that endpoint with a ClientAuthMethod:

pub enum ClientAuthMethod {
    ClientSecretBasic { client_id: String, client_secret: ZeroizedString },
    ClientSecretPost  { client_id: String, client_secret: ZeroizedString },
    PrivateKeyJwt {
        client_id: String,
        signing_key: EncodingKey,
        algorithm: Algorithm,
        key_id: Option<String>,
        audience: String,
        assertion_ttl: Duration,
    },
}

ClientSecretBasic puts client_id and client_secret in an Authorization: Basic header, which is what most off-the-shelf IdPs expect (Okta, Auth0, Entra). ClientSecretPost sends the same pair as form fields, which some older IdPs require instead; check their documentation rather than guessing. Both secrets are zeroized on drop.

PrivateKeyJwt is RFC 7523, and it is the one to reach for in a FAPI-grade integration: axess signs a short-lived assertion with its private key and the IdP validates it against the published JWKS, so there is no shared secret to rotate. Its audience is the assertion JWT's own aud claim: per RFC 7523 §3 a value the IdP recognises as naming itself, usually the token endpoint URL, though some IdPs want their issuer URL instead. It does not name the downstream API. The signing_key is validated when the client is constructed, so a malformed key fails at startup rather than on the first call.

mTLS is not a variant here. Outbound client-certificate authentication is a property of the connection rather than of the token request; see Outbound mTLS.

The rest of the builder is small: with_scopes sets the requested scopes, with_refresh_threshold moves the cache's refresh point (default 30 seconds before expiry), and with_clock, with_rng and with_http_client substitute the ambient dependencies, the first two for deterministic tests, the third for proxy, timeout or outbound-mTLS configuration.

The narrowest possible scope list is the recommendation; over-broad scopes leak privilege if the resulting token is compromised.

There is no audience parameter. Token endpoints that bind a token to a specific resource through a non-standard audience form field (Auth0, some Azure AD configurations) are not covered by the builder; where the IdP accepts a resource-shaped scope instead (.default for Entra, for example), express it through with_scopes.

Getting a token

get_access_token returns the current token as a String, fetching one if the cache has nothing fresh:

async fn call_downstream(
    client: &OutboundOAuthClient,
    http: &reqwest::Client,
) -> Result<(), Error> {
    let token = client.get_access_token().await?;

    let response = http
        .get("https://api.downstream.example/data")
        .bearer_auth(&token)
        .send()
        .await?;
    Ok(())
}

OutboundOAuthClient always caches, and there is no wrapper to reach for: it holds the token itself. The first call fetches and stores the response under a write lock, later calls inside the validity window read it under a read lock, and the window ends refresh_threshold before expires_in so a token does not expire mid-flight. Call it per request rather than holding the returned String, and the refresh is handled for you.

force_refresh bypasses the cache and replaces its contents, which is what to call when the downstream rejects an apparently-fresh token, since the IdP may have revoked it early.

Token exchange (RFC 8693)

The token-exchange flow is the alternative to client-credentials when the outbound call is on behalf of an inbound principal (human or workload). The application presents the inbound credential to a token-exchange-capable IdP and receives a token bound to the downstream audience.

use axess_core::ZeroizedString;
use axess_core::delegated::exchange::{TokenExchangeClient, TokenExchangeRequest};

let client = TokenExchangeClient::new(
    token_endpoint,
    "billing-api-prod",
    Some(ZeroizedString::new(client_secret)),
);

let token = client
    .exchange(
        &TokenExchangeRequest::new(
            inbound_token,
            "urn:ietf:params:oauth:token-type:jwt",
        )
        .with_audience("https://api.downstream.example")
        .with_scopes(["read:data"]),
    )
    .await?;

The client secret is optional. Pass None where the authorization server authenticates axess through mTLS at the transport layer instead, and configure the certificate on a reqwest::Client handed to with_http_client.

The exchange runs through the IdP's token endpoint with the RFC 8693 parameters; the IdP validates the subject token, applies whatever exchange policy it has, and returns a TokenExchangeResponse. Its access_token is a ZeroizedString rather than a String, so the in-memory copy zeroes on drop; deref it where the HTTP client wants a &str. The pattern is what most enterprise IdPs support today (Azure AD, Okta, Auth0); the OBO chapter covers it in detail from your side.

Sender-constrained tokens

The FAPI 2.0 chapter (FAPI 2.0) covers DPoP and mTLS as ways to bind an access token to a key the client controls. That machinery is inbound: SenderConstraint is a field of FapiConfig, which applies to an OAuthProviderConfig axess authenticates users against.

The outbound client does not generate DPoP proofs. Sender-constraining an outbound call means one of two things instead. Either authenticate to the token endpoint with ClientAuthMethod::PrivateKeyJwt, which proves possession of a private key on every token request and removes the shared secret a thief could replay; or present a client certificate on the connection, which is Outbound mTLS, and ask the IdP to bind the issued token to that certificate under RFC 8705.

Whether the second is available is the IdP's decision, not axess's: the binding is recorded in the token's cnf claim by the issuer. Axess presents the certificate; it does not verify that the issuer acted on it.

Threat model

The outbound OAuth flows have a smaller threat surface than the inbound flows because you control both ends of the trust relationship.

Against client credential theft: the credential lives in the application's secrets store. Theft requires application-level compromise, which has bigger problems than just the OAuth credential.

Against access token theft in transit: TLS protects the wire. A stolen token from a TLS-protected call requires breaking TLS, which is not the OAuth client's defence to provide.

Against access token theft at rest: tokens are short-lived (typically minutes) and held in process memory. A long-lived refresh token (in the stored OBO case) is what carries longer exposure; the encrypted credential store decorator covers that.

Against scope creep: the scopes parameter restricts what the token can do. The discipline is to request the narrowest scopes you need, so a compromised token has limited blast radius.

Troubleshooting

If the token endpoint returns invalid_client, the client credentials are not what the IdP expects. The most common cause is using ClientSecretBasic against an endpoint that wants the credentials as form fields (ClientSecretPost), or a shared secret where the IdP expects PrivateKeyJwt.

If the token endpoint returns invalid_scope, the requested scopes are not authorised for this client. Check the client's registration at the IdP to see which scopes are permitted.

If the downstream returns 401 on an apparently-fresh token, the audience does not match what the downstream expects. Some IdPs default a client-credentials token's audience to the client id rather than to a resource URL. The builder exposes nothing to override it, so the fix is at the IdP, either by registering the downstream as a resource and requesting its scope (.../.default and similar) or by configuring the default audience on the client registration.

If a call fails on a token that worked moments earlier, the IdP revoked it before its stated expiry. The cache has no way to learn this, so call force_refresh on a 401 and retry once before surfacing the error.

Further reading

OAuth 2.0 and OIDC covers the inbound OAuth machinery and the shared OIDC primitives. FAPI 2.0 covers DPoP and the sender-constrained-token pattern. Delegated and OBO access covers the higher-level OBO machinery that uses outbound OAuth under the hood. Operations runbook covers client-credential rotation and the DPoP key lifecycle.

Outbound: mTLS

Your application presents an X.509 client certificate during the outbound TLS handshake, to a downstream service that requires mTLS. The credential is the application's workload identity in X.509 form, typically an X.509-SVID issued by SPIRE or an equivalent. The downstream validates the certificate against its trust anchor and accepts or rejects the connection.

The feature flag is outbound-mtls (off by default).

When to use it

Outbound mTLS is the right pattern for service-to-service traffic within a federation that uses mTLS as the standard authentication mechanism (a SPIFFE-based service mesh, an intra-organisation network where everything speaks mTLS, a partner integration where both sides have agreed to mTLS). The application's certificate identifies it as a workload to the downstream; no bearer token needs to ride the request.

The pattern is operationally simpler than outbound OAuth because the authentication happens once at connection setup rather than per request. A long-lived TLS connection handles many requests without re-authenticating; a short-lived connection re-authenticates on the next request. The cost is the TLS handshake's CPU and round-trip; the benefit is no per-request authentication state.

Configuration

OutboundMtlsClient is the type that holds the certificate and key, and provides them to the outbound TLS handshake. The configuration:

use axess_core::workload::outbound::mtls_client::OutboundMtlsClient;

// The client takes the material, not paths to it: reading and reloading
// the files is yours, which is what lets an SVID come from a SPIFFE
// Workload API socket rather than from disk.
let client = OutboundMtlsClient::new_from_pem(&cert_chain_pem, &private_key_pem)?
    .with_server_roots_pem(&ca_bundle_pem)?;

client_cert_path and client_key_path are filesystem paths to the certificate and the private key. The conventional location is where SPIRE writes them: SPIRE rotates the certificate on a configurable schedule (typically every few hours), writes the new files atomically, and the client picks them up on next read.

ca_bundle_path is the optional path to the trust anchor for the downstream's server certificate. When set, the client validates the downstream's server cert against this bundle; when unset, the client uses the system trust store.

reload_interval controls how often the client checks the certificate files for changes. The check is a stat call; an unchanged file is a no-op, a changed file triggers a re-read. The default (every five minutes) matches typical SPIRE rotation schedules; deployments with faster rotation lower this.

The TLS handshake

The client integrates with your HTTP client (typically reqwest, but the pattern generalises) through a custom Connector:

use axess::workload::outbound::OutboundMtlsClient;
use reqwest::Client;

let mtls = OutboundMtlsClient::new(/* ... */);

let http_client = Client::builder()
    .use_preconfigured_tls(mtls.rustls_client_config())
    .build()?;

let response = http_client
    .get("https://downstream.example/data")
    .send()
    .await?;

rustls_client_config returns a rustls ClientConfig with the certificate, key, and trust anchor configured. The use_preconfigured_tls integration on reqwest accepts this directly; other HTTP clients have similar integration points.

The handshake validates the downstream's server certificate against the configured trust anchor (or the system store), then presents the client certificate. If the downstream requires the client certificate and yours is missing or invalid, the handshake fails. If the downstream does not require the certificate, the handshake succeeds and the certificate is ignored.

Certificate rotation

The certificate rotation is what makes outbound mTLS sustainable in production. A static certificate provisioned at deployment time expires; the deployment has to redeploy to refresh it. A rotated certificate refreshes itself; the deployment runs indefinitely.

SPIRE rotates X.509-SVIDs on a schedule the operator configures (typically every few hours). The new certificate is written atomically to the filesystem (a temporary file plus a rename, so the in-progress reads see either the old or the new, never a truncated file). The application's OutboundMtlsClient reads the files at construction and on its reload interval.

The reload-interval choice matters. Too short, and the client spends CPU on stat calls. Too long, and the client uses an expired certificate, producing handshake failures. The recommendation is to set the interval to about a third of the certificate's lifetime, so a typical rotation leaves enough time for the next reload to pick up the new files before expiry.

A reload that finds a malformed certificate logs the error and keeps the previous certificate in memory. The client continues to function until the previous certificate expires, by which point either the malformed state is fixed or the handshake fails. The graceful-degradation pattern is the right shape: a botched rotation should not bring your service down immediately.

When the downstream is also axess

A common shape is two axess-instrumented services calling each other over mTLS. The calling side presents its X.509-SVID through the outbound-mtls machinery; the receiving side validates it through the mtls resolver from Inbound: mTLS-SVID. The two sides compose without any further integration: the same SPIFFE identity flows through the TLS handshake, the receiving resolver extracts it, the resulting principal is the calling service's identity.

The pattern is what gives a SPIFFE-based deployment a fully identity-aware service mesh at the application layer, without requiring a sidecar proxy. The mesh's identity is the application's identity; the audit trail records the same identity at every hop.

Threat model

Outbound mTLS shares the threat model of the X.509-SVID inbound case from Inbound: mTLS-SVID. The key-storage problem is the biggest concern: a workload whose private key is on disk is vulnerable to filesystem compromise; a workload whose key lives in a TPM, HSM, or KMS is much harder to compromise.

The additional concern for outbound is the downstream's trust configuration. A misconfigured downstream that accepts any client certificate from any CA (or that does not require client certificates at all) defeats the authentication. The defence is operational: ensure the downstream's trust configuration is correct, monitor for unexpected accepted connections, audit the configuration on a schedule.

Troubleshooting

If the handshake fails with a certificate-validation error, the downstream does not trust your CA. Its trust bundle needs to include your CA; this is the downstream's configuration, not the client's.

If the handshake succeeds but the downstream returns 401 on every request, the downstream is performing authorisation against the certificate's identity rather than just authentication. Check the downstream's authorisation policy: it may require a specific SPIFFE path, a specific issuer, or a specific X.509 extension that your certificate does not have.

If the reload fails silently and you keep using an expired certificate, check the reload-interval configuration and your log output. The reload errors are logged at warn level; a missed reload typically surfaces as a "failed to read certificate" message.

Further reading

Inbound: mTLS-SVID covers the receiving side of the same machinery. Workload identity overview covers the SPIFFE model both sides use. Cloud STS exchange covers the alternative pattern for downstreams that require bearer tokens rather than mTLS. Operations runbook covers the certificate rotation and the key-storage choices for production deployments.

Delegated and OBO access

The scenario is common: your application needs to act on behalf of the user against a downstream service. A user signs in, grants your application the right to read their inbox or post on their behalf, and from that moment forward your application can make calls to the downstream service that the downstream sees as coming from the user. The mechanism is on-behalf-of (OBO) access, and axess covers two shapes through the delegated/ module under axess-core.

The feature flag is delegated (off by default), with two narrower variants (delegated-stored, delegated-exchange) that turn on each shape independently. The module lives inside axess-core rather than as a separate crate because the encryption envelope it needs already ships with the SQL session backends, so the isolation benefit a separate crate would have provided was illusory. Adopters who do not turn on the feature pay zero compile cost.

The two shapes

OBO comes in two architectural shapes. The shape matters because the operational characteristics differ: where credentials live, how often they refresh, what happens when the user revokes consent.

The first shape is stored OBO. The user grants consent once through an OAuth flow; you receive a refresh token along with the initial access token; you persist the refresh token; future calls to the downstream service use the refresh token to mint a fresh access token, then use the access token to make the actual call. The pattern is what most "connect your Google account" or "connect your Slack account" flows do.

The second shape is token exchange (RFC 8693). The user's session in your application carries a credential (a session cookie, a JWT session, a workload identity token). When you need to call a downstream service on the user's behalf, it presents the credential to a Security Token Service (STS) and receives a short-lived access token bound to the call. There is no persistent storage of credentials for the downstream; the exchange happens per call (or per a short cache window).

The two shapes solve different problems. Stored OBO is right when you need to act on the user's behalf when the user is not actively present (a scheduled report that pulls from Gmail at 6am, a background sync that runs while the user is offline). Token exchange is right when you need to act on the user's behalf only while the user has an active session, and where the user's session credential can be exchanged for a downstream credential at low cost.

The two mechanisms

One keeps a refresh token on your side; the other keeps nothing.

Stored OBO

The stored OBO shape uses the delegated-stored feature. The machinery has three moving parts: an OAuth flow that grants initial consent, a credential store that persists the refresh token, and a refresh path that mints fresh access tokens for calls.

The initial grant is an OAuth authorization code flow where the scopes include the downstream's access scope (https://mail.google.com/, channels:read, whatever the downstream's vocabulary is) and the flow includes offline_access (the OAuth scope that asks for a refresh token). The flow's success returns both an access token (usable immediately) and a refresh token (storable for later use).

The persistence runs through the DelegatedCredentialStore trait, keyed by the (tenant, user, provider) triple:

use axess_core::delegated::stored::{DelegatedCredentialStore, StoredDelegation};

pub trait DelegatedCredentialStore: Send + Sync + 'static {
    fn load(
        &self,
        tenant: &TenantId,
        user: &UserId,
        provider: &str,
    ) -> impl Future<Output = Result<Option<StoredDelegation>, String>> + Send;

    fn save(
        &self,
        tenant: &TenantId,
        user: &UserId,
        credential: StoredDelegation,
    ) -> impl Future<Output = Result<(), String>> + Send;

    fn revoke(
        &self,
        tenant: &TenantId,
        user: &UserId,
        provider: &str,
    ) -> impl Future<Output = Result<(), String>> + Send;
}

pub struct StoredDelegation {
    pub provider: String,
    pub access_token: ZeroizedString,
    pub refresh_token: Option<ZeroizedString>,
    pub expires_at: Option<DateTime<Utc>>,
    pub scopes: Vec<String>,
    pub token_type: String,
}

Two details in that signature are worth reading twice. The futures are native impl Future rather than #[async_trait] boxes, so an implementation costs no allocation per call. And the error is a plain String: the trait does not constrain the implementor's concrete error type, which keeps a store backed by sqlx, by Dynamo, or by a bespoke vault from having to convert into an axess error enum. Format the detail you want to see in your logs into that string.

There is no owner type. The triple is passed positionally, so the same (tenant, user) pair holds independent credentials for "google", "slack" and "github" side by side, and provider matches the DelegatedProvider::name of the grant it came from.

save overwrites. Refresh-token rotation lands there rather than on a separate update path, because the store treats the whole credential as the unit of write.

Both token fields are ZeroizedString, so the in-memory copy zeroes on drop. That says nothing about storage: encryption at rest is the implementor's responsibility, and the plaintext reaches your save unless you wrap the store. MemoryDelegatedCredentialStore ships for dev and test and holds plaintext; it is not a production store.

The wrapper is EncryptedDelegatedCredentialStore<S, K>, a decorator over any store S and a KeyProvider K. It encrypts with AES-256-GCM under a random 12-byte nonce per row, and the key provider carries historical keys so a rotation can still read what the previous key wrote. The trait surface is unchanged; the encryption happens inside the decorator.

The refresh path is StoredDelegationSession. It wraps a store and a provider, and its get_access_token loads the credential, returns the access token if it is still fresh against the configured skew, and otherwise runs the refresh exchange and saves the result before returning. Do not read StoredDelegation::access_token directly; the session is what owns refresh-before-expiry. StoredDelegationSession::revoke is the matching teardown: it calls the provider's revocation endpoint where one is configured and removes the stored row.

Token exchange

The token exchange shape uses the delegated-exchange feature. The machinery is much smaller because there is no persistent storage: the exchange runs per call.

The exchange is an RFC 8693 token exchange. The application presents:

  • A subject token: the credential identifying the user. This might be the user's session ID, a JWT session token, or a workload identity token that names the user.
  • A subject token type: an identifier for the kind of subject token (urn:ietf:params:oauth:token-type:access_token, urn:ietf:params:oauth:token-type:jwt, an application-specific string).
  • The audience: the downstream service the token will be used against.
  • Optional: the scope of the requested token (defaults to "all scopes the user has").

The STS validates the subject token, determines the user's identity, applies whatever policy decisions the deployment has configured (Cedar policies that govern the exchange, the user's allowed downstreams), and returns an access token bound to the audience.

use axess_core::ZeroizedString;
use axess_core::delegated::exchange::{TokenExchangeClient, TokenExchangeRequest};

let client = TokenExchangeClient::new(
    token_endpoint,
    client_id,
    Some(ZeroizedString::new(client_secret)),
);

let downstream_token = client
    .exchange(
        &TokenExchangeRequest::new(session_credential, ACCESS_TOKEN_TYPE)
            .with_audience("https://api.downstream.example")
            .with_scopes(["read:data"]),
    )
    .await?;

let response = http_client
    .get("https://api.downstream.example/data")
    .bearer_auth(&*downstream_token.access_token)
    .send()
    .await?;

The exchange runs in the request path. The latency cost is one round-trip to the STS plus the actual downstream call. The exchanged token is short-lived (typically minutes), so the application either re-exchanges per call (the simple shape) or caches the exchanged token for the duration of its validity (the optimisation, which is worth the complexity only at high call rates).

Which to use

The decision tree is short.

If you need to act on the user's behalf while the user is offline (a background job, a scheduled report, a notification that runs hours after the user has gone home), use stored OBO. Token exchange does not work because the user's session does not exist when the call needs to happen.

If you call the downstream only while the user is actively signed in, and the downstream service supports token exchange (Azure AD, Google Cloud, most enterprise SaaS that supports RFC 8693), use token exchange. The credential never hits your database, so the breach impact is smaller.

If you need both shapes, both work side by side. The two crates compose without conflict; turn on both feature flags.

The most common shape in practice is hybrid: token exchange for the foreground synchronous calls (the user clicks "fetch latest data from Gmail"), stored OBO for the background asynchronous calls (the nightly sync that pulls all new mail since the last run). The two flows handle the two needs.

Operating it

Recording consent, and taking it back.

Both shapes need an audit trail. The user granted consent at a specific moment; that moment is what defends against later disputes ("this application made calls I did not authorise").

Axess emits neither. Both shapes are primitives you call directly: complete_grant returns a StoredDelegation for you to store, and TokenExchangeClient::exchange returns a response. Neither has an audit sink on its call path, and the event vocabulary has no name reserved for a consent grant, a credential use or a token exchange.

So the trail is yours to write, and it is worth writing. Record the grant with what the user agreed to (which scopes, which downstream) and each use with when, against which downstream, and for which operation if you surface that. Write it where you perform the operation, into the same store your IdentityAuthnLog implementation writes to, so the delegated trail and the authentication trail line up.

The audit retention for delegated events is typically longer than for ordinary authentication events because the events defend against future disputes that may surface months or years later. The retention configuration is in Audit pipeline.

Revocation

Both shapes need a revocation path. The user (or an administrator) decides you should no longer act on their behalf; the next call should fail.

Stored OBO revocation runs through DelegatedCredentialStore::revoke, which StoredDelegationSession::revoke calls for you after it has told the provider. The credential is removed from the store (or marked revoked, if the store retains for audit). Subsequent loads return None; your call path either treats this as "user has not granted consent" or as "consent was revoked, ask again."

Token exchange revocation runs through the user's session revocation. Logging the user out invalidates the session credential, which means subsequent exchanges fail; in-flight calls that have already exchanged the token continue until the exchanged token expires (typically minutes). The granularity is coarser than stored OBO but the operational simplicity is the trade-off.

Either shape benefits from the downstream's own revocation mechanism. Most OAuth providers support RFC 7009 token revocation; calling it on logout invalidates the access and refresh tokens at the IdP, so even a stolen credential cannot be used. Stored OBO with downstream revocation gives the strongest possible revocation guarantee.

Threat model

The threat surface for OBO is unusual. The application acts as the user, which means a compromise of your application is a compromise of the user's downstream account. The defences:

Minimise the scope of the OAuth grant. Request the narrowest scopes you need (channels:read not channels:*, the specific calendar not "all calendars"). The an attacker who compromises you can act only within the granted scopes.

Encrypt the stored credentials at rest. The EncryptedDelegatedCredentialStore decorator covers this. An attacker who breaches the database without the encryption key cannot use the stored credentials.

Monitor the trail you wrote above. A spike in delegated-credential use for one user, especially against operations they do not normally perform, is a strong signal of compromise. Axess supplies no rule for this because it supplies no event; the shape of the query follows whatever you recorded.

The fourth is to time-bound consent. Some downstreams support explicit consent expiry; for those that do not, you can require the user to re-consent on a schedule (every ninety days, every year). The friction is real; the defence against long-lived stale grants is also real.

The applications this opens up

OBO is what lets axess fit into the kind of application that does more than authenticate users for itself: a unified inbox that pulls from Gmail and Outlook, a CI pipeline that posts to Slack on the user's behalf, a calendar integration that books meetings. The mechanism is opt-in (the feature flag), the two shapes cover the architectural choices, and the encryption-at-rest plus the audit trail let the deployment defend its decisions.

Further reading

Refresh tokens and session continuity covers the refresh-token family-detection mechanism that also applies to stored OBO credentials. OAuth 2.0 and OIDC covers the OAuth flow that grants the initial consent. Workload identity overview covers the subject-token side of token exchange when the subject is a workload rather than a human. Audit pipeline covers the sink the delegated trail you write should share with the authentication trail.

Local IdP

axess::local_idp is an in-process workload-identity issuer. It mints JWTs against a signing key it holds locally, exposes the matching JWKS, and serves the RFC 8414 discovery document. The crate exposes this surface in two layers, both built on the same primitives:

  • Production LocalIdp. Adopter wires a [LocalIdpKeyStore] implementation (file system, Vault, KMS, ...) and the [LocalIdp] reads the current + historical keys, mints, and rotates atomically on operator request.

  • Testing LocalIdpFixture. In-process value that mints JWTs with a generated keypair and exposes a JwkSet handle that a [JwtVerifier] can read. No HTTP endpoints, no key store; just mint() + jwks_handle().

Both layers share [MintClaims], [LocalIdpSigningKey], and the issuance pipeline that lives in axess::local_idp::primitives. A token minted by either layer verifies against the same JWKS shape, which is the property that lets adopters run the same downstream verifier in tests and in production.

What both layers do NOT do

Neither layer is a full OAuth 2.0 Authorization Server. There is:

  • no authorization-code flow, no PKCE handshake;
  • no end-session endpoint;
  • no refresh-token rotation;
  • no consent UX;
  • no user store.

Use a real Authorization Server (Keycloak, Ory Hydra, Okta, Auth0, Azure AD, etc.) when you need any of those. LocalIdp exists for direct workload-identity issuance: a process mints short-lived JWTs for service-to-service flows it controls.

The feature flag is local-idp (off by default), enabled with features = ["local-idp", "jwt-rust-crypto"] on the axess facade. It pulls in jwt transitively and nothing else: minting and the JWKS need no OAuth machinery, so enabling this does not bring oauth or oidc with it. jwt needs a crypto backend named beside it: jwt-rust-crypto (pure Rust) or jwt-aws-lc (FIPS-capable, needs a C toolchain).


Production: LocalIdp

When to use

  • A service needs to mint workload-identity JWTs for its own internal flows (e.g. signing tokens that downstream services will verify via the published JWKS).

  • A development or staging deployment needs a self-contained IdP without standing up Keycloak. The same code path runs in production; only the [LocalIdpKeyStore] backend changes.

  • An air-gapped or single-tenant deployment wants on-host token issuance with no external dependency.

When not to use

If you need a user-facing IdP with login UI, OIDC authorization code flow, refresh tokens, or federation, reach for Keycloak / Ory Hydra / similar. LocalIdp deliberately stops at issuance.

The LocalIdpKeyStore trait

Adopters implement persistence against their own key material:

pub trait LocalIdpKeyStore: Send + Sync + 'static {
    type Error: std::error::Error + Send + Sync + 'static;

    async fn load_all(&self) -> Result<LoadedKeys, Self::Error>;

    async fn rotate(&self, new_current: LocalIdpSigningKey)
        -> Result<(), Self::Error>;
}

pub struct LoadedKeys {
    pub current: LocalIdpSigningKey,
    pub historical: Vec<LocalIdpSigningKey>,
}

load_all returns current + historical keys from a single consistent read. The JWKS published at /jwks.json includes all of them so tokens already in flight under a rotated-out historical key continue to verify until the operator removes that key from the store.

rotate persists a new current key, demoting the previous current to historical, atomically. Adopters typically expose this through their own admin endpoint or out-of-band tooling.

MemoryLocalIdpKeyStore for prototyping

A MemoryLocalIdpKeyStore ships with the crate for dev and test deployments where keys can live in process memory:

use axess::local_idp::{LocalIdp, LocalIdpSigningKey, MemoryLocalIdpKeyStore};

let key = LocalIdpSigningKey::generate_es256().with_key_id("v1");
let store = MemoryLocalIdpKeyStore::with_current(key);
let idp = LocalIdp::from_key_store("https://idp.example.com", store)
    .await
    .expect("load keys");

Memory storage is not for production: restarts lose the keys, and every restart produces fresh JWKS that breaks tokens already in flight. The examples/local_idp/ directory implements a file-backed [LocalIdpKeyStore] with atomic rotation that the production path should pattern after; the same shape adapts to Vault, AWS KMS, GCP KMS, or any other key management backend.

Minting

use axess::local_idp::MintClaims;
use chrono::{Duration, Utc};

let token = idp
    .mint(
        &MintClaims::new("worker-1", Utc::now() + Duration::minutes(5))
            .with_audience("https://api.example.com")
            .with_issued_at(Utc::now()),
    )
    .await?;

[MintClaims] is a builder: new(subject, exp) is the minimum; with_audience, with_audiences (multi-aud), with_issued_at, with_not_before, with_jwt_id, and with_custom_claim cover the standard JWT fields. mint_with_header accepts a caller-supplied jsonwebtoken::Header for cases that need custom header fields (typ, cty, etc.).

The clock is injectable via .with_clock(...). Production wires SystemClock; DST tests wire MockClock for reproducible issuance.

Rotation

let new_key = LocalIdpSigningKey::generate_es256().with_key_id("v2");
idp.rotate_signing_key(new_key).await?;

The call atomically:

  1. Persists the new current via [LocalIdpKeyStore::rotate].
  2. Demotes the previous current to historical.
  3. Rebuilds the JWKS snapshot so subsequent /jwks.json reads include both keys.

In-flight verifications using the old kid continue to succeed because the historical entry stays in the published JWKS.

Discovery + JWKS endpoints

LocalIdp::router() returns a ready-to-mount Axum router that serves the two standard endpoints:

let app = axum::Router::new()
    .nest("/", idp.router())
    .route("/issue", axum::routing::post(issue));

Routes:

  • GET /.well-known/openid-configuration: RFC 8414 metadata.
  • GET /jwks.json: current + historical public JWKs.

with_base_url(...) overrides the URL the discovery document advertises for jwks_uri when the IdP sits behind a reverse proxy. with_metadata_field(name, value) appends adopter-extension fields to the discovery document (scopes_supported, claims_supported, FAPI fields, etc.).

For full control, the lower-level handlers in axess::local_idp::discovery expose openid_configuration and jwks as standalone axum handlers.

Production-pattern example

The examples/local_idp/ crate is the reference implementation:

  • File-backed LocalIdpKeyStore (FileLocalIdpKeyStore) with the directory layout pattern historical/{kid}.pem + atomic current.kid pointer file.
  • POST /admin/rotate operator endpoint.
  • POST /issue mint endpoint.
  • A curl walkthrough of the full discover-mint-rotate cycle.

Testing: LocalIdpFixture

When to use

Integration tests that exercise:

  • The inbound JWT-SVID resolver (axess::jwt::svid::JwtSvidResolver).
  • The OAuth Resource Server resolver path.
  • Any of the cloud STS adapters.
  • The JwtVerifier shape generally.

The fixture mints tokens that verify against its own JWKS, so a test can produce a token with mint() and pass it to the resolver under test without involving an external IdP.

What it is NOT

The fixture is not an HTTP service. It is a value with mint(), jwks_handle(), and a handful of accessors. Tests use it by:

  1. Constructing the fixture.
  2. Calling idp.mint(&MintClaims::...) to obtain a JWT.
  3. Wiring a JwtVerifier to idp.jwks_handle() so verification reads the same JWKS the fixture signed against.

There is no authorize endpoint, no token endpoint, no Tower service wrapping; the fixture just produces signed tokens and exposes the verification key set.

The feature flag is testing plus local-idp. The fixture lives under axess::testing::local_idp::LocalIdpFixture.

Construction

use axess::testing::local_idp::LocalIdpFixture;

let idp = LocalIdpFixture::new("https://test.idp.local");

new(issuer) generates a fresh RSA-2048 keypair per call. Other constructors:

  • LocalIdpFixture::with_algorithm(issuer, Algorithm::ES256): generate with a specific signing algorithm. Supported: RS256, RS384, RS512, ES256.
  • LocalIdpFixture::with_signing_key(issuer, key): explicit key (use when the test needs a stable signature across runs).

Builder methods (chained on the constructed fixture):

  • .with_historical_signing_key(key): add a key to the JWKS without rotating to it. Drives JWKS-cache-refresh tests.
  • .with_extra_public_jwk(jwk): add an externally-supplied public JWK to the published set.
  • .rotate_signing_key(new_key): swap the signing key; the old key moves to historical and remains in the JWKS.
  • .with_max_ttl(duration): cap minted token lifetime. Over-cap mints panic (test-time misuse).
  • .with_issuance_listener(arc): install an [IssuanceListener] for assertion-side recording.
  • .with_key_id(kid): override the auto-generated kid.

Minting

use axess::testing::local_idp::{LocalIdpFixture, MintClaims};
use chrono::{Duration, Utc};

let idp = LocalIdpFixture::new("https://test.idp.local");

// Standard JWT.
let token = idp.mint(
    &MintClaims::new("alice", Utc::now() + Duration::hours(1))
        .with_audience("https://api.example.com"),
);

// SPIFFE JWT-SVID shape (subject = SPIFFE ID, audience required).
// Subject is spiffe://<trust_domain>/<service>/<tenant>, the same
// segment order `WorkloadId::build` renders. All five are required.
let svid = idp.mint_jwt_svid(
    "test.gnomes",                  // trust domain
    "worker",                       // service
    "acme",                         // tenant
    "sts.amazonaws.com",            // audience
    Duration::minutes(5),
);

mint_with_header accepts a caller-supplied header for cases that need custom fields.

Sharing the JWKS with JwtVerifier

use axess::jwt::verifier::JwtVerifier;

let verifier = JwtVerifier::new(idp.jwks_handle())
    .with_algorithms(idp.verifier_algorithms());

let claims = verifier
    .verify::<MyClaims>(&token, "https://api.example.com")
    .await?;

jwks_handle() returns an Arc<RwLock<JwkSet>> that the verifier borrows. Calls to rotate_signing_key on the fixture update the shared JWKS in place, so the verifier sees the rotation without any explicit refresh.

Feeding a cloud STS adapter

The fixture's mint_jwt_svid produces SPIFFE-shaped tokens suitable for cloud STS exchange tests:

use axess::workload::outbound::cloud_sts::aws::AwsStsClient;

let idp = LocalIdpFixture::new("https://oidc.test.local");
let token = idp.mint_jwt_svid(
    "test.gnomes", "worker", "acme",
    "sts.amazonaws.com",
    Duration::minutes(5),
);

// Hand the token to a mocked AWS STS endpoint to exercise the
// AssumeRoleWithWebIdentity flow without hitting real AWS.

Why both shapes coexist

Production LocalIdp and the test LocalIdpFixture share the same primitives module (axess::local_idp::primitives). The primitives define LocalIdpSigningKey, MintClaims, IssuanceEvent, IssuanceListener, and the internal JWT-encode pipeline. Both layers route their mint() calls through these primitives.

The consequence: a token minted by the fixture in a test verifies identically against a JwtVerifier configured with production LocalIdp's published JWKS, given the same signing key. Tests that pin a specific JWT signature exercise the same code paths that sign in production.

The split exists for what each layer adds on top:

  • Production carries the [LocalIdpKeyStore] abstraction so keys survive process restarts and can rotate without code changes.
  • Testing carries the in-memory key generation, the MockIssuanceListener, and ergonomic builders that match what test code typically wants to assert.

Neither subsumes the other; the production class is not the right fit for a unit test (no key store means no mint), and the fixture is not the right fit for production (in-memory keys lose on restart). The shared primitives are what lets both shapes claim "this is the same JWT issuer" without code duplication.

Audit events

Axess records what happened to an authentication as an AuthEvent: one flat struct, with AuthEventType naming the operation and AuthEventStatus the outcome. Authorisation is not in here. A Cedar decision is a tracing event on its own target, for the reason Cedar providers explains, and this chapter says where to look instead.

The write is synchronous with the operation, so an operation that succeeded has its event. Since 0.6.0 the sink is fail-closed: a record_event error fails the authentication, because an authentication that leaves no evidence has not, for evidence purposes, happened. A sink that wants to drop an event under load says so with AuditOutcome::Shed, which continues the flow; see Audit pipeline for why that valve exists and the rule for using it safely.

The chapter pairs with Audit pipeline, which covers how the events get from the application into the regulatory store and the analytics path.

What the events are for

Authentication is a security-sensitive operation, and security-sensitive operations need a defensible audit trail. Three audiences read the trail.

The compliance auditor. A regulator (or an external auditor verifying compliance with a regulator's requirements) needs to verify that the application enforced the controls the regulation requires: that MFA was demanded where MFA was required, that lockouts fired when configured, that no cross-tenant access happened. The audit trail is what answers these questions.

The incident responder. When something goes wrong (a user reports unauthorised access, a SIEM rule fires on an anomalous pattern, a breach is suspected), the responder needs to reconstruct what happened: which sessions were active, what authentications succeeded, what authorisations were granted. The audit trail is what supports the reconstruction.

The operational dashboard. Your running state is visible through the audit trail: how many logins succeed per hour, what fraction trigger lockouts, which tenants are active. The trail feeds the SIEM rules and the operational metrics.

The three audiences want different things from the same data, which is what drives the dual-stream design: a regulatory stream optimised for completeness and immutability, an analytics stream optimised for query latency and aggregation. Audit pipeline covers the streams; this chapter covers the events themselves.

The shape of an event

One flat struct, AuthEvent, carries every event. What happened and how it turned out are separate fields, not separate types: event_type says which operation, event_status says the outcome. So a failed login is LoginAttempt + Failure, not a LoginFailed type, and a locked one is LoginAttempt + Locked.

AuthEventStatus has five values, and the same split applies as to the event names: axess sets three, and two are there for you.

statuswiremeansset by
Successsuccessthe operation completedaxess
Failurefailureit did not (bad credential, and so on)axess
Lockedlockedrefused because the account is locked outaxess
Expiredexpiredrefused because something had expiredadopter
Suspicioussuspiciouscompleted or refused, and flagged as anomalousadopter

Locked is what a lockout records, on both LoginAttempt and FactorVerified. Filter on the status column for those, not on a substring of error.

Expired and Suspicious are unset for the same structural reason the adopter-emitted event names are. The paths where they would apply sit outside the service layer that owns the log: refresh-token expiry is refresh_session, a free function over a RefreshTokenStore with no audit sink in its signature, and a fingerprint mismatch comes from your DeviceResolver. Use them when you write those rows; a SIEM rule that matches on them will not fire on anything axess wrote.

The event catalogue

These are the AuthEventType values. The wire string is the stable contract: it is what lands in the audit row and what a SIEM rule matches, and axess-core's own tests pin every one of them.

The Emitted by column matters more than it looks. Axess emits from its own service layer, which is the part that owns an IdentityStore to write through. The rest of the vocabulary exists for glue the adopter implements: a DeviceResolver, a SessionStore, the delegated-credential primitives. Axess defines the name and the wire string so every deployment spells these the same way; emitting them is the adopter's, because axess is not on the call path.

Authentication

variantwire stringemitted by
LoginAttemptlogin_attemptaxess
Authenticatedauthenticatedaxess
LogoutAttemptlogout_attemptaxess
FactorVerifiedfactor_verifiedaxess

LoginAttempt covers the start and the refusal of a login; the status separates them. Authenticated is the end-to-end success, emitted once every required factor has passed.

A refusal is emitted even when there is nothing to attribute it to. An attempt against a tenant that does not exist writes an unattributed row with error = "unknown_tenant"; one against a real tenant with an unknown identifier writes a tenant-attributed row with error = "unknown_identifier". Two things follow:

  • A credential-stuffing run over unregistered addresses is visible in the trail, not only in a metric. That is usually the first sign of one.
  • Because every rejection path writes, an audit-store outage fails them all identically, so it cannot be used to tell registered identifiers from unregistered ones.

Factors and methods

variantwire stringemitted by
FactorSetupfactor_setupaxess
FactorEnabledfactor_enabledaxess
FactorDisabledfactor_disabledaxess
MethodEnabledmethod_enabledadopter
MethodDisabledmethod_disabledadopter

Credentials and account state

variantwire stringemitted by
PasswordResetRequestedpassword_reset_requestedaxess
PasswordResetpassword_resetaxess
SignupStartedsignup_startedaxess
SignupCompletedsignup_completedaxess
AccountSuspendedaccount_suspendedaxess
AccountActivatedaccount_activatedaxess
Impersonationimpersonationaxess

Impersonation is the administrative act of assuming another user's identity, emitted on both the successful and the refused path.

Sessions

variantwire stringemitted by
SessionExpiredsession_expiredadopter
SessionInvalidatedsession_invalidatedadopter

Both belong to the SessionStore implementation, which owns when a session ends.

Devices

variantwire stringemitted by
DeviceFirstSeendevice_first_seenaxess
DeviceRevokeddevice_revokedaxess
DeviceBindingAddeddevice_binding_addedaxess
DeviceTrustGranteddevice_trust_grantedadopter
DevicePurgeddevice_purgedadopter
DeviceFingerprintMismatchdevice_fingerprint_mismatchadopter

DeviceFingerprintMismatch is the cookie-replay signal: a request carried a valid device cookie whose recomputed fingerprint did not match. Record it Suspicious. It comes from the DeviceResolver, which is adopter-implemented, so axess cannot emit it for you; see Device identity for what a resolver does.

What is not here

Authorisation gets no events of its own. A Cedar decision is not an AuthEvent; it is a tracing event on the target axess::authz::decision, carrying principal, action, resource, decision (allow or deny), reasons and latency_us. Axess ships no audit transport of its own, so route that target with tracing-subscriber to wherever the audit goes. Cedar providers covers the schema.

The catalogue covers authentication only. Workload-identity, rate-limit, tenant-lifecycle and delegated-access events. Those subsystems are adopter-composed primitives with no audit sink on their call path, so nothing is emitted for them today, and the vocabulary has no names reserved for them either.

Emit cadence and fields

Events fire synchronously from the operation that produced them, so an operation that succeeded has an event. They are written through IdentityStore::record_event, which the deployment implements: that is the sink, and where it goes next is the deployment's decision. A write that fails is logged at error and does not fail the user's request, because a SOC blind spot is an incident but so is an outage.

Every event is the same struct. There are no per-variant field sets:

fieldtypenotes
event_typeAuthEventTypewhich operation
event_statusAuthEventStatusthe outcome
event_timei64epoch microseconds, not a DateTime, so the event is rkyv-archivable end to end
user_idOption<UserId>None when attribution was not available, e.g. a login for a user that does not exist
tenant_idOption<TenantId>None when the tenant could not be identified
session_idOption<SessionId>session-related events
factor_kindOption<FactorKind>which factor, where one is involved
device_idOption<DeviceId>omitted from the wire form entirely when absent
ip_addressOption<IpAddr>typed, so a forged or malformed value cannot be stored. None where nothing trustworthy resolved
ip_sourceSourcehow the address was arrived at: unknown, peer, forwarded or supplied
user_agentOption<String>from the request the context was extracted from
request_idOption<String>the typed value the request-id layer left behind, under the request-id feature; None without it
trace_idOption<String>the W3C trace id, under the trace-id feature
geo_countryOption<String>ISO 3166-1 alpha-2, derived from the IP
errorOption<AuthFailureReason>why it failed, as a tag a dashboard can group by. Not the place to look for the outcome; that is event_status

Every event carries an AuditContext, because the methods that write one live on RequestAuthnService and the only way to hold a RequestAuthnService is to have supplied a context. What the context contains is a separate question: axess cannot resolve a trustworthy client address by itself, since that needs the TCP peer and your trusted-proxy set. Install axess::client_ip::layer and the address is there; leave it out and ip_address is None with ip_source = 'unknown' saying so, which is the query below.

Store user_id and tenant_id as nullable columns. Both are legitimately absent, and a schema that requires them will reject exactly the rows a SOC most wants: the ones where the attacker was not who they claimed.

SOC alert thresholds

The events are designed to feed SOC (Security Operations Center) alerting. The thresholds below are starting points; tune to the specific deployment.

Rules match on the pair, not on a type alone, because the outcome lives in event_status.

factor_verified + failure from a single source IP above one per second indicates brute-forcing. The per-IP lockout (see Multi-tenancy §"Three-lever lockout") catches the worst cases at the application layer; the SIEM alert covers the rate even when individual attempts stay under the lockout threshold.

Any locked status at all is worth reviewing. A legitimate user occasionally mistypes and trips a lockout, so a handful per day across a deployment is normal. A spike is credential stuffing or a misconfiguration. The status is the whole filter here: login_attempt and factor_verified both produce locked rows.

device_fingerprint_mismatch is a stolen cookie being replayed until proven otherwise. Above a few per hour, either the fingerprint inputs are too strict (calibrate them) or it is real. Your DeviceResolver emits these, so the rate depends on what you chose to fingerprint.

impersonation at any rate should be attributable to a named operator and a ticket. It is the one event where a low rate is not reassuring on its own.

Cedar denies are not audit rows. Alert on the tracing target axess::authz::decision where decision = "deny". A spike is either a policy denying what it should permit, or someone probing for a privilege-escalation hole. The same target carries latency_us, so the rule that alerts on denials can also alert on evaluation getting slow.

Workload-identity refusals, rate-limit rejections and delegated-consent moments produce no events today. If you need them, emit your own from the glue that performs them.

SIEM query patterns

Axess does not define the table. IdentityStore::record_event is yours, so the column names below are the struct's field names on the assumption you stored them as-is; rename to match your schema.

Two things trip people up. The values are the wire strings, lower-snake-case (login_attempt), not the Rust variant names. And event_time is epoch microseconds as a signed integer, not a timestamp type, so it needs converting before any date function touches it.

These queries assume the request path wired an AuditContext and that client_ip::layer is installed. Without either, ip_address is NULL and the IP-keyed queries below return one useless bucket.

Ask the data rather than waiting to notice:

-- Rows whose address nothing resolved. Should be zero.
SELECT event_type, COUNT(*)
FROM auth_events
WHERE ip_source = 'unknown' AND event_time > <recent>
GROUP BY event_type;

A non-zero count names the routes to fix, and ip_source distinguishes the two causes that look identical in ip_address: unknown means nothing resolved an address, peer on a deployment that runs a proxy means the trusted set does not name it. Run it once after wiring and again after any route is added.

Until 0.7.0 there was also an AuditContextPolicy::Required, which failed the login instead. It checked whether a route attached a context at all, never how much that context contained, so a context carrying no address satisfied it: it never meant "no authentication without evidence", only "no route that forgot the wiring". That is a compile-time property, and 0.7.0 checks it at compile time by putting the authenticating methods on a type you cannot construct without a context. The query above is what remains, and it answers the case the policy never could: a route that attached a context while the layer was missing.

-- Brute-force: top failing source IPs per minute.
-- The outcome is in event_status; event_type alone does not say it failed.
SELECT
    ip_address,
    DATE_TRUNC('minute', TO_TIMESTAMP(event_time / 1000000.0)) AS minute,
    COUNT(*) AS failures
FROM auth_events
WHERE event_type = 'factor_verified'
  AND event_status = 'failure'
  AND event_time > EXTRACT(EPOCH FROM NOW() - INTERVAL '1 hour') * 1000000
GROUP BY 1, 2
ORDER BY failures DESC
LIMIT 20;
-- Lockouts in the last day. Filter on the status, not on a string in
-- `error`: both login_attempt and factor_verified produce locked rows.
SELECT
    user_id,
    COUNT(*) AS lockouts,
    MAX(event_time) AS last_lockout
FROM auth_events
WHERE event_status = 'locked'
  AND event_time > EXTRACT(EPOCH FROM NOW() - INTERVAL '1 day') * 1000000
GROUP BY user_id
ORDER BY lockouts DESC;
-- Cookie replay: a fingerprint mismatch followed by a trust grant
-- within the hour. Both events come from your DeviceResolver, so this
-- returns nothing unless you emit them.
SELECT
    mismatch.user_id,
    mismatch.device_id,
    mismatch.event_time AS mismatch_at,
    granted.event_time AS granted_at
FROM auth_events mismatch
JOIN auth_events granted
    ON mismatch.device_id = granted.device_id
WHERE mismatch.event_type = 'device_fingerprint_mismatch'
  AND granted.event_type = 'device_trust_granted'
  AND granted.event_time > mismatch.event_time
  AND granted.event_time < mismatch.event_time + 3600 * 1000000;

Authorisation denials are not in this table. They are tracing events on axess::authz::decision; query them wherever that subscriber writes.

The queries assume a SQL-shaped SIEM (Splunk SPL, Sumo Logic LogReduce, ClickHouse). Adapt to the deployment's chosen tool.

Extending the catalogue

You cannot add an AuthEventType. It is a closed enum in axess-core, and the wire strings are a contract the crate's own tests pin, so a new variant is an axess change and a breaking one.

What you can do is own the sink. IdentityStore::record_event is yours to implement, and every axess event arrives there before it goes anywhere. A domain event of your own (a fund transfer, a configuration change, a sensitive read) can be written to the same table, through the same connection, in the same transaction if you want it atomic with the thing it records. Axess leaves you to it, because it has no envelope type and no payload trait, and it ships no audit transport.

The trade-off is schema. An event type your SIEM does not know about feeds no dashboard and fires no alert, so agree the shape with whoever owns the SIEM before you start writing rows.

Answering an auditor

The catalogue is what makes a deployment answerable to an auditor: who authenticated, when, from where, and what was refused. The events are typed, the wire strings are stable, and the sink is the deployment's own, so the trail lives wherever the rest of the compliance evidence already lives.

Be clear-eyed about the edges. Authorisation decisions go to a tracing target rather than the audit trail; workload identity, rate limiting, delegated access and tenant provisioning emit nothing; and six of the vocabulary's names are for glue you implement, so they only appear if you emit them. Audit pipeline covers how events flow from the application to storage.

Further reading

Audit pipeline covers the dual-stream architecture (regulatory plus analytics), the hot/cold retention tiering, and the reliability story for the asynchronous dispatch. Multi-tenancy covers the tenant-scoped routing of events. Cedar policy fundamentals covers the policy evaluator, whose decisions go to the axess::authz::decision tracing target rather than to an audit row. Security posture covers the GDPR and PCI-DSS posture for audit-event PII.

Audit pipeline

The audit pipeline is what moves events from the authentication hot path to the storage layers that compliance, incident response, and operations consume. The pipeline has two streams (regulatory and analytics), three retention tiers (hot, archived, deleted), and a small number of trait surfaces that adopters implement against their own storage.

The chapter pairs with Audit events, which catalogues what flows through the pipeline; this chapter covers how the flow itself works.

The dual stream

Two audiences read the audit trail. Compliance auditors want completeness, immutability, and unambiguous provenance; they will accept slow queries and rigid schemas in exchange. SOC and operations teams want low query latency, flexible aggregation, and enrichment with operational context (geo lookups, ASN data, parsed user-agent strings); they accept some loss of fidelity and some divergence from the wire format in exchange.

The two requirements conflict. A single store optimised for one audience disserves the other. The pipeline's answer is to fan out: the same event flows into two streams, each shaped for its audience.

The regulatory stream uses AuthEvent directly. The shape is exactly what the catalogue in Audit events describes: stable fields, no enrichment, byte-for-byte uniform across deployments. The stream feeds the regulatory store, which is typically a database or a log archive with strong durability and immutability guarantees.

The analytics stream uses RichAuthnEvent, a denormalised wrapper that adds optional enrichment fields (device trust level, geo lookup, parsed user-agent, ASN, configurable tags). The deployment populates whatever of them it wants when it builds the RichAuthnEvent around the AuthEvent; axess defines the shape so downstream consumers can be shared between adopters, and does not populate the fields for you. The stream feeds the analytics store, which is typically a columnar database (ClickHouse, DuckDB) or a streaming platform (Apache Iggy with rkyv).

       the authentication operation
                    │
                    ▼  awaited, once
           IdentityAuthnLog::record_event        <- you implement this
                    │
       ┌────────────┴────────────┐               <- and everything below
       ▼                         ▼
  AuthnAnalyticsSink        AuditArchiver
  (RichAuthnEvent)          (cold tier)
       │                         │
       ▼                         ▼
  analytics store           archive store

Axess does not fan out. It calls record_event and awaits it; whether that write also feeds an analytics stream or an archive is decided by the implementation you supply.

Reliability, and who owns it

Axess does not buffer, queue, retry or fan out audit events. It builds the AuthEvent on the authentication path and awaits one call: IdentityAuthnLog::record_event. That is the whole of the pipeline axess ships, and everything after it is the implementation you provide.

Two consequences follow, and they are the reason to read this section before choosing a sink.

The write is on the hot path. Whatever record_event does, the login waits for it. A sink that talks to the network over a slow link makes logins slow. If you want the latency off the request, buffer inside your own implementation: return as soon as the event is durable somewhere you trust, and dispatch onward in the background. That is a choice axess deliberately leaves to you, because the right answer depends on whether losing an event is worse than slowing a login, and only the deployment knows that.

A failed write fails the login. Since 0.6.0, record_event returning an error surfaces as AuthnError::Store (and on OAuth paths as OAuthError::AuditStore), and the authentication does not complete. An authentication that leaves no evidence has not, for evidence purposes, happened. Earlier versions logged the error and continued, which meant an audit outage silently produced authentications nobody could later account for.

This trades availability for evidence, and the trade has a sharp edge: logins fail while your audit store does. Put the sink behind something durable: write locally, ship onward in the background rather than a remote service on the request path, and page on AuthnMetrics::audit_store_outage.

To drop an event without failing the login, return AuditOutcome::Shed. That is the valve for the hazard this creates: every failed login writes a row, including for identifiers that do not exist, so an unauthenticated caller can drive writes at your storage without bound, and exhausting it would otherwise fail every login for every user. A shed event continues the flow and fires AuthnMetrics::audit_event_shed.

Shed on a criterion independent of the identifier: a global rate, a queue depth, a disk watermark. Shedding based on anything derived from which identifier was tried makes the drop observable per identifier, and rebuilds the user-enumeration oracle that emitting unattributed events exists to close.

axess-events has two wrappers worth knowing, and both are for the analytics stream: they implement EventSink, not IdentityAuthnLog. LogAndSwallow turns a sink's errors into log lines, which is the fail-soft shape written once, and NoopEventSink discards everything, which is what tests want when the stream is switched off.

Neither can be wrapped around the regulatory sink, and that is deliberate. Swallowing an IdentityAuthnLog error is exactly the fail-open this release removed; the way to drop a regulatory event is AuditOutcome::Shed, which is explicit and counted.

The two sinks

One is the record; the other is the stream you can drop.

The IdentityAuthnLog sink

The regulatory sink is the IdentityAuthnLog implementation the application already provides for the lockout policy (covered in Identity store implementation). The pipeline writes events to this sink as the canonical record. The sink's storage backend is the application's choice; the typical pattern is a Postgres or MySQL table with append-only writes and an index on (user_id, tenant_id, timestamp) for the lockout-policy queries.

The pattern means the regulatory store is what the application already needs for lockout. The pipeline does not add a second database; it just uses what is already there.

The AuthnAnalyticsSink

The analytics sink is the optional stream for the SIEM and analytics consumers. The trait:

pub trait AuthnAnalyticsSink: Send + Sync + 'static {
    type Error: std::error::Error + Send + Sync + 'static;

    fn record_rich(
        &self,
        event: RichAuthnEvent,
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    /// A stable name for this sink, used in log lines and metrics.
    fn name(&self) -> &'static str;
}

The sink is a fire-and-forget dispatcher. A failed dispatch is logged and dropped; the buffer's retry semantics handle the transient cases. The implementations the audit-archive-fs feature provides cover the filesystem case; for streaming or columnar stores, the implementation is the application's.

A typical Apache Iggy implementation:

struct IggyAnalyticsSink {
    client: IggyClient,   // from the `iggy` crate
    topic: String,
}

impl AuthnAnalyticsSink for IggyAnalyticsSink {
    type Error = MySinkError;

    async fn record_rich(&self, event: RichAuthnEvent) -> Result<(), Self::Error> {
        let bytes = rkyv::to_bytes::<_, 256>(&event)?;
        self.client.send(self.topic.clone(), bytes.to_vec()).await?;
        Ok(())
    }

    fn name(&self) -> &'static str {
        "iggy"
    }
}

The rkyv serialisation is the recommendation. RichAuthnEvent derives rkyv::Archive, rkyv::Serialize, and rkyv::Deserialize, which produces a wire format that is significantly more compact than JSON, much faster to serialise, and zero-copy on the deserialise side. For a stream that pumps millions of events per day, the difference is operationally meaningful.

A ClickHouse implementation is the equivalent for batch shipping: the sink accumulates events in memory until a threshold (batch size or time interval), then issues a bulk insert. The pattern matches ClickHouse's preferred ingestion shape.

Retention and archival

How long rows live, and what moves them off the hot store.

The three-tier retention

The regulatory stream's events grow without bound by default. A deployment with millions of users produces hundreds of millions of events per year; the storage cost and the query cost both trend up unless the deployment manages the retention.

The retention story has three tiers, with explicit transitions between them.

The hot tier is the live authn_attempts table (or whatever the regulatory sink writes to). Events stay in the hot tier for as long as they are operationally useful: the lockout policy's last_attempts query, the SIEM's recent-events dashboards, the incident-response window. The recommended hot retention is between 7 and 90 days, with 30 days as a sensible default for most deployments.

The archived tier is a cheaper, slower store that holds events for the compliance retention period. The data is the same; the access pattern is different. Queries against the archive are slower (typically minutes rather than milliseconds) and less flexible (no indexed lookup; full-scan reads against a known date range). The archive is the answer to "show me everything that happened to this user three years ago." The retention here is set by the regulatory regime: PCI-DSS asks for one year; banking regulations ask for seven years; HIPAA asks for six years. Configure to match.

The deleted tier is what comes after the archive expires. The events are removed entirely and the underlying data is gone. Record the deletion itself somewhere durable, with the date range and the count: axess has no event for it, and an expiry nobody wrote down is indistinguishable from a gap. Some deployments never reach this tier (an indefinite archive is a defensible choice for small-volume deployments); others rotate through it on the regulatory schedule.

AuditArchiver

The transition from hot to archived runs through the AuditArchiver trait:

pub trait AuditArchiver: Send + Sync + 'static {
    type Error: std::error::Error + Send + Sync + 'static;

    fn archive_batch(
        &self,
        events: &[AuthEvent],
    ) -> impl Future<Output = Result<(), Self::Error>> + Send;

    /// A stable name for this archiver, used in log lines and metrics.
    fn name(&self) -> &'static str;
}

The trait has one write verb and a name. archive_batch takes the events by slice, so an implementation that streams them does not force an allocation, and returns your own Self::Error.

There is no purge verb. Deleting from the cold store is the cold store's business (an S3 lifecycle rule, a partition drop, a retention setting on the object bucket), and it is usually configured where the storage lives rather than driven from the application. An archiver that wants to purge on a schedule does it inside its own implementation. Axess never calls it.

The pipeline runs an AuditRetentionLoop<S, A> (S is the source IdentityAuthnLog, A is the archiver) that drives the transitions on a configurable schedule:

let retention_policy = AuditRetentionPolicy {
    archive_after: Duration::from_secs(30 * 86400),   // 30 days; default is 90
    purge_hot_after_archive: Duration::from_secs(7 * 86400),
    delete_archive_after: None,                       // never purge archive
};

let loop_handle = AuditRetentionLoop::new(
    retention_source,
    my_archiver,
    retention_policy,
)
.with_tick_interval(Duration::from_secs(3600))  // the default
.with_batch_size(10_000)                        // the default
.spawn();                                       // JoinHandle<()>

The loop ticks once per tick_interval, hourly by default, archiving up to batch_size events per tick. Each tick reads the hot-tier events that have aged past archive_after, hands them to the archiver, and purges the hot rows whose archive copy was made more than purge_hot_after_archive ago. spawn returns a JoinHandle; the loop runs until that handle is dropped. tick is public too, for a deployment that would rather drive it from its own scheduler and read the RetentionTickReport each pass returns.

The delete_archive_after field is the optional final transition. None means the archive grows indefinitely; a configured duration means the archive itself is purged at that age.

The defaults (90 days hot, then the verification window before the hot row goes, and no archive deletion) are conservative for finance. delete_archive_after: None is the right default for most adopters, because regulators penalise premature deletion far more harshly than they reward storage savings. PCI-DSS asks for one year of audit retention, which the defaults satisfy by keeping events in the archive indefinitely. Other regulatory regimes have different requirements; tune to match.

Filesystem archive

The audit-archive-fs feature ships FilesystemAuditArchiver, a reference implementation that writes archived events to a day-partitioned JSONL directory:

/var/lib/axess/audit/
    YYYY-MM-DD.jsonl
    YYYY-MM-DD.jsonl
    YYYY-MM-DD.jsonl
    ...

Each file is append-only, fsynced per batch, and contains newline-delimited JSON-encoded events. The format is readable by standard tools (grep, jq, awk), survives forensic investigation, and lifts cleanly into cloud object storage when the deployment moves the archive there.

The reference implementation is for deployments with straightforward audit-storage needs. Larger deployments typically use S3 (with object-lock for immutability), GCS (with retention policies), or a dedicated audit-log service (Splunk, Datadog, SumoLogic). The trait surface is the same; the implementation is the deployment's.

Backpressure and tenant isolation

Since the buffer is yours, so is the backpressure. Worth deciding before a busy tenant decides it for you.

AuthEvent carries tenant_id, so a sink can route per tenant: its own buffer, its own retention, its own destination. That is what makes a per-tenant audit SLA real rather than a deployment-wide average, and it keeps one tenant's spike off another's stream. The cost is a configuration per tenant and the operational surface that comes with it.

A single shared sink with conservative behaviour is fine for most deployments, and is the sensible starting point. Reach for per-tenant routing when a contract names a number.

What axess will not do is stop authenticating because the audit is behind. It has no policy to drop, block or shut down on a full buffer, because it has no buffer. If you need a fail-shut posture, see Reliability above.

From events to a defensible trail

The pipeline is what turns axess's audit events into a defensible production audit trail. The dual stream serves the two audiences; the buffer absorbs latency without blocking the hot path; the retention tiers balance storage cost against query needs and regulatory requirements. The mechanism is small (a handful of traits, one fan-out, one retention loop), and the configuration is the deployment's lever for tuning to specific requirements.

Further reading

Audit events catalogues what flows through the pipeline. Identity store implementation covers the regulatory sink (the IdentityAuthnLog trait). Multi-tenancy covers the per-tenant configuration patterns. Security posture covers the GDPR posture for archived audit data and the PII fields that may need scrubbing before archive.

Rate limiting

A rate limiter is the layer that caps how many requests an identified caller may make per unit time. For an authentication surface, the rate limiter is one of the most consequential pieces of operational defence in depth: the lockout policy catches the specific case of failed credentials, but the rate limiter catches the broader case of brute-force and credential-stuffing distribution. This chapter covers the RateLimitLayer Tower middleware, the key-extraction strategies that determine what is rate-limited, the tuning patterns for different endpoints, and the SLI signal the layer produces.

Why rate limiting matters

The lockout policy in Multi-tenancy catches one specific pattern: many failures against one identifier. A rate limiter catches a wider pattern: a high volume of requests against an endpoint, regardless of identifier, regardless of success.

The shapes of attack the rate limiter catches:

Credential stuffing. An attacker with a list of credentials tries each one against the login endpoint. Each individual attempt fails on its own credentials (no lockout against any single user), but the aggregate rate is far above legitimate traffic. The rate limiter on the login endpoint, keyed by source IP, drops the attack to a trickle.

Account-existence enumeration. An attacker probes the signup endpoint to find which usernames are taken. Each request might succeed (the username is unique) or fail (the username is taken), and the response leaks the information. The rate limiter caps the enumeration rate; combined with response-shaping (return the same shape for both cases), the attack becomes impractical.

Token-replay forwarding. An attacker who has captured a valid session cookie forwards it through many connections to evade fingerprint detection. Each request looks legitimate on its own; the aggregate volume is the giveaway. The rate limiter keyed by session id catches the pattern.

Workload misbehaviour. A workload that for some reason has entered a tight loop calling your API. The authentication side validates the workload token on each request; the rate limiter catches the runaway pattern before it overwhelms the service.

Configuring the limiter

What to install, what it counts requests against, and how to size it.

The layer

RateLimitLayer is a Tower layer with a small configuration:

use axess::{RateLimitLayer, RateLimitConfig, KeyExtractor};
use std::time::Duration;

let layer = RateLimitLayer::new(
    RateLimitConfig::builder()
        .max_requests(10)
        .window(Duration::from_secs(60))
        .key(KeyExtractor::PeerIp)
        .build(),
);

The configuration says "no more than ten requests per minute, keyed by the peer IP." The layer counts requests against each distinct peer IP; when a key has hit the limit within the window, subsequent requests get a 429 (Too Many Requests) with a Retry-After header.

The window is a sliding token bucket. The math: each key has a bucket of max_requests tokens; each request consumes one; tokens regenerate at a rate of max_requests per window. A burst of more than max_requests requests within a short interval consumes all the tokens; subsequent requests are rejected until enough tokens have regenerated.

The buckets live in this process, in a DashMap, and there is no option to put them anywhere else. That is a deliberate scope line rather than a missing backend: with N instances behind a load balancer, each one enforces the configured limit independently, so the effective limit is N times what you configured, and a caller that spreads its requests gets N times the budget.

Distributed rate limiting belongs at the gateway, against a shared store (Valkey, Redis, or whatever your ingress already offers). Use this layer for the per-process defence: the burst that would exhaust a connection pool, the per-username budget that keeps a lockout attack from succeeding. Set the volumetric limit upstream.

Key extraction

The key is what the rate limiter counts against. The KeyExtractor enum carries the choices:

pub enum KeyExtractor {
    ClientIp,          // what client_ip::layer resolved. Behind a proxy.
    PeerIp,            // SocketAddr from ConnectInfo. The default.
    UserId,            // RateLimitUserId request extension
    TenantId,          // RateLimitTenantId request extension
    LoginIdentifier,   // RateLimitLoginIdentifier request extension
    Header(String),    // an arbitrary header, e.g. an API key
}

The three extension-backed variants read a value you put into the request extensions, which is what makes them composable with any authentication scheme: axess does not need to know how you established the user, only that you named it. Set the extension in a layer that runs before the rate limiter.

There is no custom-closure variant and no composite. One limiter keys on one thing; layering two concerns means stacking two RateLimitLayers, which is also what makes each one's configured budget legible on its own.

The choice of key determines which attack the limiter catches. PeerIp catches single-source volumetric attacks and is right where nothing sits in front of you. ClientIp is the one to use behind a proxy: it reads the address client_ip::layer resolved and consults no header, so a caller cannot pick its own bucket.

Configuring that layer means naming the proxies you trust, and usually you can: managed front ends publish their ranges, and a sidecar is loopback. Where the list is the weak link rather than the check, because a rotating range has to be kept synced and a stale one quietly resolves every request to the proxy, TrustedProxies::private_transport() asserts the other true thing instead: nothing reaches this process except through your own infrastructure. That assertion does not go stale. It is the weaker of the two, and a direct caller reaching a process configured that way has its headers believed in full, so check that the listener is not reachable rather than assuming it. It replaced ForwardedIp in 0.7.0, which read X-Real-IP and then the leftmost X-Forwarded-For entry and believed both, which made rotating either a way to mint a fresh bucket per request. UserId catches a per-user runaway loop, TenantId a per-tenant one, which is as often a noisy neighbour as an attack.

LoginIdentifier is the one to read twice. Per-IP limiting alone does not defend a login route: an attacker spreading attempts across many IPs stays under every per-IP budget while hammering one username, and with a lockout policy in place that is a denial of service against that account rather than a break-in attempt. Keying on the submitted identifier, normalised to lowercase so Alice and alice share a bucket, bounds the attempts per account regardless of where they came from. Put a LoginIdentifier limiter on every login-class route, and layer a PeerIp one beside it for the volumetric case.

Per-endpoint rate limits

Different endpoints have different sensitivities. A login endpoint can tolerate a few requests per second per IP because real users do not log in fast; a search endpoint accepts hundreds per second because real users browse. The configuration shape is typically per-endpoint:

let auth_routes = Router::new()
    .route("/login", post(login))
    .route("/signup", post(signup))
    .route("/reset-password", post(reset_password))
    .layer(RateLimitLayer::new(
        RateLimitConfig::builder()
            .max_requests(10)
            .window(Duration::from_secs(60))
            .key(KeyExtractor::PeerIp)
            .build(),
    ));

let api_routes = Router::new()
    .route("/data", get(get_data))
    .layer(RateLimitLayer::new(
        RateLimitConfig::builder()
            .max_requests(300)
            .window(Duration::from_secs(60))
            .key(KeyExtractor::SessionId)
            .build(),
    ));

let app = Router::new()
    .merge(auth_routes)
    .merge(api_routes)
    .layer(session_layer);

The pattern is to layer the rate limit on the specific routes it applies to, with the most restrictive limits on the most sensitive endpoints. A login endpoint with a tight per-IP limit is the canonical case; a token-refresh endpoint with a per-session limit is the second canonical case.

The trusted-proxy configuration covered in Cookies, fingerprinting, hijack detection applies to the PeerIp extractor here as well. Read the IP from the forwarded header only when the immediate peer is a trusted proxy; otherwise the rate limiter can be spoofed.

Per-tenant rate limits

For multi-tenant deployments, the rate limit configuration can be per-tenant. A tenant with a higher SLA gets a higher rate limit; a tenant with a lower SLA gets a tighter one. The mechanism is the same RateLimitLayer, with a Custom key extractor that composes the standard key (typically PeerIp) with the tenant id, and with separate RateLimitConfigs per tenant tier.

The pattern is operationally complex (one configuration per tenant tier), so most deployments use a single shared limit and calibrate to the deployment-wide envelope. The per-tenant shape is for deployments where the SLA differences are explicit and the operational overhead is justified.

Tuning the windows

Tuning the rate limit is more art than science, but a few guidelines hold up.

For login endpoints: 10 requests per minute per IP is the conservative starting point. Real users log in at most a few times a day from any one IP. Credential-stuffing attacks need hundreds per minute to be efficient; 10 is well below that. Tune up only if the warn rate is too high on legitimate traffic (many users behind a corporate NAT, for instance).

For signup endpoints: 5 requests per minute per IP. Signup is even less frequent for legitimate users than login; account enumeration is best stopped tight.

For password reset: 3 requests per hour per IP. A reset is a once-in-a-while operation. Attackers spam reset to exhaust the victim's inbox; the tight limit is the defence.

For token refresh: matched to the session TTL. A session that refreshes every hour should have a rate limit of a few refreshes per hour per session id; an attacker who steals a session cannot extract value through rapid refresh.

For data endpoints: matched to your expected use pattern. An API for human-driven dashboards sees a few requests per minute per session; an API for programmatic clients sees hundreds per second per workload. The pattern is deployment-specific.

The default to start with is to measure first. The metrics from AuthnMetrics::rate_limit_rejected (covered below) tell you the real reject rate; the calibration is then to set the limit just above the legitimate-traffic envelope.

Behaviour at the limit

What a rejected caller sees, and how to read a spike.

What happens at the limit

A request that hits the rate limit gets:

A 429 status code. The standard HTTP response for "Too Many Requests."

A Retry-After header. The value is the number of seconds the client should wait before retrying. The header is read by browsers and well-behaved clients; attackers ignore it.

A short JSON body explaining the limit. The body is generic ("rate limit exceeded") rather than specific (no "you have 0 of 10 requests remaining"); the latter leaks the limit configuration, which lets an attacker calibrate their attack to just under the limit.

The application's metrics record the rejection. The AuthnMetrics::rate_limit_rejected method is the metric; applications wire it to their Prometheus or OpenTelemetry counter.

Distinguishing attack from misconfiguration

A high rate of 429s is operationally interesting. The cause is either an attack (real attacker getting throttled) or a misconfiguration (legitimate traffic hitting a limit that was set too low).

The signals that distinguish them:

A rate of 429s heavily concentrated on a small set of source IPs, with the IPs not matching legitimate user patterns (datacenter IPs, VPN exit nodes, residential ASNs from countries the application does not typically serve) suggests attack.

A rate of 429s spread across many IPs, matching legitimate user patterns (residential ASNs from served countries, mixed mobile and home connections), suggests misconfiguration.

The rate limiter emits no audit event. It rejects the request and returns, and nothing reaches the audit trail, so distinguishing these patterns means logging the rejection yourself with the source IP and the endpoint at the point you install the middleware.

Composing with the lockout policy

The rate limiter and the lockout policy are different defences that compose. The rate limiter catches volume; the lockout policy catches credential pattern. Both fire on attacks, in different shapes.

The pattern that emerges: the rate limiter is the first line of defence against credential stuffing. It drops the attack to a trickle before any individual user's lockout policy can fire. The lockout policy then catches the few attempts that get through, marking the targeted user accounts as locked.

A deployment that has rate limiting but no lockout policy is vulnerable to slow attacks that stay below the rate limit. A deployment that has lockout but no rate limiting is vulnerable to high-volume attacks that distribute across many users. Both together cover both attack shapes.

Operating it

What to watch, and where the limiter sits relative to everything else.

Metrics

The layer emits two metrics through the AuthnMetrics trait:

rate_limit_rejected is incremented on each 429. The metric is the primary signal for tuning and for attack detection.

rate_limit_evaluated (optional, off by default) is incremented on every request the layer sees, regardless of outcome. The ratio of rejected to evaluated is the reject rate; below 0.1% typically means the limit is set well, above 1% suggests either attack or misconfiguration.

The AuthnMetrics implementation is yours; it typically routes to Prometheus, OpenTelemetry, or whatever metrics system the deployment uses. The examples/sqlite/ reference application shows a simple AtomicU64-based implementation suitable for adapting to a real metrics system.

Where the limiter sits in the stack

The rate limiter is the operational layer that sits between "the request was sent" and "the authentication logic runs." A deployment without it is vulnerable to a class of attack that the authentication logic alone cannot prevent; a deployment with it has the broader defence against volume-based attacks that complements the credential-pattern defence of lockout.

Further reading

Multi-tenancy covers the lockout policy that pairs with the rate limit. Audit events covers the trail the rate limiter does not write to. Cookies, fingerprinting, hijack detection covers the trusted-proxy configuration that determines how PeerIp reads the source IP. Operations runbook covers the metrics dashboards and the SIEM rules that turn the rate-limit signal into alerts.

Security posture

What axess chooses for you, what it leaves to you, and what an auditor will ask about. Read it before launch, not after the questionnaire arrives.

Crypto backends

Axess uses RustCrypto for every primitive it implements itself, unconditionally: AES-256-GCM (the session envelope), HMAC-SHA256 (cookie signing, fingerprint binding), Argon2id (password hashing), TOTP and HOTP (RFC 6238 and RFC 4226), and SHA-256 (refresh token hashing). These are plain dependencies. There is no feature that swaps them for another implementation, and no cfg in the source that selects between backends.

The one backend an adopter chooses is for JWT signature verification, because jsonwebtoken takes its provider from a cargo feature and will not pick one for you. Anything enabling jwt names one of:

[dependencies]
# Pure Rust, builds anywhere. The default choice.
axess = { version = "0.7.0", features = ["jwt", "jwt-rust-crypto"] }

# aws-lc-rs: wraps the FIPS-validated aws-lc, needs a C toolchain
# (and NASM on Windows), and does not build on every target.
axess = { version = "0.7.0", features = ["jwt", "jwt-aws-lc"] }

Naming neither is a compile error. Naming both is allowed, because cargo can enable the second one when another crate in the build asks for it, and axess installs one provider rather than letting jsonwebtoken panic.

ring still appears in the dependency graph through TLS-adjacent crates (rustls and its consumers), not through axess's own code.

FIPS targeting

Axess does not today offer a FIPS-validated build. A deployment that needs one should read this section as a statement of the gap rather than a route through it.

The reason is the first of the three things a FIPS 140-3 deployment requires: every cryptographic operation must run through a validated module. Axess's own primitives are RustCrypto, which is not validated, and they are not switchable. The jwt-aws-lc feature routes JWT signature verification through aws-lc-rs, and that is the only operation it covers. The session envelope, password hashing, refresh-token hashing and HMAC fingerprint binding do not move with it.

The second requirement is that the compile and link chain introduces no non-validated crypto. Cargo's dependency graph is the source of truth: cargo tree and inspecting for non-aws-lc crypto crates (rustls, ring, the RustCrypto crates) shows what the deployment actually pulls in.

The third is that the validation certificate covers the platform the deployment runs on. NIST publishes FIPS validation certificates per platform-binary combination; a certificate for Linux x86-64 does not cover macOS ARM. The deployment's compliance evidence must include the certificate matching the production platform.

Closing the gap means making the remaining primitives selectable, which is a design change and not a feature flag. Raise it as an adopter requirement if you need it.

PII classification

The application records PII across several stores. The classification matters for GDPR (the data subject's rights), for SOC 2 (the control objectives), and for the retention sweep (Device identity's device_retention_days). The classification:

ClassWhatWhereRetention
PrimaryIdentifier (email, username), password hash, TOTP secret, FIDO2 credentials, the IP seen at authentication, device fingerprintIdentity store, device storeYours to choose, within whatever regulatory bounds apply
SecondaryThe audit-event log, which reaches the primary through user_id, tenant_id, device_id and client_ipAudit storeAudit pipeline. The usual GDPR pattern keeps it longer than primary PII but scrubs or hashes the IPs once the hot window closes
PseudonymousSession id, refresh-token hash, device id (a UUID that names no user)Session store, device storeLonger than primary PII, with no GDPR implication

Pseudonymous is a claim about the data on its own. Each of those values becomes PII the moment it is joined to the primary set, and the join needs access to the identity store, so that store's access control is what keeps the classification true.

The GDPR right-to-erasure verb is IdentityAdmin::delete_user, and what it does is your implementation's decision rather than a cascade axess runs. The trait states the contract: delete or irreversibly anonymise the user row, every factor config under AuthnScope::User, the refresh tokens, the persisted sessions, the password history, and any application rows whose retention basis was the consent now withdrawn. After Ok(()), get_user, find_user and account_status must report the user gone, and any in-flight session must fail its next is_valid check.

Its default body panics rather than returning an error, so a backend that never overrode it fails loudly the first time an erasure request arrives instead of reporting success and deleting nothing. The audit-event entries that reference the user are not removed (the audit trail is load-bearing for compliance); the user's identifier in the events is hashed to a pseudonymous token, which makes the events non-PII without losing the ability to correlate them.

Compliance touch-points

The deployment will face one or more of these regulatory frames. Axess does not provide compliance on its own; it provides the controls each framework requires. The touch-points:

FrameWhat axess gives youWhat stays yours
GDPR (EU data protection)The erasure verb above, audit retention configuration, IP scrubbing in the cold-tier archive, DeviceStore::sweep against your SweepConfig thresholdsData subject notices, the privacy policy, the legal basis for processing
SOC 2 (operational controls)The audit catalogue, the lockout policy against credential stuffing, session and refresh-token security, the operational metricsPolicy and procedure documentation
PCI-DSS (card data)Strong authentication for administrative access, audit retention of at least one year, session data encrypted at restThe cardholder data environment. Axess covers the authentication boundary into it, not the environment
HIPAA (US healthcare)Strong authentication for access to protected health information, audit retention of at least six years, session data encrypted at rest and in transitThe HIPAA-covered systems, on the same boundary split

One gap in that first column is easy to miss under SOC 2: every authentication decision produces an AuthEvent, but authorisation decisions go to a tracing target instead. They are not in the catalogue, so an evidence pipeline that reads only AuthEvent rows will not have them. Wire the target separately.

For the mechanism behind a specific control: Session lifecycle and crypto envelope for encryption at rest, Audit pipeline for retention, Refresh tokens and session continuity for refresh-token hygiene, Multi-tenancy for the lockout policy.

Failing closed

Two stores sit behind the login path, and what happens when each one is down is a decision rather than an accident.

The lockout counter is the first. record_failed_attempt is a write, and the read-replica split this library encourages puts reads on a replica and writes on the primary, so a primary outage leaves logins working and the counter dead. LockoutPolicy::on_counter_unavailable decides what happens then. It defaults to CounterUnavailable::Lock, which treats the attempt as locked and keeps brute force bounded. CounterUnavailable::Allow keeps those users logging in and leaves lockout disabled until the counter returns. Alert on AuthnMetrics::factor_counter_store_outage either way: under Lock it explains the support calls, and under Allow it is the only signal that a control is off.

The audit store is the second, and it fails closed with no switch. If IdentityAuthnLog::record_event returns an error, the flow returns AuthnError::Store and the login does not succeed. An authentication that leaves no evidence has not, for evidence purposes, happened, and a catalogue offered as SOC 2 or PCI-DSS evidence cannot be allowed to develop holes quietly.

The cost is availability, and it is not small: logins fail while the audit store does. Put the sink behind something durable. Write locally and ship asynchronously, so record_event only fails when a local write fails, rather than calling a remote service on the request path. AuthnMetrics::audit_store_outage fires on this path and should page rather than feed a dashboard, because every login is failing while it does.

One consequence is worth stating, because the obvious implementation gets it wrong. Every path that rejects a login emits before it returns, including the ones where the tenant or the identifier does not exist. That is what stops an audit outage from becoming a user-enumeration oracle: if only known users triggered an audit write, an attacker who could degrade the audit store would see Err for real accounts and an ordinary rejection for everything else, and could read off which identifiers are registered. Both paths write, so both fail identically.

The same change closes a blind spot that had nothing to do with outages: a credential-stuffing run against a list of addresses, none of which are registered, previously left no audit rows at all. Those attempts now appear as LoginAttempt / Failure with an error of unknown_tenant or unknown_identifier, unattributed or attributed to the tenant only.

Disclosure protocol

The vulnerability disclosure protocol lives in the canonical SECURITY.md at the repo root. The summary:

Vulnerability reports go through the private channel described in SECURITY.md (typically a security email or GitHub Security Advisories). Do not file vulnerabilities on the public issue tracker.

The maintainers acknowledge reports within a few business days and triage to a severity level. Critical and high-severity issues get a private fix in a security branch, a coordinated disclosure window, and a CVE if the issue warrants one. Lower severity issues fix in the normal development cycle.

Adopters are expected to keep their axess dependency current. Vulnerability fixes ship in the next patch release; the changelog notes which fixes are security-relevant. Deployments behind on patches accept the risk of the unfixed vulnerabilities.

Canonical SECURITY.md

The rest of this chapter is the canonical SECURITY.md from the repo root, included so the production checklist is in one place.

Security Policy

Reporting a Vulnerability

If you discover a security issue in Axess, please report it through GitHub's private vulnerability reporting (the Report a vulnerability button under the repository's Security tab) or by emailing security@gnomes.ch. Do not open a public issue.

Response targets (best-effort while the project is pre-1.0):

  • Acknowledgement: within 48 hours of report
  • Triage and severity assessment: within 7 calendar days
  • Critical / High fix: patch release within 7 calendar days of confirmation
  • Medium fix: patch in the next scheduled release (typically within 30 days)
  • Advisory: published via GitHub Security Advisory once a fix is available

Only the latest 0.x minor receives security patches. If you are on an older version, upgrade to receive fixes.

Using Axess Securely

Axess is a library for authentication and authorization. Its security depends on correct integration and configuration in your application.

Production integration checklist

Transport and cookies

  • Terminate TLS before Axess sees requests. All session cookies default to Secure; HttpOnly; SameSite=Lax.
  • Set an HSTS header (Strict-Transport-Security: max-age=63072000; includeSubDomains) at the reverse-proxy or application layer so browsers never downgrade to HTTP.
  • Use a cryptographically random 32-byte signing key loaded from a secrets manager. Never hard-code or re-use the all-zero example key.

CSRF

  • Mount CsrfLayer on state-changing routes. The shipped middleware implements signed double-submit cookie protection; CsrfConfig::new(signing_key) is the entry point.
  • SameSite=Lax (the cookie default) mitigates the most common vectors, but is not sufficient on older browsers or cross-site GET-triggered mutations; keep CsrfLayer engaged.
  • For API-only endpoints, validate Origin / Referer headers or use a custom request header as a CSRF defence in addition.

Session binding and hijacking

  • Enable session binding (e.g. UserAgentBinding) to detect cookie theft from a different browser/client.
  • Understand the trade-off: session binding raises the bar for opportunistic theft but does not protect against an attacker who copies the User-Agent string along with the cookie.
  • Consider combining with IP-subnet or TLS channel binding for higher-security environments.

Session registry and forced logout

  • If using a session registry for forced logout, guard all authenticated routes with registry validity checks; not just require_authn!; so suspended or force-logged-out users cannot continue using stale sessions.
  • Call suspend_user (which automatically invalidates registry entries) rather than updating store status manually.

Rate limiting

  • Apply per-IP rate limiting on login, factor verification, and OAuth callback endpoints using the built-in RateLimitLayer. Axess enforces per-user lockout, but distributed brute-force across many usernames requires IP-level throttling.

Recommended configuration for authentication endpoints:

use axess::{RateLimitLayer, RateLimitConfig, KeyExtractor};
use std::time::Duration;

// Tight limit for login / factor verification (5 attempts per 60 s per IP).
let auth_rate_limit = RateLimitLayer::new(
    RateLimitConfig::builder()
        .max_requests(5)
        .window(Duration::from_secs(60))
        .key(KeyExtractor::ClientIp)
        .build(),
);

// Separate, tighter limit for OTP verification (3 attempts per 60 s).
let otp_rate_limit = RateLimitLayer::new(
    RateLimitConfig::builder()
        .max_requests(3)
        .window(Duration::from_secs(60))
        .key(KeyExtractor::ClientIp)
        .build(),
);

let app = Router::new()
    .route("/login", post(login_handler))
    .route("/verify-totp", post(totp_handler))
    .layer(auth_rate_limit)
    // Or apply per-route:
    .route("/verify-email-otp", post(otp_handler))
    .route_layer(otp_rate_limit);
  • Rate-limit OTP verification endpoints separately; 8-digit email OTPs have 10^8 possibilities but a tighter window reduces feasibility further.

Trusted proxy and IP extraction

  • Wrap the router in axess::client_ip::layer with a TrustedProxies set naming your proxies, and read ClientIp wherever an address is needed. Nothing reads a forwarded header directly any more: there is no function that takes one and believes it.
  • Still configure your reverse proxy to strip client-supplied X-Forwarded-For and X-Real-IP. The trusted-proxy walk is defence in depth, not a substitute.

Rate limiting is required in front of login routes

  • Apply the rate-limit layer to every route that reaches begin_login. Axess does not enforce it inside the service, and since 0.6.0 every failed login, including for identifiers that do not exist, writes an audit row, while a failed audit write fails the login. Without a limit, an unauthenticated caller can drive unbounded writes at your audit store, and exhausting it turns every login into a failure for every user. Your sink can also shed under load by returning AuditOutcome::Shed, but shed on a global rate or watermark, never on anything derived from which identifier was tried, or the drop becomes a user-enumeration signal.
  • Alert on AuthnMetrics::audit_store_outage. It fires on exactly that path.
  • Put the audit sink behind something durable: write locally, ship asynchronously, rather than a remote service on the request path.

Identity store lookup latency

  • If your IdentityStore caches find_user or account_status, cache negative results on the same terms as positive ones. Axess equalises login response time between known and unknown identifiers using a fresh random id, which can never hit a cache; caching only real users inverts the timing signal and restores a user-enumeration oracle.

Session store selection

  • In-memory stores (MemorySessionStore, MemoryRefreshTokenStore) are for testing only. They use non-constant-time lookups and do not persist across restarts.
  • SQL stores (SqliteSessionStore, PostgresSessionStore, MysqlSessionStore) support optional AES-256-GCM encryption at rest via SqliteSessionStore::new(pool, SessionCrypto::new(key)); opt out only via the explicit ::plaintext(pool) constructor (dev/test only).
  • Valkey store supports AES-256-GCM encryption via ValkeySessionStore::new(client, key). Plaintext available via ::plaintext(client) for dev/test.
  • All encryption-capable stores support key rotation via SessionCrypto::with_previous_key(old_key); sessions encrypted with the previous key are transparently re-encrypted on the next access.

Content Security Policy

  • Set a Content-Security-Policy header on all HTML responses to mitigate XSS impact. At minimum: default-src 'self'; script-src 'self'; style-src 'self'.
  • Avoid unsafe-inline and unsafe-eval in CSP directives.

OAuth / OIDC

  • Register only HTTPS issuer URLs. Axess rejects http:// issuer URLs in discovery (localhost / 127.0.0.1 / [::1] exemption for dev).
  • Request the minimum scopes needed; avoid offline_access unless refresh tokens are required.
  • Validate that the OAuth redirect URI matches exactly; do not use wildcard patterns.

Social login (plain OAuth 2.0)

  • Prefer OIDC whenever the provider supports it. Reach for SocialProvider only for IdPs that explicitly don't (GitHub user login, Twitter/X, Discord, Reddit, Spotify, …).
  • Understand the weaker security model: identity comes from a userinfo HTTPS GET, not from a signed assertion. A compromised IdP can impersonate any of its users to your service; you accept that blast radius when you adopt the provider.
  • Keep PKCE on (the default). A handful of providers reject the extra parameter; SocialProvider::without_pkce is the opt-out and should be used sparingly.
  • Verify csrf_state echo on the callback before calling exchange_code; SocialProvider::mint_csrf_state produces a fresh value routed through the same injectable RNG as PKCE.

Workload identity

  • Pin the trust domain at resolver construction. Every shipped resolver (JwtSvidResolver, MtlsResolver, WorkloadResolver) accepts an expected TrustDomain and rejects tokens / certs whose synthesised WorkloadId lives under a different one; defense in depth against a confused-deputy where the JWKS or CA happens to be shared across trust domains.
  • For the generic WorkloadResolver, keep adopter-supplied claim mappers strict about which subject paths the application admits. The recipes in examples/workload-identity/ are templates, not policy.
  • When fetching SVIDs from a local SPIRE agent, use the spire-workload crate today; see docs/workload-identity/jwt-svid.md for the fetch-side recipe.

Dependencies

  • Regularly update Axess and its dependencies (cargo update).
  • Run cargo audit in CI to catch known vulnerabilities in the dependency tree.

Trusted proxy configuration (detailed)

Axess can extract client IP addresses from the X-Real-IP and X-Forwarded-For headers for audit logging and rate limiting. These headers are only trustworthy if your reverse proxy strips or overwrites them before forwarding, and if you use the trusted-proxy form described below.

If you don't run behind a trusted reverse proxy, these headers are user-controlled and any IP-based security decision (rate limiting, geo-blocking, audit trails) can be spoofed.

Configure your reverse proxy to:

  1. Strip incoming X-Forwarded-For and X-Real-IP from client requests.
  2. Set X-Real-IP to the immediate client address (TCP peer).
  3. Append X-Forwarded-For with the client address (for multi-hop chains).

Example for nginx:

proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

What axess does with these headers

Since 0.6.0 axess does walk the forwarded chain and does maintain a trusted-proxy allowlist. trusted.client_ip(headers, peer):

  1. Returns the TCP peer immediately if the peer is not itself a trusted proxy: an unproxied client's headers are never read.
  2. Otherwise joins every X-Forwarded-For field line in order (RFC 9110 §5.3 makes repeated lines one comma-joined list) and walks it right to left, skipping hops that are themselves trusted, returning the first address that is not.
  3. Stops at a malformed entry and returns the peer, rather than trusting past it.
  4. Reads X-Real-IP only when no X-Forwarded-For is present, and only when there is exactly one such line: more than one means something appended rather than overwrote, so the first is whatever the client sent.

Rightmost-untrusted is the only correct reading: X-Forwarded-For is append-only, so the leftmost entry is whatever the client chose to put there. Axess took the leftmost entry before 0.6.0, which was spoofable through a correctly configured proxy.

Build the allowlist with exact addresses or CIDR ranges:

use axess::client_ip::TrustedProxies;

let trusted = TrustedProxies::from_cidrs(["10.0.0.0/8", "2001:db8::/32"])?;
// or, for a same-pod sidecar:
let trusted = TrustedProxies::loopback_only();

A spec with bits set below the prefix is rejected rather than widened: 10.0.0.5/8 is an error, because it reads like one host and would mean sixteen million. Write 10.0.0.0/8 or 10.0.0.5/32.

Since 0.7.0 you do not usually call the walk yourself. client_ip::layer runs it once per request, outside every route, and hands each handler a ClientIp:

let app = axess::client_ip::layer(app, trusted);

ClientIp has a private field and the layer is the only thing that fills it, so no amount of header reading produces one. Everything that wants an address takes it from there: the audit context, the Cedar request context, the rate limiter's KeyExtractor::ClientIp, the device gate, and lockout.

There is no longer a function that reads a forwarded header and believes it. The untrusted forms were removed in 0.7.0: a name carrying its own warning was tried twice and lost both times, once to a consumer that moved seven call sites onto one because its documentation recommended it.

Feature inventory

The shipped security surface, grouped by area. Caveats live in the Notes column.

Authentication factors

FeatureNotes
PasswordArgon2id hashing with workspace-pinned parameters, per-user lockout, password-reuse history, plaintext zeroized after hash.
TOTP (RFC 6238)Constant-time comparison, last-step replay guard, SHA-1 / SHA-256 / SHA-512, 6–8 digit codes.
HOTP (RFC 4226)Counter advancement, zeroized secrets, same algorithm options as TOTP.
Email OTP8-digit default, Argon2-hashed codes at rest, TTL-bound, single-use.
FIDO2 / WebAuthnRegistration + authentication + clone detection + discoverable / passwordless. Per-ceremony UV / attestation policy waits on webauthn-rs 0.6 stable; see ROADMAP.md.
LDAP bindVerifier over ldap3 with TLS via rustls. Bind only; schema mapping is the application's responsibility.
mTLS factorX.509 certificate verification against a configured trust anchor; SAN URI extraction for SPIFFE / regular identity binding.
JWT bearerGeneric JWT verifier with JWKS rotation, iss / aud / exp / nbf / alg allowlist, clock-injected for DST.
Multi-factor chainsOrdered factor pipeline (FactorStep::AnyOf for choice steps), session state machine enforces sequencing at compile time.

Sessions

FeatureNotes
Session cookiesHMAC-SHA256 signed, Secure; HttpOnly; SameSite=Lax by default. Configurable via SessionLayer::with_secure / with_same_site.
Session bindingHMAC-keyed fingerprint (not a plain hash); UserAgentBinding + extension points for IP / TLS channel binding.
Session registry + forced logoutSessionRegistry::invalidate_user is error-observable and fail-closed; cooperates with suspend_user for combined identity + session revocation.
ID cyclingAutomatic on Guest→Authenticated transition (fixation defense) and on logout; explicit AuthSession::regenerate for app-defined privilege boundaries (MFA enrollment, password change, role grant). See docs/sessions/lifecycle.md.
Refresh tokensRotation with family revocation on reuse; integration with device-binding cascade.
In-memory storeTesting only; no persistence, no encryption, non-constant-time lookups.
SQLite session storeOptional AES-256-GCM via SqliteSessionStore::new(pool, SessionCrypto::new(key)). Key rotation via with_previous_key.
Postgres session storeSame encryption model. Recommended for multi-instance deployments. Validated against CockroachDB via the cockroach_compat CI job.
MySQL / MariaDB session storeSame encryption model. Tested against MySQL 8.x and MariaDB 10.5+.
Valkey session storeAES-256-GCM, key rotation, TTL-managed eviction.
Cross-backend Store<K, V> traitAll five backends implement it for adopters that want backend-agnostic dispatch via Arc<dyn Store<…>>.

Device identity

FeatureNotes
Three-stage trust ladderUnknown → Seen → Trusted (plus terminal Revoked); retention sweep demotes idle devices and purges revoked rows past the grace window.
Per-tenant fingerprint pepperStops cross-tenant fingerprint correlation; TenantPepperResolver is adopter-provided.
Cascade revocationRefresh-token family compromise revokes every device that carried that family's binding.
CachedDeviceStore decoratorLRU + clock-driven TTL eviction; revocation propagates through set_trust_level.
Five DeviceStore backendsMemory, SQLite, Postgres, MySQL / MariaDB, Valkey; surface-equivalent across SQL dialects + Valkey hash storage, optional AES-256-GCM envelope on the bindings blob (SQL backends).
Adopter-supplied store recipeDocumented contracts (tenant scoping, atomic save, hot-path sighting, required sweep) in docs/identity/device.md for adopters with non-shipped backends (DynamoDB, MongoDB, …).
PII tokenisationMemoryDevicePiiStore reference impl + adopter trait for the GDPR-scoped fields (label, last-seen IP).

Workload identity

FeatureNotes
Principal::{Human, Workload} unified abstractionSame ToCedarEntity bridge for both shapes; Cedar policies authorise across without branching.
JwtSvidResolverSPIFFE JWT-SVID spec adherence; mandatory spiffe:// URI in sub, trust-domain extracted and pinned.
MtlsResolverSPIFFE X.509-SVID over mTLS via leaf-cert SAN URI extraction.
WorkloadResolver<C, F, R>Generic JWT-bearer workload resolver covering GitHub Actions OIDC, Kubernetes service accounts, GitLab CI, Okta, Azure AD, Auth0, LocalIdP, and any other JWT-issuer via an adopter-supplied claim parser + mapping closure. Ready-made recipes for GitHub Actions + k8s SA ship in examples/workload-identity/.
Cloud STS exchangeaws-sts, gcp-wif, azure-fic adapters for exchanging federated workload identity for short-lived cloud credentials.
Outbound identityoutbound-oauth (axess as OAuth client with client_credentials / private_key_jwt) and outbound-mtls (axess presenting an mTLS identity to downstream services).

OAuth / OIDC ceremonies

FeatureNotes
Authorization Code + PKCEDiscovery, token exchange, nonce validation; HTTPS-enforced (localhost / 127.0.0.1 / [::1] exemption for dev).
Client CredentialsReal HTTP token exchange via OAuthProviderConfig.
Device Code (RFC 8628)Real HTTP; device endpoint configured via with_device_authorization_endpoint. Nonce-bindable per RFC.
Token refreshProvider-delegated refresh with audit logging.
FAPI 2.0 Baseline ProfilePushed Authorization Requests (PAR, RFC 9126), DPoP (RFC 9449), JARM, RP-initiated logout. Strict nbf enforcement on ID tokens with clock-injected validation.
Back-Channel LogoutJWT signature verified via cached JWKS; sid-based session invalidation.
Front-Channel LogoutGET handler with sid query parameter; shared SidMap with back-channel.
Plain-OAuth-2.0 social loginSocialProvider (gated on social, off by default) for IdPs that don't support OIDC (GitHub user login, Twitter/X, Discord, Reddit, Spotify, …). Weaker security model than OIDC; identity comes from a TLS-trusted userinfo endpoint, not from a signed assertion. Parallel types (SocialClaims vs IdTokenClaims) keep the distinction visible at the call site. PKCE on by default.
LocalIdpFixtureIn-process IdP minting workload JWTs against an in-memory RSA-2048 keypair + matching JWKS endpoint. RS256 + ES256, RFC 8414 discovery, multi-key rotation.

On-behalf-of (OBO)

FeatureNotes
delegated-storedRFC 6749 §4.1 Authorization Code + PKCE with persisted refresh token for long-lived offline access.
delegated-exchangeRFC 8693 Token Exchange for short-lived per-request exchange.
delegated-stored-encryptedEncryptedDelegatedCredentialStore<S, K> decorator wraps any delegated-credential backend with AES-256-GCM at rest.

Authorization

FeatureNotes
Cedar Policy engineRBAC + ABAC + ReBAC. AuthzStore orchestrates evaluation; ToCedarEntity bridges principals, resources, and contexts.
Layered policy bundleBase + overlay; adopters drop additional .cedar and .schema.cedar.json files into an overlay/ directory.
Procedural macrosrequire_authn!, require_partial_authn!, require_authz! guard handler functions at compile time.
Entity cachingEntityCache (in-process, default), MokaEntityCache, ValkeyEntityCache (cross-node). Asymmetric defaults: cache authz, not authn.

Middleware

FeatureNotes
CSRFSigned double-submit cookie; required for state-changing form posts.
Rate limitingToken-bucket via RateLimitLayer. KeyExtractor::{PeerIp, ClientIp} for direct vs proxied deployments; ClientIp reads what client_ip::layer resolved and no header.
Request IDX-Request-Id extraction + generation.
Trace IDW3C Trace Context (traceparent) propagation.
WebSocketRevocation-aware wrapper that closes connections on session invalidation.

Audit and observability

FeatureNotes
AuthEvent regulatory audit trailSix device-identity event variants + the full authn event surface.
AuthnMetrics17-method trait (counters + timers) with no-op defaults.
AuditArchiver + AuditRetentionPolicyHot / cold tiering with three-stage retention (90d / 7d / never defaults). FilesystemAuditArchiver reference impl behind audit-archive-fs.
AuthnAnalyticsSink + RichAuthnEventDenormalised analytics path parallel to the regulatory AuthEvent. serde + rkyv derives for Apache Iggy / ClickHouse / DuckDB / Snowflake.
TracingCaptureTest subscriber for asserting on emitted tracing events.

PII classification

Axess processes personal data as part of authentication. This section documents what the library logs, stores, and never touches; useful for GDPR Data Protection Impact Assessments and SOC2 evidence packages.

What axess logs (via tracing and AuthEvent)

FieldWherePurposePII?
user_idStructured log spans, AuthEventCorrelate events to accountsPseudonymous; opaque ID, not directly identifying
tenant_idStructured log spans, AuthEventMulti-tenant correlationNo
session_idAuthEvent, tracing spansSession correlationNo (random UUID)
IP addressAuditContext (extracted from headers)Geo/fraud detection, complianceYes; personal data under GDPR
User-AgentAuditContext, session bindingClient identification, hijack detectionIndirect; device fingerprint
event_typeAuthEventAudit trail (login, factor verified, logout)No
factor_kindAuthEventWhich factor was attemptedNo
success/failureAuthEventSecurity monitoringNo
request_idAuditContextRequest tracingNo

What axess stores in session data

FieldStoragePII?
user_id / tenant_idSession store (Memory / SQLite / Postgres / MySQL / Valkey)Pseudonymous
auth_stateSession storeNo
fingerprintSession store (HMAC hash)No (one-way hash)
customSession store (application-defined)Depends on application

What axess NEVER logs or stores

  • Plaintext passwords (only Argon2id hashes are stored; input is zeroized after hashing)
  • TOTP/HOTP secrets in logs (stored encrypted in FactorConfig, zeroized on drop)
  • Session cookie values
  • OAuth tokens (access, refresh, ID tokens); these stay in memory only during the exchange
  • PKCE verifiers, CSRF state tokens (cleared from session after use)

Recommendations

  • Encrypt at rest: pass a SessionCrypto::new(key) to the SQL session-store constructors (SqliteSessionStore::new(pool, crypto), same for Postgres / MySQL) or use ValkeySessionStore::new(client, key) so session data (which contains user_id) is AES-256-GCM protected. The explicit ::plaintext(pool) constructor opts out and is dev/test only.
  • Log retention: configure your log aggregator to retain auth events per your compliance requirements (MiFID II: 5 years; GDPR: minimize).
  • Right to erasure: deleting a user's sessions (SessionRegistry::invalidate_user) and database records satisfies GDPR erasure for axess-managed data. The custom session field is the application's responsibility.

Compliance framework mapping

GDPR

RequirementHow Axess addresses it
Lawful basis for processingApplication's responsibility. Axess processes only what the app sends.
Data minimizationSessions store only user_id, tenant_id, auth_state, and fingerprint (HMAC hash).
Right to erasureSessionRegistry::invalidate_user() + database record deletion.
Data protection by designAES-256-GCM encryption at rest, zeroization of secrets in memory.
Breach notificationApplication responsibility. Axess provides audit trail via AuthEvent.
DPA (Data Processing Agreement)Not applicable; Axess is a library, not a service.

SOC2

Trust service criteriaHow Axess addresses it
CC6.1; Logical access securityMFA, session binding, Cedar policy authorization
CC6.3; Access revocationSessionRegistry::invalidate_user(), session TTL
CC7.2; MonitoringAuthnMetrics trait (17 hooks), AuthEvent audit trail, tracing
CC8.1; Change managementApplication responsibility (CI/CD, version pinning)

PCI-DSS

RequirementHow Axess addresses it
8.3; MFA for admin accessMulti-factor chain support (password + TOTP/FIDO2)
8.6; Session managementSigned cookies, TTL, session binding, forced logout
3.4; Encryption of cardholder dataAES-256-GCM session encryption (session store, not card data)
10.2; Audit trailsAuthEvent records login attempts, factor verifications, logouts

HIPAA

SafeguardHow Axess addresses it
Access control (§164.312(a))MFA, Cedar RBAC/ABAC, session state machine
Audit controls (§164.312(b))AuthEvent audit trail with timestamps
Integrity controls (§164.312(c))HMAC-signed session cookies, AES-GCM encryption
Transmission security (§164.312(e))Application must terminate TLS; Axess sets Secure cookie flag

These mappings are informational. Compliance certification requires assessment of the complete application stack, not just the authentication library.

Supported Versions

We recommend using the latest release of Axess and actively maintained branches.

Disclaimer

Axess is provided as a library. While we strive for secure defaults, the overall security of your application depends on your usage and integration.

Further reading

Operations runbook covers the production-launch checklist (key rotation, multi-instance considerations, graceful shutdown). Audit events and Audit pipeline cover the audit mechanisms the compliance frames depend on. Migration guide covers cross-version upgrade paths, including security-relevant breaking changes.

Behind an mTLS terminator

Mutual TLS authenticates the client to the server at the transport layer, before your handler sees the request. The client presents an X.509 certificate during the TLS handshake, the server validates the certificate against a trust anchor, and the resulting connection carries a known identity. For service-to-service traffic between parties that own both sides of the connection, mTLS is the strongest practical authentication: there is no credential to phish, no token to leak, no replay window after the handshake.

A certificate identifies a machine. What you do with that depends on whether a machine is the whole answer.

Where the caller is a service, it is: Inbound: mTLS-SVID covers MtlsResolver, which reads a SPIFFE URI out of the validated leaf and produces a Principal::Workload, and the certificate is the subject.

Where the caller is a person at a kiosk or an internal admin host, the certificate says which machine and nothing about who is using it. This chapter is that case: where the certificate reaches your process from, how to gate on it, and what it leaves for the factor flow behind the gate to establish.

The feature flag is mtls (off by default), enabled with features = ["mtls"] on the axess facade.

Where the certificate comes from

The most important detail about an mTLS integration is that axess does not handle the TLS handshake. Axum sits behind a TLS terminator (rustls in process, or nginx, HAProxy, AWS NLB, or Cloudflare in front), and the certificate validation happens at the terminator. Axess receives the validated certificate as part of the request, extracts an identity from it, and proceeds.

The extraction is a Tower middleware the adopter wires in. The middleware reads the certificate from wherever the terminator put it:

  • For rustls in process, the certificate is in axum_server::tls_rustls::RustlsConnectInfo or an equivalent connector callback.
  • For nginx, the certificate is passed through as the X-SSL-Client-Cert header (the exact header is the deployment's choice).
  • For HAProxy, the convention is X-Client-Cert or similar.
  • For AWS NLB with TLS passthrough, rustls handles the validation; for AWS ALB with mTLS, the certificate is in X-Amzn-Mtls-Clientcert.

The middleware reads the certificate, validates that it came from a trusted source (the certificate must be present, the header must have arrived only from the trusted terminator, the deployment must not allow clients to inject the header directly), wraps the certificate chain in a PeerCertChain, and inserts it into the Axum request extensions:

use axess::federation::mtls::PeerCertChain;

async fn mtls_middleware<B>(
    mut req: Request<B>,
    next: Next<B>,
) -> Response {
    // `new` takes the chain leaf first, as the client presented it.
    // Cloning a `PeerCertChain` is cheap; it holds an `Arc<[..]>`.
    if let Some(chain) = extract_cert_from_terminator(&req) {
        req.extensions_mut().insert(PeerCertChain::new(chain));
    }
    next.run(req).await
}

The trusted-terminator check is the critical line. If the deployment accepts the certificate header from anywhere, an attacker who can reach your service directly (bypassing the terminator) can spoof any identity by setting the header themselves. The defence is to either configure your listener to listen only on a socket the terminator owns, or to gate the extraction on a token the terminator injects alongside the certificate.

The trust anchor

The certificate validation that the TLS terminator performs uses a trust anchor: a set of CA certificates the terminator considers authoritative. A client certificate is accepted only if it chains back to one of those CAs.

For service-to-service mTLS within an organisation, the trust anchor is typically the organisation's own internal CA. The CA issues certificates to known clients, the terminator trusts the CA, and the validation works on the closed set of certificates the organisation has signed.

For broader deployments (a partner integration where the partner runs their own CA), the trust anchor is the partner's CA or a short list of CAs, and the validation accepts clients signed by any of them.

For consumer-facing deployments where clients might use any certificate, mTLS is the wrong factor. Use OAuth or another flow where the client does not need to provision a certificate.

The gate

The certificate identifies the machine and the factor flow identifies the person. Run them as two separate things: a gate in front, an ordinary method behind.

The gate is your middleware, not an axess type. It reads the PeerCertChain the extraction layer inserted, decides whether this certificate may reach the login routes at all, and rejects the request before any handler sees it:

use axess::federation::mtls::PeerCertChain;

async fn require_org_certificate(req: Request, next: Next) -> Response {
    let Some(chain) = req.extensions().get::<PeerCertChain>() else {
        return StatusCode::FORBIDDEN.into_response();
    };
    let Some(leaf) = chain.leaf() else {
        return StatusCode::FORBIDDEN.into_response();
    };
    // Your policy: which CA, which CN or SAN, which expiry window.
    if !issued_by_org_ca(leaf) {
        return StatusCode::FORBIDDEN.into_response();
    }
    next.run(req).await
}

Behind that gate, the user authenticates with whatever method the tenant configures: password plus TOTP, a passkey, an OAuth provider. Only a provisioned machine reaches the login page, and only the right person finishes, which is the pairing a high-assurance admin interface wants.

Two consequences follow from the split. The certificate is not part of the authentication, so no audit row mentions it: record the gate's decision yourself if you need evidence that a particular machine was used. And the session is not bound to the certificate, so one issued behind the gate lasts until it expires whatever becomes of the certificate. Pair short session lifetimes with short-lived certificates where that matters.

Threat model

mTLS is robust against the standard authentication attacks: credential reuse, credential stuffing, password phishing, replay. The certificate is hard to steal without compromising the device that holds the private key, and a compromised private key is no easier to use than a compromised password (both require some attacker action and both can be revoked).

It is weak against three specific attacks.

Private-key theft from a compromised device. An attacker with full filesystem access to a client can copy the private key, install it on their own machine, and use the certificate. The defence is to store the private key on hardware the operating system protects (a TPM, a hardware security module, a smartcard) rather than in a file. Hardware-backed keys cannot be exported and survive even a full filesystem compromise.

CA compromise. An attacker who can issue certificates from a CA you trust can authenticate as anyone. The defence is operational: keep the issuing CA offline, use short-lived certificates so revocation is automatic, and monitor the CA's audit log. For service-to-service mTLS, a SPIFFE control plane handles this with rotating, short-lived certificates backed by an attested root.

Missing revocation. When a certificate is revoked (employee leaves, machine is lost), you need to know. The TLS terminator checks revocation through OCSP (the Online Certificate Status Protocol, which asks the issuer about one certificate), a CRL (a certificate revocation list, which the issuer publishes in bulk), or a short-lived-certificate strategy that lets revocation happen by expiry; an unchecked revocation lets the old certificate continue to work. The defence is to wire revocation checking at the terminator and to monitor the revocation lifecycle.

Troubleshooting

If the middleware never sees a certificate, the most likely cause is that the TLS terminator is not requiring client certificates. Some terminators require explicit configuration to request the client certificate at handshake time; others accept the handshake without a certificate and silently let the request through. Check the terminator's configuration.

If certificates are present but the CN extraction returns nothing, the certificate may use a SAN URI instead of a CN. Inspect the certificate (openssl x509 -in cert.pem -text) to see what fields are present. Updating the extraction to read the SAN URI is the fix; the structured-mapping pattern above is the right shape.

If the trust-anchor configuration accepts a certificate the application does not expect, the terminator's trust store may include a CA the deployment did not intend to trust. Check the terminator's CA-bundle configuration and remove anything that should not be there. Use a dedicated trust store for client certificates rather than reusing the server's general CA bundle.

Further reading

Workload identity overview covers the workload-side use of mTLS, where the certificate identifies a service rather than a human. Inbound: mTLS-SVID covers the SPIFFE X.509-SVID profile that is the standard shape for service-to-service mTLS today. Security posture covers the production crypto requirements that apply to mTLS deployments, including FIPS-routing notes for regulated contexts.

Operations runbook

For whoever carries the pager. Read the pre-launch checklist before launch; the rest is reference for when something is wrong at three in the morning.

The chapter has two halves. The first half is operational guidance specific to axess. The second half is the canonical OPERATIONS.md from the repo root, included so the deployment's runbook checklist is in one place.

Pre-launch checklist

The list below is the minimum an axess-instrumented deployment should clear before serving real traffic. Each item is covered in detail in another chapter; the list here is the inventory.

The session signing key is loaded from the deployment's secrets manager. The key is 32 bytes of cryptographic randomness, stable across process restarts. The development placeholder ([0; 32] from Getting started) is replaced.

The session envelope key is loaded the same way. The two keys are independent; one is for HMAC signing the cookie, the other is for AES-256-GCM encrypting the session payload at rest. Session lifecycle and crypto envelope covers the distinction.

The fingerprint pepper is loaded for the fingerprint binding. Each tenant has its own pepper, stored alongside the tenant record; Multi-tenancy and Cookies, fingerprinting, hijack detection cover the mechanism.

The session cookie has Secure=true set. TLS terminates at the edge; your service sees only HTTPS traffic; the cookie is only sent on HTTPS.

The trusted-proxy list is configured. The application reads the forwarded header (X-Forwarded-For or Forwarded) only when the immediate peer is in the trusted list. Without this, the fingerprint and the rate-limit keys can be spoofed.

The rate limit is configured on the login, signup, password-reset, and any other authentication-adjacent endpoints. The defaults from Rate limiting are starting points; calibrate to the deployment's legitimate-traffic envelope.

The lockout policy is configured (or the system default is accepted). The three levers (per-user, per-tenant, per-IP) all have explicit thresholds suited to the deployment's risk posture. Multi-tenancy §"Three-lever lockout" covers the configuration.

The audit pipeline is wired. The regulatory sink is the IdentityAuthnLog the lockout policy already uses; the analytics sink (if configured) is the deployment's SIEM connector. The retention loop is configured with the deployment's required retention period. Audit pipeline covers the full pipeline configuration.

The health check is wired. /healthz (or whatever the deployment chooses) queries the session store, the identity store, and the device store; the response is a JSON document that aggregates the per-component states. Operations runbook in the canonical SECURITY/OPERATIONS section covers the deployment expectations.

The metrics are exported. The AuthnMetrics trait is implemented; the metric values flow into Prometheus or OpenTelemetry; the dashboards cover the auth-attempt rate, the failure rate, the rate-limit rejection rate, and the lockout trigger rate. Operations runbook below covers the production-dashboard expectations.

The Cedar policy set is loaded and validated against the schema. The startup path refuses if the validation fails; a production launch with a misconfigured policy set never gets to serve traffic. Cedar policy fundamentals covers the validation flow.

The cleanup tasks are scheduled. The session cleanup, the device retention sweep, the audit retention loop, the OAuth JWKS cache refresh: all of these run on intervals; the scheduler is the application's responsibility. Backends §"SQLite" and similar sections cover the per-backend cleanup patterns.

Key rotation

The deployment has three keys to rotate on a schedule: the session signing key, the session envelope key, and the per-tenant fingerprint pepper. The mechanism is the same shape for all three: provide the new key alongside the old one for a transition window, let in-flight sessions and devices roll over, then remove the old key.

Session signing key

The signing key is what HMAC-protects the session cookie. Rotating it without invalidating sessions requires keeping the old key available for verification during the transition.

let session_layer = SessionLayer::new(store, new_signing_key)
    .with_previous_key(old_signing_key)
    .with_ttl(session_ttl);

with_previous_key accepts the old key. Cookies signed with the old key continue to validate; new cookies sign with the new key. After enough time for all old cookies to expire (one session TTL plus a safety margin), the previous key can be removed.

The rotation sequence:

  1. Deploy with new_signing_key = old_key and previous_key = old_key. Nothing has changed; this is the baseline.
  2. Generate a fresh 32-byte signing key. Store it in the secrets manager alongside the existing one.
  3. Deploy with new_signing_key = fresh_key and previous_key = old_key. New cookies sign with the fresh key; existing cookies continue to validate against the old.
  4. Wait one session TTL. By the end of this window, every existing session has either expired or been refreshed (which re-signs the cookie with the fresh key).
  5. Deploy with previous_key = None (or absent). The old key is now unused.
  6. Remove the old key from the secrets manager.

Session envelope key

The envelope key is what AES-256-GCM protects the session payload at rest. Rotating it without invalidating sessions is similar to the signing-key rotation, with the additional consideration that sessions stored before the rotation continue to be readable but new writes use the new key.

let crypto = SessionCrypto::new(new_envelope_key)
    .with_previous_key(old_envelope_key);
let store = SessionStore::new(pool, crypto);

The rotation sequence is the same as the signing key. The transition window covers one session TTL; after that, every stored session has been rewritten with the new key.

For deployments with long session TTLs (a week or a month), rotating the envelope key per the deployment's compliance cycle (quarterly, semiannually) requires the transition window to be at least the TTL. Alternative: a background scan that proactively rewrites stored sessions with the new key, finishing the rotation faster than the TTL would.

Per-tenant fingerprint pepper

The fingerprint pepper rotates per-tenant rather than globally. The mechanism is on the tenant record:

service.rotate_fingerprint_pepper(
    &tenant_id,
    new_pepper,
).await?;

The rotation invalidates every device record under the tenant. Existing sessions remain valid (they do not depend on the device record), but the next request from each user re-registers their device from scratch (transitioning the device to Unknown and walking the assurance ladder again). Users see no break; the device store sees a churn.

The pepper rotates on tenant suspension and on demand. The default cadence is annual; tighter cadences are appropriate for high-sensitivity deployments.

Multi-instance considerations

A deployment that runs multiple application instances behind a load balancer has a handful of considerations the single-instance deployment does not.

Shared session store. The session backend must be cluster-safe: Postgres, MySQL, or Valkey. SQLite is single-writer and works only for single-instance deployments. Backends covers the choices.

Shared signing and envelope keys. Every instance must use the same keys; otherwise an instance that issued a cookie cannot have the cookie validated by a different instance that receives the next request. The secrets manager is the source of truth; each instance pulls the keys at startup.

Shared rate-limit state. If the rate limiter is keyed by PeerIp and the buckets live in memory per instance, an attacker hitting all instances in parallel evades the limit. The fix is BucketStore::Valkey { client }, which moves the state to a shared Valkey instance; every application instance sees the same buckets.

Session affinity (sticky sessions). Optional, not required. The session is stored server-side; any instance can serve any session. Some deployments prefer sticky sessions to improve local cache hit rates; the trade-off is reduced resilience to instance failure.

Load-balancer-level fingerprint handling. The load balancer must forward the real client IP through X-Forwarded-For (or the load balancer's specific header). The application's trusted-proxy list must include the load balancer's IP range. Without this, every request looks like it came from the load balancer, and the fingerprint and rate-limit keys are useless.

Graceful shutdown

A graceful shutdown drains in-flight requests before stopping the process. The pattern in axess:

The process receives a SIGTERM (from Kubernetes, systemd, or whatever orchestrator). The application's shutdown handler sets a flag that tells the HTTP server to stop accepting new connections.

In-flight requests continue. The HTTP server is in draining mode; new connections get refused (which the load balancer treats as the signal to route elsewhere), existing connections complete their request.

The shutdown handler waits for the in-flight requests to complete, with a timeout (typically 30 seconds; long enough for real requests, short enough that a stuck request does not block shutdown forever).

The audit pipeline drains. The shutdown handler triggers the pipeline to flush its buffer to all sinks. The wait is bounded (typically 10 seconds); buffered events that do not flush in time are written to a local recovery log for the next process start to pick up.

The session store closes. The connection pool drains; in-flight queries complete; the pool releases its connections.

The process exits.

The pattern is what Axum's with_graceful_shutdown enables; the application wires the shutdown signal through the standard shutdown handler. No axess-specific code is needed beyond the audit-pipeline drain.

Health checks and metrics

A production deployment exposes /healthz and /metrics endpoints. The health check confirms your backends are reachable; the metrics expose the operational counters.

The health check pattern:

let health = Arc::new(
    CompositeHealthCheck::new()
        .add("session_store", session_store.clone())
        .add("identity_store", identity_store.clone())
        .add("device_store", device_store.clone())
);

async fn healthz(State(state): State<AppState>) -> impl IntoResponse {
    let status = state.health.check_all().await;
    let code = if status.is_healthy() {
        StatusCode::OK
    } else {
        StatusCode::SERVICE_UNAVAILABLE
    };
    let body = serde_json::json!({
        "status": if status.is_healthy() { "healthy" } else { "unhealthy" },
        "components": status.components,
    });
    (code, axum::Json(body))
}

Each backend that implements HealthCheck provides its own probe (typically a bounded SELECT 1 for SQL backends or a PING for Valkey). The composite aggregates the results; the endpoint returns 200 on all-healthy or 503 on any-unhealthy.

The metrics pattern:

async fn metrics_endpoint(State(state): State<AppState>) -> impl IntoResponse {
    let m = &state.metrics;
    axum::Json(serde_json::json!({
        "auth_attempts": m.auth_attempts.load(Ordering::Relaxed),
        "auth_successes": m.auth_successes.load(Ordering::Relaxed),
        "auth_failures": m.auth_failures.load(Ordering::Relaxed),
        "rate_limit_rejections": m.rate_limit_rejections.load(Ordering::Relaxed),
    }))
}

The metrics implementation (covered in AuthnMetrics trait) exposes the counters; the endpoint serialises them in whatever format the deployment's metrics system expects (Prometheus text format, JSON, OpenMetrics).

The dashboards the operational team uses combine these counters with the audit-event volumes from the SIEM. Audit events §"SIEM query patterns" covers the SIEM-side queries.

Common failures and remedies

The categories of failure that recur in production deployments, and the standard responses.

Spike in auth_failures: typically a credential-stuffing attack or a credential leak elsewhere. The rate limiter should be absorbing the bulk; the lockout policy catches the rest. Investigate the source IPs in the failure events; if the spike is concentrated on a small set of IPs, block them at the WAF; if it is spread broadly, the leak is the larger concern.

Spike in rate_limit_rejections: either an attack (real attacker getting throttled) or a misconfiguration (legitimate traffic hitting a limit too tight). Rate limiting §"Distinguishing attack from misconfiguration" covers the signals.

Health check failing on session store: the session backend is unreachable. Investigate the database. Until the backend is back, you cannot serve authenticated traffic; the load balancer treats the 503 as a signal to route around the instance.

Session cookie validation failing for known-good sessions: the signing key has changed without the previous-key transition. Add the previous key to the configuration; sessions will start validating again as soon as the deployment picks up the change.

Spike in DeviceFingerprintMismatch events: typically the fingerprint tolerance is too tight. Calibrate against the warn rate; widen the IP-prefix tolerance or the user-agent matching. Cookies, fingerprinting, hijack detection covers the tolerance configuration.

Logins slowing with no change in traffic: look at the audit sink. IdentityAuthnLog::record_event is awaited on the login path, so whatever it does, the login waits for it. A SIEM under maintenance behind a synchronous sink shows up as login latency, not as an audit alert. If that is a risk you carry, buffer inside your own sink implementation; Audit pipeline covers exactly how far axess goes here, and where you take over.

Logins failing in a burst with the store reachable: check AuthnMetrics::audit_store_outage before auth_failure. A failed audit write fails the login since 0.6.0, so an audit problem presents as an authentication problem. audit_event_shed rising instead means the sink is deliberately dropping events to protect itself: nothing is failing, but the trail has gaps and something is driving volume at you.

Canonical OPERATIONS.md

The rest of this chapter is the canonical OPERATIONS.md from the repo root.

Axess; Operations Guide

Deployment, key management, and operational procedures for production environments.

Key rotation (zero-downtime)

Session signing keys and encryption keys can be rotated without invalidating active sessions.

Signing key rotation

The signing key is the 32-byte master fed to SessionLayer::new(store, signing_key). HKDF-Expand derives two sub-keys from it: one for session-cookie HMAC and one for session-binding fingerprint HMAC. SessionLayer supports zero-downtime rotation via with_previous_signing_key(old_master); sessions signed under the previous master transparently re-authenticate and their cookies + stored fingerprints are re-issued under the new master.

Procedure:

  1. Generate a new 32-byte signing master in your secrets manager.
  2. Deploy with both masters wired: new as current, old as previous.
    let layer = SessionLayer::new(store, new_master)
        .with_previous_signing_key(old_master);
  3. On every request, the layer tries current-master verification first; on mismatch it falls back to the previous master. Fallback matches trigger:
    • A fresh Set-Cookie header signed under the current master.
    • An update to the stored session fingerprint (computed under the current master) so the next request hits the fast path without falling back.
  4. Watch for the "session cookie verified with previous (rotated) signing key" and "session fingerprint verified with previous (rotated) signing key" tracing::debug! events to gauge migration progress.
  5. After one full session-TTL window (the longest a legit cookie signed under the old master can still be in flight), retire the previous slot: either drop the with_previous_signing_key(...) call from the deployment, or call SessionLayer::remove_previous_signing_key() on the freshly-built layer (same "mutate before cloning" contract). SessionLayer::has_previous_signing_key() returns false after that.

Operational rule: only one previous slot. Plan rotations so at most one is in flight per session-TTL window. Rotating again while the previous slot is still populated overwrites it: the pre-first-rotation master falls out of the ring and its cookies fail both current and previous verification. This is the correct security behavior for emergency chained rotations (a compromised previous master must not remain valid) but it forces re-authentication for any session still holding a pre-first-rotation cookie. In the non-emergency case, wait one full session-TTL between rotations.

CSRF / adopter-derived sub-keys. Sub-keys derived from the session master via SessionLayer::derive_subkey(info) (e.g. an adopter's own CSRF signing key) do NOT get rotation-aware verify-with-fallback for free. The returned bytes come from the CURRENT master only. Adopters that rotate through derive_subkey must maintain their own previous-key state and implement their own try-current-then-previous verification. axess's own CSRF layer takes an independent key and is unaffected.

Encryption key rotation

SessionCrypto supports transparent key rotation via with_previous_key():

let crypto = SessionCrypto::new(new_key)
    .with_previous_key(old_key);

Procedure:

  1. Generate a new 32-byte encryption key in your secrets manager.
  2. Deploy with both keys: new as current, old as previous.
  3. Sessions encrypted with the old key are transparently re-encrypted with the new key on next access.
  4. After all sessions have been accessed (or after the session TTL expires), remove the previous key from the deployment.
  5. Monitor the "session decrypted with previous (rotated) key" log message to track migration progress.

Multi-instance deployment

Shared state requirements

ComponentSharing requirement
Signing keyMust be identical across all instances
Encryption keyMust be identical across all instances
Session storeValkey, PostgreSQL, or MySQL (shared). SQLite is single-instance only.
Session registryValkey-backed (ValkeySessionRegistry). In-memory is single-instance only.
OIDC sid_mapIn-memory per instance. Back-channel logout works when the IdP sends to the instance that handled the login. Use sticky sessions or a shared store for full coverage.
Rate limit bucketsIn-memory per instance. For distributed rate limiting, use an external solution (e.g. Valkey-based sliding window at the reverse proxy).

Health checks

Implement a /healthz endpoint using the CompositeHealthCheck trait:

use axess::{CompositeHealthCheck, HealthCheck, HealthStatus};

async fn healthz(State(health): State<CompositeHealthCheck>) -> impl IntoResponse {
    match health.check().await {
        HealthStatus::Healthy => StatusCode::OK,
        HealthStatus::Degraded(_) => StatusCode::OK, // still serving
        HealthStatus::Unhealthy(_) => StatusCode::SERVICE_UNAVAILABLE,
    }
}

All session store implementations (SqliteSessionStore, PostgresSessionStore, MysqlSessionStore, ValkeySessionStore) implement HealthCheck.

Session store migration

To migrate from one session store to another (e.g. SQLite to Valkey):

  1. Dual-write phase: deploy a wrapper that writes to both stores, reads from the new store first with fallback to the old store.
  2. Cutover: once the old store's TTL has expired (default 24h), switch reads to the new store only.
  3. Cleanup: remove the old store configuration.

There is no built-in migration tool. Sessions are short-lived (default 24h TTL), so a simpler approach is:

  1. Deploy the new store.
  2. Accept that active sessions on the old store will expire naturally.
  3. New sessions are created on the new store.

Session cleanup

SQLite, PostgreSQL, and MySQL stores accumulate expired sessions. Use the built-in helper:

let store = SqliteSessionStore::new(pool, crypto);
store.init_schema().await?;
let _cleanup = store.spawn_cleanup_task(Duration::from_secs(3600));

PostgresSessionStore::spawn_cleanup_task and MysqlSessionStore::spawn_cleanup_task work the same way. The returned JoinHandle aborts the loop when dropped; store it for the lifetime of the application (or pass it through to graceful shutdown, see below).

Valkey manages expiration natively via TTL; no cleanup needed.

Graceful shutdown

Axess spawns long-lived background tasks for everything that needs to run on a wall-clock cadence: session cleanup, JWKS refresh, back-channel-logout sid_map aging. None of these survive SIGTERM unless the application drains them; tokio::spawn tasks are unconditionally aborted when the runtime stops.

The standard pattern is Axum's with_graceful_shutdown plus explicit abort/await of every JoinHandle axess returns:

use axum::serve;
use std::sync::Arc;
use tokio::signal;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // ── Build stores and spawn axess background tasks ─────────────
    let session_store = SqliteSessionStore::new(pool.clone(), crypto);
    session_store.init_schema().await?;

    let cleanup_handle = session_store.spawn_cleanup_task(
        std::time::Duration::from_secs(3600),
    );

    let jwks_handle = oauth_provider.spawn_jwks_refresh(
        std::time::Duration::from_secs(3600),
    );

    // ── Shared shutdown signal ────────────────────────────────────
    let shutdown = async {
        let ctrl_c = async { signal::ctrl_c().await.ok(); };
        let term = async {
            #[cfg(unix)]
            {
                use signal::unix::{SignalKind, signal};
                if let Ok(mut s) = signal(SignalKind::terminate()) {
                    s.recv().await;
                }
            }
        };
        tokio::select! { _ = ctrl_c => {}, _ = term => {} }
    };

    // ── Serve until SIGTERM/SIGINT ────────────────────────────────
    let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
    serve(listener, app)
        .with_graceful_shutdown(shutdown)
        .await?;

    // ── Drain background tasks ────────────────────────────────────
    // Aborting is safe; both loops persist via the database, so a
    // killed cleanup tick at most leaves expired rows for the next
    // scheduled run, and a killed JWKS tick leaves the cached JWKS
    // intact until the next process serves a request.
    cleanup_handle.abort();
    jwks_handle.abort();
    let _ = cleanup_handle.await;
    let _ = jwks_handle.await;

    Ok(())
}

What survives shutdown vs what is lost

StateSurvives?Notes
Persisted sessions (SQL / Valkey)YesStored in DB; new process re-reads.
MemorySessionStore contentsNoIn-process only; everyone is logged out.
MemorySessionRegistry contentsNoSame; fresh registry on restart.
Refresh tokens (SQL / Valkey)YesHash + family in DB; rotation continues seamlessly.
JWKS cacheNo (re-fetched)First post-restart OAuth callback warms it.
sid_map (back-channel logout)NoOIDC sid → local session mapping is in-process. Sessions remain valid; only the sid-keyed lookup is lost, so a back-channel logout that arrives before re-login will silently no-op. Acceptable; the session still expires on its TTL.
In-flight HTTP request being servedYes (via with_graceful_shutdown)Axum waits for active connections to close before returning from serve.
In-flight cleanup_expired queryAbortedThe next scheduled cleanup picks up the slack.
In-flight refresh_jwks HTTP callAbortedThe next request triggers a fresh fetch on demand.

Why drain the handles after serve returns

with_graceful_shutdown only drains in-flight HTTP requests. The tokio::spawn'd cleanup / JWKS refresh tasks are independent of the HTTP server and continue running until the runtime is dropped. Without an explicit abort().await they hold a reference to the store clone and the runtime keeps them alive; at minimum delaying shutdown to the next tick, at worst (with tokio::main(flavor = "current_thread")) deadlocking because the abort signal can't be processed while the runtime is also waiting for the task to yield.

Monitoring and alerting

The thresholds below are starting points for a single-region deployment serving thousands to low-millions of users. Tune to your traffic shape; a free-tier app with no MFA will see very different baselines than a banking dashboard with mandatory FIDO2. The general rule: alert on ratios and rates, not absolute counts, so an alert that fires at 1k DAU still fires at 100k DAU without re-tuning.

Critical (page on-call)

SignalThresholdWhy it matters
auth_failure / (auth_success + auth_failure)> 50% for 5 minEither a brute-force campaign is in progress or the IdP is down. Either way, real users are locked out.
account_locked rate> 10 / minute for 5 minSustained password-spray; tens of accounts being locked per minute is well above any realistic legitimate spike.
session_binding_mismatch rate> 1 / minute per tenant for 5 minEither a stolen session cookie is being replayed across user agents, or a buggy client is rotating UAs mid-session. Investigate immediately.
Health check returns Unhealthyfor 2 consecutive checksSession store / database is unreachable; users cannot log in.
JWKS RwLock was poisoned logany occurrenceA panic happened while holding the JWKS lock; OAuth verification may be silently degraded.

Warning (alert in chat / ticket queue)

SignalThresholdWhy it matters
factor_failure / factor_attempt (per factor kind)> 30% for 15 minTargeted factor probe (e.g. TOTP guessing) or a regression in the factor verification code.
rate_limit_rejected / (rate_limit_allowed + rate_limit_rejected)> 5% for 10 minEither the rate limit is mis-tuned for legitimate traffic or an attacker is sustained-firing requests.
sid_map capacity reached; evicted oldest mapping log> 1 / minuteOAuth login throughput exceeds the configured sid_map cap (default 10 000, DEFAULT_SID_MAP_CAPACITY); back-channel logout precision degrades (some sid lookups will miss). Raise via AuthnService::with_sid_map_capacity(N) on startup, or shorten the TTL if the churn is real.
session decrypted with previous (rotated) key logpersists > 7 days after rotationLong-lived sessions are still on the old key. The next rotation will invalidate them; communicate the cutover.
account_locked rate> 1 / minute for 5 minBackground brute force or aggressive credential stuffing. Below paging threshold but worth watching.
session custom data exceeds size limit logany occurrenceApplication is writing too much to the session; investigate before users hit it in production.

Info (dashboard only, no alert)

auth_attempt, auth_success, factor_attempt, factor_success, session_created, session_invalidated, rate_limit_allowed; useful for trend dashboards, capacity planning, and as denominators for the ratio-based alerts above. Avoid alerting on absolute counts; they swing wildly with traffic.

Computing rates from counters

AuthnMetrics exposes counters; alerts live in your monitoring system (Prometheus / Datadog / Grafana / CloudWatch). The standard pattern in Prometheus terms:

# Auth failure rate over 5 minutes
rate(axess_auth_failure_total[5m])
  / (rate(axess_auth_success_total[5m]) + rate(axess_auth_failure_total[5m]))
> 0.5

Implement the AuthnMetrics trait against your metrics client and emit _total-suffixed counters for the rate queries above to compose cleanly.

Key log messages

MessageSeverityAction
"session decrypted with previous (rotated) key"InfoKey rotation in progress; monitor until gone
"JWKS RwLock was poisoned"WarnInvestigate what panicked while holding the lock
"sid_map capacity reached"WarnMany OAuth logins; consider increasing capacity
"session custom data exceeds size limit"WarnApplication is writing too much to session
"login rejected by tenant IP policy"WarnLegitimate user from blocked IP, or attack

Emergency procedures

Force-logout all users

// Via session registry (if configured):
registry.invalidate_user(&user_id).await;

// Nuclear option; clear the session store:
store.cleanup_expired().await; // only clears expired
// For immediate full clear: truncate the sessions table or flush Valkey.

Encryption key compromise

  1. Generate a new encryption key immediately.
  2. Deploy with new key only (no previous key); this invalidates all active sessions.
  3. Rotate the signing key as well (the attacker may have decrypted session data containing the HMAC tag).
  4. Review audit logs for suspicious session activity during the compromise window.

Further reading

Security posture covers the production-readiness posture and the compliance touch-points. Audit pipeline covers the audit retention and the buffer-overflow policies. Migration guide covers cross-version upgrades and the security-relevant breaking changes. Backends covers the per-backend operational notes (CockroachDB caveats, MySQL timezone handling, Valkey eviction policies).

Migration guide

Find your version below; the newest is first. Each break is listed by what you will see rather than by what we changed, because a compile error, a failed deserialization and a behaviour that quietly differs send you looking in different places. Each one names the symptom and the fix.

The behaviour changes are the ones to read closely. Nothing tells you about those.

0.6.0 to 0.7.0

Working out who a request came from was correct only through a path that cost a policy engine to reach, so the rate limiter did it wrong instead, the audit context offered a constructor that read a forgeable header, and the JWT module was filed where it needed an unrelated feature to reach. Every error below comes from closing that off.

no TrustedProxies in authz

TrustedProxies, CidrParseError, ip_from_headers_trusted and ip_from_headers_untrusted moved from axess::authz to axess::client_ip, which is not feature-gated.

// Before
use axess::authz::{TrustedProxies, ip_from_headers_trusted};

// After
use axess::client_ip::TrustedProxies;   // the walk is TrustedProxies::client_ip

They were behind the authz feature, which pulls cedar-policy. An adopter who wanted a correct client address and no policy engine could not have one.

no variant ForwardedIp for KeyExtractor

Resolve the address once, in a layer, and key on the result:

// Before: the limiter read X-Real-IP, then the leftmost X-Forwarded-For.
let config = RateLimitConfig::builder()
    .key(KeyExtractor::ForwardedIp)
    .build();

// After: the layer resolves, the limiter reads the answer.
let app = axess::client_ip::layer(app, trusted);

let config = RateLimitConfig::builder()
    .key(KeyExtractor::ClientIp)
    .build();

Read this one even if it compiles after a rename. Both headers the old extractor read are caller-writable, and nothing upstream is obliged to overwrite them: a proxy that appends to X-Forwarded-For leaves the caller's entry in front of the real one, and no proxy sets X-Real-IP unless configured to. A caller rotating either got a fresh bucket per request. If your deployment relied on ForwardedIp behind a proxy, assume the limit was not enforced against a caller who cared to avoid it, and check what your proxy actually does to both headers.

Put the layer outside everything that reads an address, and serve the router with into_make_service_with_connect_info::<SocketAddr>(). Without the peer there is nothing to check a header against, and every request resolves to no address and shares one bucket.

ClientIp is what every consumer now reads, and none of them takes an address from the caller any more. Its fields are private; only the layer and the explicit ClientIp::resolved fill one, so a handler cannot build one out of a header by accident.

no method begin_login on AuthnService

The methods that write an audit event moved to RequestAuthnService, which with_audit_context returns. There is no other way to make one.

// Before: the stamp was optional, and reaching back to the shared
// service for the second call wrote that row with no address.
state.authn.begin_login(&identifier, tenant, &session, client_ip).await?;

// After
let svc = state.authn.with_audit_context(audit);
svc.begin_login(&identifier, tenant, &session).await?;
svc.verify_factor(&credential, &session).await?;

Keep the AuthnService in application state and derive a RequestAuthnService per request. It derefs to the service, so check_session, the revocation methods and the capability predicates are reachable through the one handle; only the authenticating methods require the context.

begin_login lost its client_ip argument, and with it a hole. The tenant IP policy was enforced only if let Some(ip) = client_ip, so a caller passing None skipped it: an allowlist that a caller could switch off by omitting an argument. 51 of the 52 call sites in this repository omitted it. The address now comes from the handle, and a tenant policy that restricts anything refuses a request whose address did not resolve, because a policy that cannot be evaluated has not been satisfied. If you have tenants with an IpPolicy, install client_ip::layer before upgrading or their logins will fail closed.

request_id goes None if you never ran the layer

Nothing fails to compile here. In 0.6.0 AuditContext::request_id was filled straight from the X-Request-Id header, ungated, while the middleware that mints request ids sat behind the request-id feature. The two were unconnected, so a deployment that had never enabled the feature still recorded whatever a caller put in that header.

If you run RequestIdLayer under request-id, nothing changes: the value was the layer's id before and is the layer's id now, read from a typed extension instead of a header the layer had just written.

If you do not, request_id is None from here on instead of a caller-supplied string. That is a column going quiet, not a control weakening: an unvalidated header value was never evidence of anything. Turn the feature on and install the layer if you want the column back.

axess = { version = "0.7", features = ["request-id"] }

accept-client-id honours an id minted upstream rather than generating one, validating the inbound value before it reaches the context. middleware::request_id::RequestId is public, so a deployment whose generator uses a custom HEADER_NAME can read the typed value directly.

trace_id is new and behaves the same way under trace-id, with no prior value to lose.

no variant MissingAuditContext / no AuditContextPolicy

Both are gone, along with with_audit_context_policy and the audit_context_missing metric. Delete the builder call; there is nothing to replace it with, because the thing it checked at runtime is now checked by the compiler.

Required only ever tested whether a route attached a context, never how much that context held, so it never meant "no authentication without evidence". A route that forgot the wiring is now a type error. A route that wired a context while the client-IP layer was missing is the case the policy could not see either way, and the ip_source = 'unknown' query in Audit events is what finds it.

no function extract_audit_context

All four builders are gone: extract_audit_context, ..._async, ..._untrusted, ..._async_untrusted. The context is an extractor.

// Before
let ip = ip_from_headers_trusted(&headers, peer, &trusted);
let ctx = extract_audit_context(&headers, Some(ip), Some(&session));
let service = state.authn.with_audit_context(ctx);

// After: the handler asks for it.
async fn login_route(session: AuthSession, audit: AuditContext) {
    let service = state.authn.with_audit_context(audit);
}

It reads the address client_ip::layer resolved, plus the user-agent, request id and session id from the request. Install the layer or every context carries no address, which is honest and is not evidence.

Two of the four read X-Real-IP directly and believed it. If your code called either _untrusted form, the addresses in your audit rows were chosen by the subject of the audit for as long as it did, and they are not evidence of where anything came from.

no function ip_from_headers_trusted

The walk belongs to the set that decides it:

// Before
let ip = ip_from_headers_trusted(&headers, peer, &trusted);

// After
let ip = trusted.client_ip(&headers, Some(peer));

Option<IpAddr> now, because a unix-domain socket has no peer for an address set to match. The old client_ip_layer is client_ip::layer, and ip_from_headers_untrusted is gone with no replacement: walking from the right is correct wherever reading the leftmost entry was, and correct in the cases where it was not.

could not find jwt in federation

// Before
use axess::federation::jwt::svid::JwtSvidResolver;

// After
use axess::jwt::svid::JwtSvidResolver;

The module is gated on jwt now rather than oauth. If you enabled oauth only to reach it, you can drop that feature.

0.5.0 to 0.6.0

v0.6.0 is the largest break so far. One change is a cargo feature, five are compile errors in adopter code, and four change behaviour without any compiler help. The behaviour changes are the ones to read closely: each was a security defect, and each is fixed by doing something the old version did not do.

The build fails before anything else

If you enable jwt, oauth, oidc, fapi, bearer, jwt-svid, local-idp or workload-id, the build now stops with:

axess-factors: the `jwt` feature needs a crypto backend. Enable
`jwt-aws-lc` (...) or `jwt-rust-crypto` (...)

Add exactly one. jwt-aws-lc is FIPS-capable and needs a C toolchain (and NASM on Windows); jwt-rust-crypto is pure Rust and builds anywhere. Enabling both is allowed, because cargo may switch the second on when another crate in your build asks for it.

Until 0.5.1 axess-factors pinned aws-lc-rs itself, which chose for adopters who had already chosen the other; with both switched on, jsonwebtoken cannot pick and panics on first verification. If you sign tokens yourself, call axess_factors::jwt::ensure_crypto_provider before jsonwebtoken::encode.

Compile errors you will see

IdentityAuthnLog::record_event returns AuditOutcome. Replace Ok(()) with Ok(AuditOutcome::Recorded):

async fn record_event(&self, event: AuthEvent) -> Result<AuditOutcome, Self::Error> {
    // ... unchanged write ...
    Ok(AuditOutcome::Recorded)
}

Return AuditOutcome::Shed to drop an event deliberately under load. The flow continues and AuthnMetrics::audit_event_shed fires, where an Err fails the login. This is the valve for a hazard the fail-closed audit creates: every failed login writes a row, including for identifiers that do not exist, so an unauthenticated caller can drive writes at your storage without bound.

Shed on a criterion independent of the identifier: a global rate, a queue depth, a disk watermark. Shedding on anything derived from which identifier was tried makes the drop observable per-identifier and reintroduces the user-enumeration oracle that emitting unattributed events exists to close.

MockIdentityStore::arm_record_event_shedding exercises the path in your own tests, beside the existing arm_record_event_failure.

AuthnService construction moved to a builder. The service is now a cheap handle over one Arc, so it can carry per-request state; the collaborators are shared the moment it is built, and customising an already-shared service is therefore not possible.

let service = AuthnService::builder(identity, factors)   // was: ::new(..)
    .with_clock(clock)
    .with_registry(registry)
    .build();                                            // <- new

AuthnService::new(identity, factors) with no customisation is unchanged. from_backend(b) is unchanged; chain from builder_from_backend(b).

Client metadata now reaches audit events, if you wire it. Before this release nothing in axess attached an AuditContext to the events it emitted, so every row carried a null IP:

let ip = ip_from_headers_trusted(&headers, peer.ip(), &trusted);
let ctx = extract_audit_context(&headers, Some(ip), Some(&session));
let service = state.service.with_audit_context(ctx);
service.begin_login(&identifier, tenant, &session, None).await?;
service.verify_factor(&credential, &session).await?;

with_audit_context returns a copy of the handle; the collaborators are shared, so this costs a refcount bump per request. Route every call in the request through that copy, not just the first: the stamp is on the handle, so falling back to state.service for verify_factor writes the failed-password row with no address on it. The sqlite example derives one in each of its four audited handlers.

If your audit trail is compliance evidence, build with .with_audit_context_policy(AuditContextPolicy::Required) so a route that forgets the wiring fails loudly instead of recording blanks. That is a fail-closed path: it turns a missing context into a failed login, so wire every route before turning it on.

Removed in 0.7.0. The wiring it checked at runtime is checked by the compiler now; see 0.6.0 to 0.7.0 above.

Two password-history methods are no longer on IdentityAdmin. record_password_hash and password_history moved to a new IdentityPasswordHistory trait with no default bodies:

impl IdentityPasswordHistory for YourBackend {
    async fn record_password_hash(/* ... */) { /* unchanged body */ }
    async fn password_history(/* ... */) { /* unchanged body */ }
}

Both had defaults that panicked, and record_password_hash is called on every password change with no guard, so a backend that had not overridden it unwound the first time any user changed their password. The method looked optional, because a defaulted trait method does.

If you have no password-reuse policy, implement nothing. The password change and reset flows are bounded on this trait, so they become unavailable at compile time rather than panicking at runtime.

Note that IdentityAdmin::delete_user still has a panicking default. It is not called by any axess flow: you reach it only by calling it yourself but override it before you rely on it for GDPR erasure.

Two password-reset methods are no longer on IdentityAdmin. store_reset_token and verify_reset_token moved to a new IdentityPasswordReset trait with no default bodies. Move the two impls into their own block:

impl IdentityPasswordReset for YourBackend {
    async fn store_reset_token(/* ... */) { /* unchanged body */ }
    async fn verify_reset_token(/* ... */) { /* unchanged body */ }
}

They defaulted to unimplemented!(), so a backend that never implemented them compiled and then panicked on an open route. If you do not use password reset, implement nothing: the reset flow is bounded on the new trait, so it is no longer reachable.

extract_audit_context takes the client IP. Resolve it against the peer your server accepted rather than letting the function read headers:

let ip = ip_from_headers_trusted(&headers, peer, &trusted);
let ctx = extract_audit_context(&headers, Some(ip));   // was: (&headers)

Passing None is honest where you cannot establish the address. The old behaviour is still available as extract_audit_context_untrusted, whose name now carries the warning.

ip_from_headers is ip_from_headers_untrusted, in both the authn and authz modules. The behaviour is unchanged and remains correct behind a proxy you control. Rename, or switch to ip_from_headers_trusted, which is the one to prefer.

OAuthError has a new variant. AuditStore(String) is returned when the audit store rejects the event recording an OAuth outcome. The enum is not #[non_exhaustive], so an exhaustive match over it now fails with E0004. It classifies as transient under OAuthError::is_transient.

AuthEvent::error is an AuthFailureReason. It was Option<String>, and AuthEventBuilder::with_error took impl Into<String>. This is the field a SOC dashboard groups by, so it is the same defect AuthEventBuilder::locked was introduced to fix for the outcome: querying meant matching a string, and a string nothing enforced.

use axess::authn::AuthFailureReason;

AuthEventBuilder::failure(AuthEventType::LoginAttempt)
    .with_error(AuthFailureReason::UnknownTenant)   // was: .with_error("unknown_tenant")

The setter deliberately does not take impl Into<_>: that would keep string literals compiling, and a typo would land in Other silently, which is the thing being removed. Where no tag fits, name AuthFailureReason::Other explicitly.

Reading the field, compare against the variant rather than a string:

assert_eq!(event.error, Some(AuthFailureReason::CsrfMismatch));

The wire form does not change. Serde still emits the plain tag string, and a text column still stores it, so stored rows and JSON are unaffected. Converting a stored string back is infallible: an unrecognised tag becomes Other rather than an error, so rows from another version are never dropped:

let error = error.map(AuthFailureReason::from);

One stored value does change. The impersonation refusal was written as "cross-tenant impersonation refused" and is now the tag cross_tenant_impersonation. A dashboard matching the old prose needs updating; rows written before the upgrade keep the old text and will read back as Other.

AuthEvent::ip_address is an IpAddr. It was Option<String>, and AuthEventBuilder::with_ip took impl Into<String>, so the field that this release spent its security budget making trustworthy would still accept "not-an-ip", or an address the caller invented. AuditContext was already typed; the builder discarded the type with ip.to_string().

let ip = ip_from_headers_trusted(&headers, peer, &trusted);
let event = AuthEventBuilder::failure(AuthEventType::LoginAttempt)
    .with_ip(ip)                         // was: .with_ip(ip.to_string())
    .build();

Reading the field, the borrow you used to take becomes a plain copy, and a sink writing to a text column stringifies at the point of the write:

let ip_address = event.ip_address.map(|ip| ip.to_string());

Reading rows written before 0.6.0 needs a decision. That column holds whatever string the old writer supplied, including values a client forged, and some will not parse. Prefer warning and storing None over dropping the row: the address is optional metadata rather than identity, and a null address is honest where an invented one is not. The sqlite example does this, beside the same handling for factor_kind.

Note that cargo-semver-checks does not flag a public struct field changing type: there is no lint for it, so this break is invisible to the tool and is documented here and in the changelog instead.

ShortString::prefix is gone. It was documented as powering an equality fast path that PartialEq never used. Delete the call; there is nothing to replace it with.

Behaviour changes with no compile error

A login now fails if its audit record cannot be written. IdentityAuthnLog::record_event returning an error used to be logged and discarded, and the login proceeded unrecorded. It now surfaces as AuthnError::Store, and on OAuth paths as OAuthError::AuditStore.

This trades availability for evidence: logins fail while your audit store does. Put the sink behind something durable: write locally and ship asynchronously, rather than a remote service on the request path. AuthnMetrics::audit_store_outage fires here and should page.

Related, and the reason the above is safe: rejected logins now emit an audit row even when there is no user to attribute them to, tagged unknown_tenant or unknown_identifier. Without that, an attacker who could degrade your audit store would see Err for registered identifiers and an ordinary rejection for everything else.

Lockout fails closed when its counter store is unavailable. record_failed_attempt is a write, so a primary-database outage in a read-replica deployment used to leave logins working and the lockout counter dead: brute force unbounded exactly when monitoring was degraded. LockoutPolicy::on_counter_unavailable now defaults to CounterUnavailable::Lock.

During such an outage a user who mistypes is told they are locked and retries after duration. Set CounterUnavailable::Allow for the old behaviour. Note that Lock with duration: None means a persistently broken counter store needs an administrator per account. Alert on AuthnMetrics::factor_counter_store_outage either way.

ip_from_headers_trusted returns a different address. It took the leftmost X-Forwarded-For entry, but that header is append-only: a client sends its own value and the proxy appends the real address after it, so the leftmost entry was whatever the attacker chose. It now walks from the right, skipping hops that are themselves trusted proxies, and returns the first address that is not. A malformed entry stops the walk and yields the peer; X-Real-IP is read only when X-Forwarded-For is absent. The value changes on any deployment where a client can prepend, which is the point.

If exact proxy addresses were impractical for you, TrustedProxies now accepts CIDR ranges through from_cidrs and with_cidrs.

Audit rows you already store

A lockout is recorded as AuthEventStatus::Locked. It was Failure with error = "locked", so the outcome lived in a free-text field while the Locked variant was unreachable. A dashboard matching status = 'failure' AND error = 'locked' will stop matching: filter on the status column instead. Rows written before the upgrade keep the old encoding, so a query spanning the boundary needs both. Other non-active states are unchanged, still Failure with error = "not_active".

If you built SIEM queries or dashboards from the audit chapters before 0.6.0, re-check them regardless: those chapters described a model axess does not implement, and were rewritten in this release.

0.4.0 to 0.5.0

v0.5.0 has three adopter-facing changes, each to a single struct field. Two are type changes from String to ZeroizedString, for the same reason: the field held a secret in a Debug-deriving public type, so printing the value printed the secret. SocialProviderConfig::client_secret reaches adopters of the social feature; ClientCredentialsToken::access_token reaches adopters of oauth who call OAuthProvider::client_credentials. The third is a visibility change: axess_events::KeyId stops exposing its inner ShortString.

Compile errors you will see

Building a SocialProviderConfig from a String no longer compiles: expected 'ZeroizedString', found 'String' (E0308), at the struct literal. Wrap the secret:

use axess::authn::ZeroizedString;
use axess::social::SocialProviderConfig;

SocialProviderConfig {
    client_secret: ZeroizedString::new(secret),   // was: secret.into()
    // ...
}

ZeroizedString::new takes impl Into<String>, so a &str, an owned String, or a generic impl Into<String> parameter all wrap directly; From<String> and From<&str> mean .into() works too, where the target type is unambiguous. A helper that accepts impl Into<String> and passes it through to this field keeps its own signature, because the wrap happens at the field; the helper's callers need no change.

The rationale is that SocialProviderConfig derives Debug and its documentation invites loading it from a TOML/YAML/JSON config file, so an adopter logging their own configuration printed the OAuth client secret verbatim. ZeroizedString prints as ZeroizedString(***) and zeroes its bytes on drop, which is what TotpConfig::secret, HotpConfig::secret and the outbound OAuth client already did; social was the only place in the crate that did not.

ClientCredentialsToken::access_token

This one is a value axess returns, not one you construct, so most code needs no change: &token.access_token still derefs to &str and passes to anything taking &str, including an Authorization header value. Two shapes do need an edit. Code that binds the field as an owned string (let bearer: String = token.access_token) becomes token.access_token.to_string(). Code that builds the struct itself, which in practice means a test double standing in for a token endpoint, wraps the value: access_token: ZeroizedString::new("test-token").

Serialization is deliberately unchanged. Unlike OAuthClaims, which marks its token #[serde(skip_serializing)], a client-credentials response is a token, so an adopter caching one must still be able to serialize it; ZeroizedString is transparent to serde in both directions. What changed is Debug: the field now prints as ZeroizedString(***), which is the disclosure this release closes.

KeyId's inner field

axess_events::KeyId no longer exposes its ShortString. A struct literal or a destructuring pattern stops compiling:

let id = KeyId(ShortString::new("kms-2026-01"));   // was
let id = KeyId::new("kms-2026-01");                // now
let id = KeyId::from_static("kms-2026-01");        // const, no allocation

let raw = id.0;            // was
let raw = id.as_str();     // now

All three constructors and the accessor existed before this release; only the field's visibility changed, matching KindTag, which had always kept its own field private. If you do need to name ShortString (the From<ShortString> for KindTag impl is the reason you might), axess-events now re-exports it, so use axess_events::ShortString replaces a direct axess-strings dependency.

What is not a break

Config files are unaffected. ZeroizedString is a newtype over String deriving Serialize/Deserialize, which serde treats transparently, so a bare string in a config file still deserializes into the field.

Most read sites keep compiling. ZeroizedString derefs to str, so &config.client_secret, config.client_secret.len() and anything taking &str work unchanged. Only a site that needs an owned String has to say so, with config.client_secret.to_string().

0.2.2 to 0.3.0

v0.3.0 upgrades the jsonwebtoken dependency from 10 to 11 and raises the workspace MSRV. Two adopter-facing changes. (There is no 0.2.1 to 0.2.2 entry: 0.2.2 was a transitive-dependency security patch with no public-API change: see What does not migrate.)

Compile errors you will see

An exhaustive match on a jsonwebtoken::Algorithm obtained from axess no longer compiles: non-exhaustive patterns: '_' not covered. jsonwebtoken 11 marks Algorithm #[non_exhaustive], and axess re-exposes that type through its public surface: ALLOWED_ALGORITHMS, JwtVerifier::with_algorithms, and the local-IdP algorithm / verifier_algorithms helpers. Add a wildcard arm to any such match:

match alg {
    Algorithm::RS256 => ...,
    Algorithm::ES256 => ...,
    _ => ...,            // required by jsonwebtoken 11
}

The rationale is upstream's: #[non_exhaustive] lets jsonwebtoken add algorithms in a future minor without that being a breaking change for them, which moves the "handle the unknown" obligation onto callers. For a verifier the safe default is to fail closed: treat the _ arm as "unsupported algorithm, reject".

Toolchain

The workspace MSRV is now 1.93.1 (up from 1.87). The library itself builds on 1.88 (jsonwebtoken 11 raised that) but the declared floor is set to the workspace-wide requirement so the full build-and-test suite runs on one toolchain. Bump your toolchain to 1.93.1 or later.

What is not a break

The new EventSubjectRef and EventPayload::subject_ref() are purely additive: subject_ref() defaults to None, so existing EventPayload implementations need no change. The inter-crate version pins moving to exact =0.3.0 are internal to the axess workspace; adopters depend on the axess facade with their own version requirement and are unaffected.

0.2.0 to 0.2.1

v0.2.1 is a patch release. The only behavioural change is a CSRF hardening: the double-submit token now binds to the session id (HMAC(signing_key, nonce || session_id)) rather than to the signing key alone, and CsrfLayer fails closed with 403 when the session-id extension is missing on a state-changing request. Two adopter-facing consequences:

Adopters MUST layer CsrfLayer inside (i.e. run after) the session layer so the SessionHandle request extension is present by the time CSRF validation runs. In axum, later .layer(...) calls wrap earlier ones, so .layer(csrf).layer(session_layer) is the correct order. Stacks that had the layers in the opposite order silently validated an unbound token before 0.2.1; that shape now returns 403.

Clients that cache the CSRF token across a session change (login, session regeneration) MUST re-read the token after the change or their first post-change state-changing request will 403. Tokens minted under one session id no longer verify against another.

0.1.x to 0.2.0

The first crates.io publish is the 0.2.0 release. The accumulated changes since the previous stable line are catalogued exhaustively in CHANGELOG.md; this chapter covers the breaking ones an adopter has to act on.

Compile errors you will see

use axess::PolicyStore becomes use axess::AuthzStore. The authorisation entry point was renamed for consistency with the Authz* prefix convention. The new name better describes what the type is (an immutable store of policies plus schema, not just a policy collection).

use axess::AxessSession becomes use axess::AuthSession. The session extractor was renamed; the new prefix is the shared Auth* prefix from the naming conventions (Architecture at a glance).

use axess::backends::SqliteStore becomes use axess::backends::sqlite::SessionStore. The backend module layout was reorganised so the same trait name (SessionStore) appears under each backend's namespace; the previous flat SqliteStore symbol no longer exists.

AuthnService::new(backend) becomes AuthnService::new(identity_store, factor_store). The service now takes the two stores separately so adopters can wire different implementations (for instance, a read-replica identity store and a write-only factor store). When the two stores are the same type (the common case), pass it twice.

The signing key is a constructor argument, not a builder call: SessionLayer::new(store, signing_key), taking [u8; 32]. Rotation is the one related builder, with_previous_signing_key, which keeps the outgoing key valid for verification while the new one signs.

AuthState::Logged becomes AuthState::Authenticated. The state was renamed for clarity; nothing else changed about the variant.

Configuration changes

The axess_factors_default_password_hasher config function is gone. Argon2id is now the default; deployments that need a different hasher (PBKDF2, legacy bcrypt) implement a custom factor and register it. Factors and methods covers the extension pattern.

There is no audit-pipeline config type to migrate. Axess awaits one call, IdentityAuthnLog::record_event, and the implementation behind it is yours; a deployment that had wired sinks into a config struct wires them inside that implementation instead. Audit pipeline covers the seam.

The RateLimitConfig no longer accepts a key_fn field directly; use KeyExtractor::Custom(Arc<dyn KeyExtractorFn>) to provide a custom extractor, or use one of the built-in variants (PeerIp, SessionId, UserId, TenantId, WorkloadId, Composite). The change is to make the common cases discoverable without losing the escape hatch.

Behaviour changes

The Authenticating state now carries a Vec<FactorKind> for remaining rather than the previous Option<FactorKind>. The change is what enables multi-factor methods longer than two factors. Code that pattern-matched on Some(kind) needs to adapt to remaining.first() or to iterate over the list.

LockoutPolicy is per-user and has no other scale. It is three fields (max_attempts, duration, attempt_window) with no per-tenant or per-IP variant to configure or to turn off. Throttling a source IP is the rate limiter's job, where the answer is a 429 rather than a locked account; see Rate limiting, and note KeyExtractor::LoginIdentifier for the per-account half of that defence.

The session cookie's SameSite attribute now defaults to Lax rather than Strict. The change is to match modern browser defaults and to admit cross-site link-to-app navigations as legitimate. Deployments that need Strict configure it explicitly.

Session binding is off unless you ask for it. Nothing is bound until the layer is built .with_binding(UserAgentBinding), and a mismatch then resets the session to Guest. There is no policy to set and no quieter setting to start from (Cookies, fingerprinting, hijack detection covers the limits of what the binding catches).

Schema migrations

The users table gained a tenant_status field for the tenant-suspension support. The migration is a single ALTER TABLE that adds the column with a default value. The examples/sqlite/migrations/ shows the SQL.

The devices table gained a fingerprint_hash field and lost the previous fingerprint_raw field. The migration is destructive: the fingerprint_raw field carried PII that the new design hashes before storage (Device identity covers the rationale). Adopters who want to preserve the audit trail of past fingerprints write the migration accordingly; adopters who do not, just drop the column.

The authn_attempts table gained an event_kind enum field that distinguishes between attempt outcomes, rather than relying on a separate outcome string. The migration is non-destructive; the outcome field stays for backward compatibility and is populated from event_kind automatically.

The session-data schema version bumped from 1 to 2. The new version adds a device_id field on Authenticated (for the device-binding work covered in Device identity). The schema-migration code (Schema migration) handles existing sessions transparently; no manual data migration is needed.

Workspace structure changes

The axess-delegated crate folded back into axess-core. The adopter import paths stay the same (axess::delegated::* continues to work through facade re-export); the Cargo.toml no longer needs an explicit axess-delegated dependency, just the delegated feature on axess. The workspace dropped from 11 to 10 library crates.

For deployments running on the 0.1.x line:

The first step is to read this chapter end-to-end. Make a checklist of every change that applies to your code.

The second step is a parallel-deploy approach. Stand up a 0.2.0 build alongside the production 0.1.x; route a small fraction of traffic to it; observe behaviour. The session cookies between the two versions are not compatible (the schema-migration mechanism handles cookie reads but not writes across major versions), so the parallel deploy needs to be on isolated session storage.

The third step is the cutover. Once the 0.2.0 build has been green for at least the session TTL on the production-like sample, route 100% of traffic to it. The 0.1.x build can be decommissioned after a roll-back window has passed without incident.

The roll-back path: if 0.2.0 surfaces problems, route traffic back to 0.1.x; the sessions that started under 0.2.0 will be invalid against 0.1.x and will land as Guest, prompting re-login. The user-visible impact is one re-login; the behavioural impact is bounded.

Future migrations

The pattern from 0.2.0 to 0.2.1 is the pattern future migrations will follow. Each migration documents itself here, sorted by release. The pattern:

Symptom: what the compiler or the runtime will tell you.

Rationale: why the change happened. Most changes happen because the previous shape was wrong in a specific way (a footgun, a performance bug, a security gap, an inconsistency with the rest of the library). The rationale gives the explanation; the next section gives the action.

Action: what to update in adopter code. The action is the shortest possible change that satisfies the new shape; longer restructurings are flagged as optional improvements.

A typical migration entry runs five to ten lines for a small change, a few paragraphs for a larger one. The chapter grows additively; older migrations are not removed.

What does not migrate

Some adopter changes do not produce a migration entry. The patterns:

Behaviour that was bug-fixed. A previous version's incorrect behaviour might have been load-bearing for an adopter who built around it; the fix is still the right thing to do, and the adopter has to adapt. The fix appears in the changelog as a bug fix; if the bug-fix is large enough to warrant a migration entry, it lands here, but not all of them do.

Internal refactors that do not change the public API. The internal split between axess-core modules is free to reorganise without producing a migration entry, as long as the public re-exports stay stable.

Configuration defaults that change but are configurable. A default that flipped is a behaviour change, captured above. A default that is configurable in both directions and the configuration is the source of truth does not produce a migration entry; the adopter's existing configuration continues to apply.

Further reading

Schema migration covers the per-session schema migration mechanism that handles session-data shape changes. The CHANGELOG.md covers the exhaustive list of changes per release; this chapter is the curated migration subset. Security posture covers the security-relevant breaking changes specifically, with the disclosure protocol for security fixes.

Contributing

What a pull request needs before it will be merged. The DST requirement is the one that surprises people, so read that section even if you skip the rest.

The chapter has two halves. The first half is contributor-facing guidance specific to working on axess. The second half is the canonical CONTRIBUTING.md from the repo root, included so the workflow checklist is in one place.

Before you open a PR

Three things to do before you open a PR.

Read or skim Architecture at a glance. The verifier-versus-orchestrator boundary, the three state slices, the DST discipline, and the naming conventions are the four architectural decisions that the review process holds new code against. A PR that violates one of them is harder to land; a PR written with them in mind sails through.

Find or create an AX-NNN tracking entry. The ROADMAP is the source of truth for "what is being worked on" and "what is committed." A PR that lands a feature should reference an AX-NNN. A PR that lands a bug fix can do without (though one is often associated even with fixes). The number lives in the PR description and in the commit messages; the format is AX-NNN (no #, no space).

Discuss substantial changes before writing them. The review cycle is faster when the maintainers have agreed to the shape ahead of time. A drive-by PR that rewrites a module is usually rejected even when the rewrite is well-thought-out; the cost of integration is higher than the value of the rewrite. A discussion (an issue, a draft PR description, a comment in an existing thread) before the work starts is the shape that lands.

Testing requirements

Every change passes its tests under both the production and the mock implementations of Clock, SecureRng, and the backend traits. The DST discipline is the testing non-negotiable; it is not aspirational.

A test that fails on the production implementation but passes on the mock is detecting a real bug in the production code (or in the test). A test that fails on the mock but passes on production is detecting either a real timing-dependent bug or an over-strict test; either way it is worth investigating before landing.

The pattern in the test code is to parameterise:

#[tokio::test]
async fn login_succeeds_with_correct_password() {
    // The mocks are builders, assembled per test, so each test states
    // the world it needs rather than inheriting a shared fixture.
    let identity = MockIdentityStore::new()
        .with_tenant(fixture_tenant())
        .with_user(fixture_user());
    // `MockClock::at` pins the instant; `MockClock::now` takes the
    // wall clock once and then holds it. Either way the clock does not
    // move unless the test calls `advance_secs`.
    let service = AuthnService::builder(identity.clone(), MockFactorStore::new())
        .with_clock(Arc::new(MockClock::at(
            DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
        )))
        .build();

    let outcome = service
        .verify_factor(
            &FactorCredential::Password("Gnomes2+".into()),
            &session,
        )
        .await
        .unwrap();
    assert!(matches!(outcome, FactorOutcome::Authenticated));
}

There is no all-in-one harness. The pieces are MockIdentityStore and MockFactorStore for the two halves of what used to be one backend, MemorySessionStore and MemorySessionRegistry for sessions, MockClock and MockRng for the injected clock and randomness, and MockOAuthProvider, MockFido2Provider, MockLdapProvider, MockPolicyEvaluator and MockEntityProvider where a test reaches those seams. Wiring them per test is more typing and leaves each test saying what it depends on, which is worth more in a suite this size.

For tests that need a real database (integration tests that verify SQL adapters), the pattern is to feature-gate them and run them in CI under a service container:

#[tokio::test]
#[ignore = "requires Postgres"]
async fn postgres_session_round_trip() {
    let pool = sqlx::PgPool::connect(env_var("TEST_POSTGRES_URL")?).await?;
    // ... full integration test
}

The #[ignore] attribute keeps the test out of the default cargo test run; the CI runs them explicitly with cargo test --features integration -- --ignored. The pattern keeps the inner loop fast (default cargo test is in-process) while still exercising the integration tests in CI.

What good PR descriptions look like

The PR description is what reviewers read first. The goal is to explain what the PR does, why, and what to look for. The shape:

A one-sentence summary at the top. "Add the bearer-token factor for inbound API authentication." Not "Misc fixes." The summary is what shows up in the PR list and in the commit history.

A "Why" paragraph. What problem does the change solve. The problem might be a documented bug, a missing capability, an operational signal that needs response. The reviewer's first question after "what" is always "why now"; answer it in the description rather than the comments.

A "How" section. The shape of the change. Which modules touched, which traits added or modified, which tests added. The reviewer's first question after "why" is "where to look"; the section is the map.

A "Testing" section. What tests cover the change. The default expectation is unit tests against the mocks; integration tests where the change crosses an integration boundary; manual testing notes for changes that are hard to automate (typically migrations or operational tooling).

A "Migration" section if the change is breaking. What downstream code has to update. The section is what feeds the Migration guide chapter; the maintainers add the entry there as part of the merge, but the PR author drafts the wording.

A reference to the AX-NNN tracking number. If the work is substantial, the AX entry has the larger context; the PR description summarises the slice this PR delivers.

Naming and visibility

The naming conventions from Architecture at a glance are enforced at review. The shapes:

A type that is shared across authentication and authorisation uses the Auth* prefix. A type used only for authentication uses Authn*. A type used only for authorisation uses Authz*. A type that does not fit any of the three either picks one (typically the broader one) or argues in the PR description why the convention does not apply.

A type's suffix carries its role. *Store, *Registry, *Provider, *Resolver, *Config, *Error, *Outcome, *Decision. A new type that does not fit any of these picks the closest match or argues in the PR description; the conventions are tight, but they are not exhaustive, and the rare exception is acceptable when documented.

A method's verb carries its complexity. get_* is O(1) by primary key. find_* may scan. load_* and save_* are serialisation pairs. begin_* and complete_* are ceremony starts and finishes. verify_* is a credential check. A method that does not fit any of these picks the closest match.

Visibility defaults to pub(crate). A type is promoted to pub only when an external consumer needs it; the default is to not export, and the burden is on the PR to justify the promotion. The convention catches the common case where an internal helper accidentally becomes public surface that has to be maintained forever.

The no-#[non_exhaustive] policy

Axess does not use #[non_exhaustive] on its public enums and structs. The attribute trades exhaustiveness checking (the downstream compiler does not catch missing match arms) for backward compatibility (the upstream can add variants without breaking downstream). For axess, the trade is the wrong way around: missing match arms in the downstream are bugs we want to catch, and the backward-compatibility cost of adding variants is manageable through deprecation cycles and the migration guide.

A PR that adds #[non_exhaustive] to a public type is rejected unless the reasoning in the PR description argues a specific case. The default is to bump the semver major version when a variant is added, document the change in the migration guide, and let the downstream's compiler catch the missing arm.

The DST non-negotiable

The DST discipline is reproduced from Architecture at a glance as a contributor reminder:

Every code path that reads wall time goes through the Clock trait. Every code path that sources entropy goes through the SecureRng trait. Every backend trait has a mock implementation that the tests use. A PR that introduces a chrono::Utc::now() call, a getrandom() call, or a direct database read outside the trait surface is rejected.

The exceptions are extremely narrow: the axess-cache crate's moka-cache feature uses wall-clock-driven eviction (opt-in, documented as DST-breaking), and the production SystemClock and SystemRng implementations delegate to the OS (these are the only places where the OS calls happen). New code introduces no further exception and no workaround that hides the same problem.

The discipline is what lets the test suite be reproducible. A contributor who finds the discipline frustrating is usually about to introduce a bug; the friction is the point.

Canonical CONTRIBUTING.md

The rest of this chapter is the canonical CONTRIBUTING.md from the repo root.

Contributing to Axess

Thanks for your interest! Axess accepts bug reports, feature requests, documentation improvements, and code contributions.

Before opening a PR for non-trivial work, please file an issue first; this lets us flag overlap with in-flight work in ROADMAP.md and confirm the change fits the library's direction (see docs/intro/architecture.md) before you invest time.

Before you submit

  1. Fork the repository and create a topic branch from main.
  2. Tests; add or update tests for every behaviour change. The library uses deterministic simulation testing (DST); inject MockClock / MockRng rather than calling SystemTime::now() or rand::rng() directly.
  3. Run the full check locally:
    cargo fmt --all
    cargo clippy --workspace --all-features --lib --tests -- -D warnings
    cargo test --workspace --all-features
    
  4. Update CHANGELOG.md; add an entry under the [unreleased] section describing the change. Behaviour-changing entries belong under ### Changed (breaking) if they alter a public API.
  5. Open a PR with a description that covers the why; link the issue, summarise the design choice, and call out any deliberate trade-offs.

Coding conventions

  • Idiomatic Rust, async/await for IO, thiserror for error types, tracing for logs.
  • Prefer traits + generics on hot paths; vtable dispatch (Box<dyn …>) only where it earns its keep.
  • Public APIs need rustdoc; including at least one usage example for newly-introduced traits or builders.
  • All time + randomness goes through the Clock / SecureRng traits. This is non-negotiable; it's what makes the test suite deterministic.

See .github/copilot-instructions.md for the full house style.

Workspace layout

CrateRole
axessPublic facade: middleware builder, re-exports, feature gates
axess-coreCore types, session orchestrator, Cedar authz integration, on-behalf-of credential storage + token exchange
axess-cacheGeneric clock-aware TTL cache
axess-clockClock / MockClock traits for DST
axess-eventsrkyv-serialisable audit event types
axess-factorsAuthentication factor implementations
axess-identityNewtype ID macros + impls
axess-macrosProcedural macros for route guards
axess-rngSecureRng / MockRng traits
axess-stringsShort hot-path string primitive
examples/*Reference example applications

Repository conventions

A few rules that aren't obvious from reading the code but affect every PR. Most exist because the cost of not following them showed up somewhere.

Module layout

axess uses the modern Rust convention: foo.rs + a sibling foo/ directory holding submodules. No mod.rs files in new code. Every directory module declares its submodules in the foo.rs file next to (not inside) the directory.

Test-sideways-pull

When #[cfg(test)] tests crowd a production file enough to make scrolling expensive, pull them into a sibling tests.rs:

axess-core/src/path/file.rs      ; production code +
                                    #[cfg(test)] mod tests;
axess-core/src/path/file/tests.rs; the actual tests, gated by
                                    #![cfg(test)]

Applied so far across several files where the tests-to-production ratio exceeded ~40%.

pub(crate) for state-machine internals

AuthSession carries identity / session-state accessors as pub. State mutation methods (set_authenticated, begin_authenticating, advance_factor, record_attempt_at) are state-machine transitions that the factor pipeline drives; they are pub(crate) so handler code cannot corrupt the state machine. Adopters drive flow through AuthnService; the session is read-only-ish from outside axess-core.

Per-app workflow mutations (set_identifying, set_pending_workflow, clear, regenerate) remain pub; apps build their own two-step identify / workflow-step / logout flows on top.

No #[deprecated] pre-1.0

Breaking changes happen freely across the 0.x line; adopters get one coordinated migration window per minor bump, not a long #[deprecated] trail. CHANGELOG documents each break under ### Changed (breaking).

MSRV bumps are breaking changes

The workspace pins rust-version in [workspace.package]; ./scripts/check-msrv.sh reports the current value and proves the tree still builds on it. A bump to a higher MSRV requires a minor-version bump on every published crate (0.x → 0.x+1 for 0.x; 1.x → 1.x+1 once stable). The reasoning: adopters pin Rust toolchains in CI; jumping the floor without warning silently breaks their builds.

Procedure for an MSRV bump:

  1. Justify in the PR description (which compiler feature, why it earns the bump).
  2. Update rust-version in [workspace.package]. That is the only place it is written: ./scripts/check-msrv.sh and the MSRV job in .github/workflows/ci.yml both read it from there, and ./scripts/check-doc-versions.sh fails if the docs disagree.
  3. Add an entry under ### Changed (breaking) in CHANGELOG.md naming the new floor.
  4. Bump the workspace version (in [workspace.package]) accordingly.

cargo semver-checks does not catch everything

./scripts/release-preflight.sh runs it, and a pass means none of its covered classes regressed --- not that the release is non-breaking. It has no lint for a public struct field changing type, so pub client_secret: String becoming ZeroizedString is a breaking change that the tool reports clean against a real published baseline. Breaking changes are documented by hand under ### Changed (breaking) in CHANGELOG.md; the tool is a backstop for what it does cover, not the record.

No #[non_exhaustive] on first-party enums

#[non_exhaustive] trades one breakage class (adding variants) for another (every downstream match needs a wildcard arm forever, even when the caller wants compile-time exhaustiveness on a closed set). Project policy is to bump the version and let downstream match failures be loud. CI enforces this; the ban_non_exhaustive workflow job rejects any PR that introduces the attribute.

No ticket-meta date stamps pre-v0.1.0

Source-code comments do not carry // AX-NNN (YYYY-MM-DD): markers. The CHANGELOG is the authoritative timeline; in-source stamps add noise without information a future reader can use. ROADMAP + CHANGELOG retain their AX-NNN references unchanged.

Closed AX-NNN references get stripped

Once an AX-NNN case closes, every reference in source / doc-strings / test names is stripped, preserving the rationale comment but dropping the case number. Open + deferred cases stay referenced.

Promoting a module out of axess-core

axess-core has accumulated significant surface. When proposing a new crate carve-out, check:

  1. No reverse dep from axess-core onto the carved module. If the module's types appear in AuthnService method signatures or in any axess-core trait surface, the carve isn't yet feasible; invert the dependency first.
  2. Module has its own external dep blast. Carving delegated/ into axess-delegated won because it pulls aes-gcm only when adopters opt in. A carve that pulls no extra deps is just churn.
  3. Module is consumable in isolation. A consumer who wants only the carved module should not transitively recompile axess-core's protocol surface.
  4. Re-export via the facade preserves the import path. Adopters write axess::RateLimitLayer, not axess_core::middleware::ratelimit::RateLimitLayer. The facade decides the shape, and it flattens: the middleware lives under axess_core::middleware, and the facade lifts the types an adopter needs to the root and the layers to their own modules (axess::csrf, axess::request_id, axess::trace_id).

Security

Do not open public issues for security vulnerabilities. Report them privately per SECURITY.md.

Licensing

By contributing, you agree your contribution will be dual-licensed under MIT and Apache-2.0, matching the project licence.

Community

Be respectful and constructive. See CODE_OF_CONDUCT.md.

Maintainer time is volunteer-funded; review turnaround is best-effort.

Further reading

Architecture at a glance covers the architectural decisions that review enforces. Publishing runbook covers the maintainer-only release process. The CHANGELOG.md catalogues what each release has shipped, which is useful context for understanding what the next PR is meant to do.

Publishing runbook

Maintainers only. Publishing to crates.io is irreversible and versions cannot be reused, so the order below is not advisory.

The audience is the maintainer cutting a release. An adopter does not need this chapter; the chapter is here so the maintainer has a written reference and so the procedure can be followed by a different maintainer if needed.

Pre-flight

The pre-flight checklist runs before the first dry-run. Each item is a binary pass-or-fail; one failure blocks the release.

The CI is green on the release branch. The full test matrix (default features, all-features, per-backend isolation, FIPS backend) all pass. A red CI does not publish.

The version bumps are consistent across the workspace. Every member of the workspace gets the same version bump (this is the versioning policy: the workspace ships as one unit). The Cargo.toml in each crate carries the new version; the version.workspace = true shape inherits from the root.

The Cargo.lock is up to date. Run cargo update --workspace, review the changes, commit if needed.

The migration guide is complete. The Migration guide chapter in the book carries an entry for every breaking change in the release. The entry covers the symptom, the rationale, and the fix.

The CHANGELOG.md has a current entry for the release. The entry covers the new features, the breaking changes (referencing the migration guide), and the bug fixes. The entry is the short-form version of the migration guide; both exist because they serve different audiences (the CHANGELOG is the per-release overview, the migration guide is the per-change reference).

The docs.rs configuration is present and correct on every crate that publishes to crates.io. The shape:

[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]

Verify by running cargo doc --all-features locally; the build must succeed without warnings. A docs build that fails on docs.rs after publish is a maintenance problem that surfaces once the release is out.

The description, license, repository, keywords, and categories fields are populated on every published crate. The crates.io page renders these; a missing field is a missing detail in the listing.

The publish = false flag is removed from every crate that should publish. This is the deliberate gate that keeps accidental publishes from happening; flipping the flag is what makes the release possible.

The version branch (a fresh release/0.2.0 branch from main) exists. The branch is the source of truth for the release; any fixes during the publish window land on the branch and merge back to main after.

Topological dependency order

The workspace's library crates publish in dependency order: a crate must be published before any crate that depends on it. The order:

  1. axess-strings (no axess deps)
  2. axess-clock (no axess deps)
  3. axess-rng (no axess deps)
  4. axess-identity (no axess deps)
  5. axess-cache (depends on axess-clock)
  6. axess-events (depends on axess-identity)
  7. axess-factors (depends on axess-identity, axess-clock, axess-rng)
  8. axess-macros (no axess deps; procedural macros stand alone)
  9. axess-core (depends on everything in the previous tier)
  10. axess (the facade, depends on axess-core and axess-factors and axess-macros)

The order is generated by cargo publish --dry-run against each crate in turn, but the maintainer should know it manually so a publish that fails partway through can be resumed at the right position.

The dry-run

Before the actual publish, run a dry-run for each crate in topological order. The shape:

cargo publish --dry-run -p axess-strings
cargo publish --dry-run -p axess-clock
cargo publish --dry-run -p axess-rng
cargo publish --dry-run -p axess-identity
cargo publish --dry-run -p axess-cache
cargo publish --dry-run -p axess-events
cargo publish --dry-run -p axess-factors
cargo publish --dry-run -p axess-macros
cargo publish --dry-run -p axess-core
cargo publish --dry-run -p axess

The dry-run does everything the publish does except the upload. It builds the package, verifies the manifest, runs the publish-time checks, and prints the path of the .crate file it would have uploaded. A failure here is an opportunity to fix without having to yank a half-published release.

A failure on a downstream crate (say axess-core) typically means an upstream crate (say axess-factors) needs an update that the dry-run does not yet reflect. The fix is to update the upstream first; the downstream dry-run picks up the change.

The publish

After the dry-runs pass, run the actual publish in the same topological order. The shape:

cargo publish -p axess-strings
# wait ~30s for crates.io to index, then:
cargo publish -p axess-clock
# wait, then:
cargo publish -p axess-rng
# ... and so on

The wait between publishes is necessary because each subsequent publish needs the previous one to be available on crates.io's index. Without the wait, the downstream publish fails with "crate not found"; with the wait, the index has propagated by the time the next publish queries it.

The wait time is short (30 seconds is generous; sometimes 15 seconds works). For automation, the wait can be scripted with a retry loop that polls the crates.io API until the expected version is listed.

After the final publish (cargo publish -p axess), wait a few minutes and verify on crates.io that all the crates are listed at the new version.

The smoke test

After the publish, run a smoke test against a fresh dependency on the published version. The shape:

mkdir /tmp/axess-smoke
cd /tmp/axess-smoke
cargo new --name axess-smoke .
echo 'axess = "0.7"' >> Cargo.toml
cargo build

The build pulls the published crates from crates.io (not from the workspace) and verifies they assemble. A failure here indicates an issue that the dry-run did not catch (typically a crates.io-specific issue like a missing file in the package manifest); the rollback procedure is the response.

For a more thorough smoke test, copy examples/sqlite/ to a fresh directory, point its Cargo.toml at the published versions (replacing path = "../../axess" with version = "0.2"), and verify it builds and runs.

The smoke test is the last gate before announcing the release. A successful smoke test means the publish is real.

The announcement

After the smoke test passes:

Tag the release in git (git tag v0.NEW.0 && git push origin v0.NEW.0). The tag is the canonical reference point.

Update the status banner near the top of README.md from "pre-release" to "0.2.0 released."

Open a 0.3.0-pending section in CHANGELOG.md. Future PRs land entries under that section until the next release cuts.

Post to the project's announcement channels (the GitHub releases page is the canonical one; the project's Discord, Slack, mailing list, or other channels mirror as appropriate).

Update the docs.rs links anywhere they hardcode a version. The canonical version is now the released one, not the development branch.

The copy-pasteable dependency snippets in the READMEs, the book and the testing doc-comments are checked rather than remembered: ./scripts/check-doc-versions.sh compares every axess... = { version = "..." } in a tracked .md or .rs file against [workspace.package] version, and ./scripts/release-preflight.sh runs it among its cheap early gates. The comparison is cargo's compatibility rule, not string equality, so "0.4" and "0.4.0" both satisfy a 0.4.0 workspace; for 0.x the minor is the breaking unit. CHANGELOG.md is exempt because its entries are supposed to name the versions they shipped in, and non-semver placeholders such as the 0.NEW.0 in docs/production/release.md are ignored.

Bump the workspace version first, then run the check: it will list every snippet the bump left behind.

Rollback

If the publish goes wrong (a critical bug surfaces, a crate is broken on crates.io, the release was premature), the rollback procedure:

cargo yank --version 0.2.0 axess (and every other crate) withdraws the version from crates.io. Yanked versions remain available to existing consumers (so Cargo.lock references continue to work), but new resolves do not pick them up.

A yanked version cannot be unyanked, and a new publish with the same version number is not possible. The next release picks a new version (0.2.1); the fix lands there.

In practice the rollback is needed less often than the dry-run suggests; the topological-publish discipline catches most issues before the upload. A yank is the genuine emergency response, typically for a security issue that warrants withdrawing a specific version.

Post-release maintenance

After the release lands, the maintenance window is the period where the maintainer watches for issues. The shape:

The first 24 hours: monitor crates.io for download numbers (a quick check that the publish reached an audience); monitor the GitHub issue tracker for new bug reports; monitor the dashboards of any deployments that follow main closely.

The first week: triage the issues that come in; assess whether any warrant a patch release (0.2.1). The criteria for a patch release: a critical bug, a security issue, a regression from 0.1.x that the migration guide did not catch.

The first month: roll up the lessons learned. The patches shipped, the issues that surfaced, the documentation gaps the release exposed. The roll-up feeds the next release's planning.

Further reading

Migration guide covers the cross-version compatibility surface that the release-management decisions depend on. Contributing covers the development workflow that produces the changes a release ships. The CHANGELOG.md covers the exhaustive list of changes per release.