Skip to content

Rust: Axum

seamless-auth on crates.io is the official Seamless Auth server adapter for Rust and Axum. It gives you an Axum router for the /auth routes the frontend SDKs call, a require_auth() tower layer for your own routes, and a User extractor for the signed-in user. The session lives in signed HttpOnly cookies on your own domain. You do not write token parsing, JWKS fetching, cookie signing, or refresh logic.

Use it when your backend is Axum and you want passkeys, magic links, email or SMS codes, or OAuth sign-in against a Seamless Auth server (self-hosted seamless-auth-api or a managed instance). It is held to the same conformance suite as the Express, Fastify, Go, and Python adapters. For other languages see Server SDKs And Adapters.

Source: fells-code/seamless-auth-rust. Current release: 0.2.0 (pre-1.0, so the public API may change between minor versions).


Terminal window
cargo add seamless-auth
cargo add axum@0.8 serde_json
cargo add tokio@1 --features macros,rt-multi-thread,net

It needs Rust 1.88 or later and Axum 0.8. The crate name is seamless_auth.

Feature Default Purpose
rustls yes TLS to the auth API with rustls
native-tls no TLS to the auth API with the platform’s TLS library

With neither, the adapter reaches the auth API over plain HTTP only.


This mounts the auth routes at /auth, protects /api/me, and reads the current user. It follows the reference app the conformance suite drives (conformance/refapp/main.rs).

use std::net::SocketAddr;
use axum::{Json, Router, routing::get};
use seamless_auth::{Adapter, User};
use serde_json::json;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut builder = Adapter::builder(std::env::var("AUTH_SERVER_URL")?)
.cookie_secret(std::env::var("COOKIE_SIGNING_KEY")?) // 32 bytes or more
.service_secret(std::env::var("API_SERVICE_TOKEN")?) // the auth API's API_SERVICE_TOKEN
.jwks_kid(std::env::var("JWKS_KID")?);
if let Ok(issuer) = std::env::var("AUTH_SERVER_ISSUER") {
builder = builder.auth_server_issuer(issuer);
}
let auth = builder.build()?;
let app = Router::new()
// Your own route, behind the adapter's guard. require_auth answers 401 without a session.
.route(
"/api/me",
get(|user: User| async move { Json(json!({ "id": user.id, "roles": user.roles })) })
.route_layer(auth.require_auth()),
)
// The auth routes, at /auth, which is where the frontend SDKs call.
.nest("/auth", auth.router());
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
// Connect info gives the adapter the peer address to forward to the auth API.
axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.await?;
Ok(())
}
  • Adapter::builder(url) takes the auth API URL; .build() returns a BuildError on an invalid URL or a secret shorter than 32 bytes, so a misconfigured app fails to boot.
  • auth.router() serves every auth route. Nest it at /auth.
  • auth.require_auth() is a tower layer. It answers 401 {"error":"unauthenticated"} unless the request carries a session, and puts a User in the request extensions, which handlers take as an extractor.
  • User has id, roles, email, phone (both Option<String>), and token (the auth API access token). On a bearer request email and phone are None, because the access token does not carry them.
  • Serve with into_make_service_with_connect_info::<SocketAddr>(). Without it the adapter has no peer address and forwards no client address to the auth API.

There is no role layer in the crate. Check user.roles in your handler. For a web app on another origin, add credentialed CORS for that origin (for example tower-http’s CorsLayer).


The adapter reads no environment variables itself. The names below are the ones the Seamless starters, the conformance reference app, and seamless init use, so a project the CLI wrote already has them in .env.

