In this article
November 9, 2026
November 9, 2026

How to add authentication to a Rust axum app with WorkOS AuthKit

Use the WorkOS Rust SDK to sign users in with AuthKit, verify their session on every request, and check webhook signatures in an axum service.

Explore with AI
Open in ChatGPT
Open in Claude
Open in Perplexity

Sending a user to AuthKit and back is the easy half of authentication in a Rust backend. The half your service owns is deciding, on every request, whether the cookie in front of you still belongs to a live session, and on every webhook, whether the JSON really came from WorkOS. The WorkOS Rust SDK covers both: a sealed session cookie verified locally against your environment's signing keys, and a webhook verifier that checks the signature before you touch the payload.

The examples use axum, but nothing depends on it. Anything that hands you a query string, a cookie and the raw request body will do.

The sign-in flow. The login handler builds a PKCE authorization URL, stores the state and code verifier in a short-lived sealed cookie, and redirects to AuthKit. After the user signs in, AuthKit redirects to the callback with a code. The callback checks the state, exchanges the code with the verifier, and sets a sealed wos_session cookie. On every later request, session().authenticate() unseals the cookie and verifies the access token locally against cached signing keys.
Sign-in happens once; verification happens on every request, locally, against a key set the SDK caches.

How do you set up the client?

The crate needs Rust 1.88 or newer on the 2024 edition. Its default features pull in a reqwest transport with rustls; enable native-tls instead if you want the platform TLS stack.

  
cargo add workos
cargo add axum tokio --features tokio/full
cargo add axum-extra --features cookie
cargo add serde --features derive
cargo add tracing
  

cargo add workos picks up the latest release, which is what you want: the SDK ships fixes regularly, so let Cargo track minor versions.

Build one Client at startup and share it. It's cheap to clone and designed to be passed across handlers and tasks:

  
use tokio::sync::mpsc;
use workos::{Client, EventSchema};

#[derive(Clone)]
struct AppState {
    workos: Client,
    cookie_password: String,
    webhook_secret: String,
    redirect_uri: String,
    events: mpsc::Sender<EventSchema>,
}

fn app_state(events: mpsc::Sender<EventSchema>) -> Result<AppState, workos::Error> {
    let workos = Client::builder()
        .api_key(std::env::var("WORKOS_API_KEY").expect("WORKOS_API_KEY"))
        .client_id(std::env::var("WORKOS_CLIENT_ID").expect("WORKOS_CLIENT_ID"))
        .try_build()?;

    Ok(AppState {
        workos,
        cookie_password: std::env::var("WORKOS_COOKIE_PASSWORD").expect("WORKOS_COOKIE_PASSWORD"),
        webhook_secret: std::env::var("WORKOS_WEBHOOK_SECRET").expect("WORKOS_WEBHOOK_SECRET"),
        redirect_uri: "https://app.example.com/callback".to_string(),
        events,
    })
}
  

build() panics if the API key contains bytes that aren't valid in an HTTP header; try_build() returns Err(workos::Error::Builder(_)) instead, so a stray newline in a secrets file becomes a startup error you can log. The builder also takes .base_url(), .timeout() (30 seconds by default) and .max_retries() (3 by default).

The cookie password has a strict format: a 64-character hex string, which the SDK turns into a 32-byte AES-256-GCM key. Anything else is rejected. Generate one with openssl rand -hex 32 and store it next to the API key. Sealed cookies use a format specific to the Rust SDK, so a cookie sealed here can't be read by a service using another WorkOS SDK, and the other way around.

How do you send the user to AuthKit?

client.authkit().pkce_authorization_url(...) builds the AuthKit URL without making an HTTP call. It generates a PKCE code verifier and challenge plus a random state value, and hands back all three. Keep the verifier and state in a short-lived cookie, sealed with the same password, so the callback can check them:

  
use axum::{extract::State, response::{IntoResponse, Redirect}};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use serde::{Deserialize, Serialize};
use workos::{seal, AuthKitAuthorizationUrlParams};

#[derive(Serialize, Deserialize)]
struct PendingLogin {
    state: String,
    code_verifier: String,
}

async fn login(State(app): State<AppState>, jar: CookieJar) -> Result<impl IntoResponse, AppError> {
    let auth = app.workos.authkit().pkce_authorization_url(AuthKitAuthorizationUrlParams {
        redirect_uri: app.redirect_uri.clone(),
        provider: Some("authkit".to_string()),
        ..Default::default()
    })?;

    let pending = seal(
        &PendingLogin { state: auth.state, code_verifier: auth.code_verifier },
        &app.cookie_password,
    )?;
    let cookie = Cookie::build(("wos_pending", pending))
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Lax)
        .path("/callback")
        .build();

    Ok((jar.add(cookie), Redirect::to(&auth.url)))
}
  

(AppError here and below is a thin IntoResponse wrapper around workos::Error, with a BadRequest variant for bad input.) The other fields on AuthKitAuthorizationUrlParams cover organization_id, connection_id, login_hint, domain_hint and screen_hint. Leave client_id empty and the client's configured one is used. The redirect_uri has to be registered first, on the Redirects page of the WorkOS dashboard.

How does the callback create a session?

The callback checks the state, then exchanges the code with pkce_code_exchange. On a server client, that call sends your API key as the client secret along with the code verifier. The response carries the user and both tokens, which go straight into a sealed session cookie:

  
use axum::extract::Query;
use workos::{seal_session, unseal, AuthKitPkceCodeExchangeParams, SessionData};

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

async fn callback(
    State(app): State<AppState>,
    jar: CookieJar,
    Query(params): Query<CallbackParams>,
) -> Result<impl IntoResponse, AppError> {
    let pending: PendingLogin = jar
        .get("wos_pending")
        .ok_or(AppError::BadRequest)
        .and_then(|c| Ok(unseal(c.value(), &app.cookie_password)?))?;
    if pending.state != params.state {
        return Err(AppError::BadRequest);
    }

    let auth = app
        .workos
        .authkit()
        .pkce_code_exchange(AuthKitPkceCodeExchangeParams {
            code: params.code,
            code_verifier: pending.code_verifier,
        })
        .await?;

    let sealed = seal_session(
        &SessionData {
            access_token: auth.access_token,
            refresh_token: auth.refresh_token,
            user: Some(auth.user),
            impersonator: auth.impersonator,
        },
        &app.cookie_password,
    )?;

    let session = Cookie::build(("wos_session", sealed))
        .http_only(true)
        .secure(true)
        .same_site(SameSite::Lax)
        .path("/")
        .build();
    let jar = jar
        .remove(Cookie::build("wos_pending").path("/callback"))
        .add(session);

    Ok((jar, Redirect::to("/dashboard")))
}
  

Both tokens arrive as workos::SecretString. Its Debug output is SecretString("<redacted>"), so a tracing line that dumps the whole response doesn't put a bearer token in your logs; call .expose() when you need the raw string. That protection covers values the SDK hands back to you. If you write a custom transport, don't log outgoing requests, because they carry your API key in the Authorization header.

How do you verify the session on every request?

The sealed cookie is opaque to the browser, but it can still be tampered with, replayed, or presented after the session behind it has ended. client.session(sealed, password).authenticate() unseals it and verifies the access token's signature against your environment's signing keys. The check itself runs locally; only the key set crosses the network, and the SDK caches it for 10 minutes, shares one in-flight fetch between concurrent callers, and keeps the last good key set if a refresh fails.

authenticate() never returns an error. Every outcome comes back as a SessionState, with authenticated, needs_refresh and a reason, plus the session's session_id, organization_id, role, permissions, entitlements and user. That shape lets a handler answer the question it actually has, which is what to do with this caller:

  
use workos::SessionState;

enum Gate {
    Allow(SessionState),
    Refresh,
    SignIn,
    Reject,
}

async fn gate(app: &AppState, sealed: &str) -> Gate {
    let state = app
        .workos
        .session(sealed, app.cookie_password.clone())
        .authenticate()
        .await;

    if state.authenticated {
        return Gate::Allow(state);
    }
    match state.reason.as_str() {
        // The access token expired; trade the refresh token for a new one.
        "session_expired" => Gate::Refresh,
        // No cookie: a normal first visit.
        "no_session_cookie_provided" => Gate::SignIn,
        // Tampered, sealed under another password, or a token that failed
        // verification. Clear the cookie and log it.
        _ => Gate::Reject,
    }
}
  

session_expired is the one that sets needs_refresh. invalid_session_cookie and invalid_jwt both mean a credential failed verification, which is worth a log line; a missing cookie is just someone who hasn't signed in. An API route maps SignIn and Reject to a 401, and a browser route redirects to /login.

One rule for building authorization on top: verification binds the session to the signed token's subject, the user ID. Treat the rest of the cookie's profile data, email included, as display data. If you check an admin allowlist or an email domain, look the user up by the verified ID in the WorkOS API or your own database.

What a handler does with each authenticate() result. Authenticated: allow the request. session_expired, which sets needs_refresh: call refresh() and write the new cookie. no_session_cookie_provided: send the user to sign in. invalid_session_cookie or invalid_jwt: clear the cookie, reject the request and log it.
Four outcomes, four responses: the difference worth keeping is between no credential, which is a normal visit, and a credential that failed verification, which belongs in your logs.