Variable Builder method Value
AUTH_SERVER_URL Adapter::builder(url) Where your server reaches the auth API, for example http://localhost:5312, or the Auth Server URL from the portal
AUTH_SERVER_ISSUER auth_server_issuer Only when the auth API signs as a different URL (the seamless init --local Docker stack signs as http://auth:5312)
COOKIE_SIGNING_KEY cookie_secret Any secret of 32 bytes or more, for example openssl rand -base64 48
API_SERVICE_TOKEN service_secret The auth API’s API_SERVICE_TOKEN (self-hosted), or a service token generated in the portal (managed)
JWKS_KID jwks_kid kid header on the service tokens the adapter signs, not the auth API’s signing key id; dev-main on a local stack

The builder also has:

Builder method Default Purpose
audience the issuer Expected aud of the auth API’s tokens
cookie_domain none Cookie Domain
insecure_cookies false Drops Secure, for local development over plain HTTP on a host other than localhost
same_site None, or Lax with insecure_cookies Cookie SameSite
allowed_origins none The only cross-origin callers allowed to change state while cookies are SameSite=None
session_cookie_names seamless-access, seamless-refresh Session cookie names
flow_cookie_names seamless-ephemeral Sign-in flow cookie names
deliver none Send OTP codes and magic links through your own transports
trusted_proxies, resolve_client_ip the connecting peer The end user’s address; use TrustedProxies::new(["10.0.0.0/8"])? behind a proxy
disable_manifest_fetch false Use only the manifest embedded in this version
http_client 15 second timeout, no redirects Outbound reqwest::Client

Without deliver, the auth API sends OTP codes and magic links. With it, the adapter asks the API for the message and hands it to you as a Delivery (kind, to, and the optional token, magic_link_url, and sign_in_url). A returned error answers 502 delivery_failed.

.deliver(move |d: seamless_auth::Delivery| {
let mailer = mailer.clone();
async move { mailer.send(&d.to, &d.kind, d.token.as_deref(), d.magic_link_url.as_deref()).await }
})

auth.console_router() serves the Seamless admin dashboard at /console from your API, proxied from the auth API. Merge it rather than nesting it, because a router nested at /console never sees /console/:

let app = Router::new()
.nest("/auth", auth.router())
.merge(auth.console_router());

Add your API’s origin to the auth server’s ORIGINS so passkey ceremonies started in the console verify.


The same /auth routes serve two contracts:

  • Browsers get cookies. The adapter keeps the auth API’s tokens in signed HttpOnly cookies and refreshes the session silently on the auth routes. Page scripts never see a token.
  • Native clients (mobile apps, CLIs) send x-seamless-auth-transport: bearer. Session-issuing responses then return token and refreshToken in the body and set no cookie, and the client refreshes with POST /auth/refresh and Authorization: Bearer <refreshToken>.

require_auth() accepts either the session cookie or an auth API access token in Authorization: Bearer, and the cookie wins when both are present. It reads the Cookie header itself, so it works on any route, and it does not refresh. auth.authenticate(&headers) runs the same check without answering the request.

While cookies are SameSite=None, a state-changing request the guard admits on the cookie from another site (Sec-Fetch-Site: cross-site, or an Origin outside allowed_origins) answers 403 cross_site_request_blocked. The auth routes only accept JSON request bodies (application/json or a +json type); anything else answers 415 unsupported_media_type. The frontend SDKs already send JSON.

See Bearer Transport For Native Clients for the full contract, which every adapter shares.


Every frontend SDK calls ${apiHost}/auth/..., so point it at this server’s origin, for example <AuthProvider apiHost="http://localhost:8080"> in React. The same apiHost option exists in Angular, Vue, Svelte, and React Native. Browser requests to your own protected routes need credentials: 'include' when the web app runs on another origin.


seamless-auth (crates.io) Auth API Rust Axum
0.2.0 v0.18.0 or later 1.88+ 0.8

Which routes the adapter serves comes from the adapter manifest the auth API publishes at /.well-known/seamless-adapter.json, first served by auth API v0.18.0, so a new API route works without a new crate release. The adapter fetches it on the first request and keeps it for the life of the process. If the API serves none, it falls back to the copy embedded in the crate. Run auth API v0.18.0 or later: the adapter sends the OTP and magic-link send routes as POST, which v0.17.0 answers with 404. seamless-cli 0.22.0 and later scaffold v0.18.0. In a project generated by an older CLI, bump the ghcr.io/fells-code/seamless-auth-api image tag to v0.18.0.


The adapter is held to the adapter conformance suite in seamless-cli (verify/CONFORMANCE.md), the same suite the Express and Fastify adapters pass, in CI on every change. To run it yourself you need Docker, a checkout of seamless-auth-api, and the seamless CLI:

Terminal window
PORT=8080 AUTH_SERVER_URL=http://localhost:5312 AUTH_SERVER_ISSUER=http://auth-api:5312 \
API_SERVICE_TOKEN=verify-dev-service-token-not-a-real-secret \
COOKIE_SIGNING_KEY=verify-dev-service-token-not-a-real-secret JWKS_KID=dev-main \
cargo run --example refapp &
SEAMLESS_API_DIR=../seamless-auth-api seamless verify --adapter-url=http://localhost:8080

seamless-templates has an Axum api starter (templates/api/axum, status beta) built on this crate, which seamless init selects with --axum or --api=axum. It ships in templates v0.18.0, which seamless-cli 0.22.0 and later scaffold together with auth API v0.18.0:

Terminal window
npx seamless-cli init my-app --local --yes --email=you@example.com --axum

Use require_auth() or authenticate rather than decoding the session cookie or the access token yourself. They check the cookie signature, the auth API’s JWKS signature, issuer, audience, expiry, and token type, and the layer adds the cross-site rules above. A hand-rolled jsonwebtoken check usually misses one of those, and the cookie format is internal to the adapter.