How do refresh and sign-out work?

refresh() trades the refresh token at WorkOS and re-seals the result, so your job is to write the new cookie:

  
use workos::SessionRefreshOptions;

let result = app
    .workos
    .session(sealed, app.cookie_password.clone())
    .refresh(SessionRefreshOptions::default())
    .await?;

if result.authenticated {
    // Set wos_session to result.sealed_session and retry the request.
} else if result.reason == "refresh_token_revoked" {
    // The session ended at WorkOS: clear the cookie and send the user to sign in.
} else {
    // "refresh_failed" or "no_refresh_token": don't sign the user out over a transient error.
}
  

For sign-out, logout_url(Some("https://app.example.com")).await builds the WorkOS logout URL for the session's ID. It needs a valid session to do that and returns an error if the session isn't authenticated or has no session ID. Build the URL first, then send the redirect to it in a response that also clears your wos_session cookie, so the user is signed out of your app even if they close the tab mid-redirect.

How do you verify webhook signatures?

An unverified webhook endpoint is a public, unauthenticated write endpoint. WorkOS signs each delivery with a WorkOS-Signature header carrying a timestamp (t=) and an HMAC SHA256 signature (v1=) of the timestamp, a period and the raw body, keyed with your endpoint's secret. WebhookVerifier checks both and only then deserializes the event:

  
use axum::http::{HeaderMap, StatusCode};
use workos::WebhookVerifier;

async fn webhooks(State(app): State<AppState>, headers: HeaderMap, body: String) -> StatusCode {
    let signature = headers
        .get("workos-signature")
        .and_then(|value| value.to_str().ok())
        .unwrap_or_default();

    let event = match WebhookVerifier::new(app.webhook_secret.clone()).construct_event(signature, &body) {
        Ok(event) => event,
        Err(err) => {
            tracing::warn!(error = %err, "rejected unverified webhook");
            return StatusCode::BAD_REQUEST;
        }
    };

    // Hand off to a background worker and answer quickly.
    if app.events.send(event).await.is_err() {
        return StatusCode::INTERNAL_SERVER_ERROR;
    }
    StatusCode::OK
}
  

Take the body as a String, not a deserialized struct: the signature covers the exact bytes WorkOS sent, and parsing first breaks the check. The comparison is constant-time, the default timestamp tolerance is 180 seconds (.with_tolerance() changes it), and an unknown component in the header is rejected rather than ignored. Failures come back as Error::Webhook with messages such as webhook not signed, timestamp outside tolerance and no valid signature found. In tests, .with_clock() pins the clock to a fixture's timestamp, so a replay test needs no sleep.

The rest follows the WorkOS webhooks guide. Return 200 quickly: a non-2xx counts as a failed delivery and is retried up to 6 times with exponential backoff over up to 3 days in production. The same event can arrive more than once and out of order, so the worker should record each event.id it has processed (an INSERT … ON CONFLICT DO NOTHING against a table of IDs is enough) and skip repeats.

What should you know about errors and retries?

Every API call returns Result<_, workos::Error>, and errors carry structured metadata: status(), code(), request_id() and helpers such as is_rate_limited(). Log request_id() on anything you might ask WorkOS support about.

Retries are on by default for 429s, 5xx responses and retryable transport errors, up to max_retries, with backoff that honors Retry-After. The client only retries requests it considers safe to repeat: GET, HEAD and OPTIONS, plus requests that carry an Idempotency-Key. A POST, PUT, PATCH or DELETE without a key is sent once. The SDK never adds that header on its own; you set it per call through RequestOptions. Before you do, check that the endpoint deduplicates: WorkOS documents deduplication for POST /audit_logs/events. A key on an endpoint that doesn't honor it makes the request retry-eligible in the client without making it safe to repeat on the server.

What doesn't local verification cover?

Revocation. Because the session check is local, it can't see that a session was ended at WorkOS until the access token expires. A session revoked in the dashboard keeps passing authenticate() for the rest of the token's lifetime, which is set by your environment's access token duration.

If that window matters, close it where it counts rather than on every request: subscribe to session and user events on the webhook endpoint you just built, and re-check with WorkOS before genuinely destructive operations. Verifying cheaply on every request and checking revocation on a few is a better trade than a round trip to WorkOS on every page load.

The crate is MIT licensed and on GitHub, with the generated API surface documented on docs.rs. For a command-line app instead of a web service, see How to add auth to your Rust CLI using WorkOS.

Further reading