Rust
Verify Base IdP tokens in a Rust service — Axum middleware, offline verification, no secret required.
A Rust service behind a frontend is verify-only. It verifies the PASETO
token offline against published public keys and serves the request. No
secret. No required env. This guide walks through the integration in Axum,
which is the most common Rust web framework, but the pattern adapts to any
tower::Service-based stack.
Scaffold
npx base-idp create --stack rustThis writes src/base_idp.rs, a Cargo.add.txt hint, and a setup readme
into ./base-idp-rust/. Move the Rust file into your project.
Add the crate
cargo add base-idpOr, during development inside the monorepo:
[dependencies]
base-idp = { git = "https://github.com/squareexp/base-idp", package = "base-idp" }You also need axum, tokio, and tower-http if you do not already.
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"No required env
# Verify-only. Nothing required.
# Optional: pin the audience.
# BASE_IDP_CLIENT_ID=sq_live_yourappThe verification module
use base_idp::{Client, Config, Error, Principal, VerifyOptions};
/// Verify a Base IdP access token offline. Returns the verified principal
/// or an Error explaining why the token was rejected.
pub async fn verify(client: &Client, token: &str) -> Result<Principal, Error> {
client
.verify_access_token(token, VerifyOptions::default())
.await
}App state
The Base IdP client is cheap to build once and reuse. Put it in your Axum
AppState:
use std::sync::Arc;
use base_idp::Client;
#[derive(Clone)]
pub struct AppState {
pub base_idp: Arc<Client>,
}
impl AppState {
pub fn new() -> anyhow::Result<Self> {
let client = Client::new(base_idp::Config::from_env()?)?;
Ok(Self {
base_idp: Arc::new(client),
})
}
}Arc lets you clone the state into each request handler cheaply.
Middleware
Axum has a few ways to write middleware. The simplest for auth is
axum::middleware::from_fn_with_state.
use std::sync::Arc;
use axum::{
extract::{Request, State},
http::{header::AUTHORIZATION, StatusCode},
middleware::Next,
response::Response,
};
use base_idp::{Principal, VerifyOptions};
use crate::state::AppState;
pub async fn require_auth(
State(state): State<AppState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
let token = req
.headers()
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer "))
.ok_or(StatusCode::UNAUTHORIZED)?;
let principal = state
.base_idp
.verify_access_token(token, VerifyOptions::default())
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
req.extensions_mut().insert(Arc::new(principal));
Ok(next.run(req).await)
}Wiring it up
use axum::{
routing::{get, post},
Router,
};
use crate::state::AppState;
mod middleware;
mod routes;
mod state;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let state = AppState::new()?;
let public = Router::new()
.route("/healthz", get(routes::healthz))
.route("/v1/auth/idp-exchange", post(routes::idp_exchange));
let protected = Router::new()
.route("/v1/me", get(routes::me))
.route("/v1/admin", get(routes::admin))
.layer(axum::middleware::from_fn_with_state(
state.clone(),
middleware::auth::require_auth,
));
let app = Router::new()
.merge(public)
.merge(protected)
.with_state(state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
axum::serve(listener, app).await?;
Ok(())
}Reading the principal in handlers
The middleware attaches the principal to the request as an extension. Pull
it out with an Extension extractor.
use std::sync::Arc;
use axum::{Extension, Json};
use base_idp::Principal;
use serde::Serialize;
#[derive(Serialize)]
pub struct MeResponse {
id: String,
email: Option<String>,
name: Option<String>,
}
pub async fn me(Extension(p): Extension<Arc<Principal>>) -> Json<MeResponse> {
Json(MeResponse {
id: p.subject.clone(),
email: p.email.clone(),
name: p.display_name.clone(),
})
}Scope enforcement
The VerifyOptions::required_scope field rejects tokens missing a scope.
For per-route scope checks, do them in the handler.
use std::sync::Arc;
use axum::{Extension, http::StatusCode, response::Json};
use base_idp::Principal;
use serde_json::{json, Value};
pub async fn admin(
Extension(p): Extension<Arc<Principal>>,
) -> Result<Json<Value>, StatusCode> {
if !p.scopes.iter().any(|s| s == "admin") {
return Err(StatusCode::FORBIDDEN);
}
Ok(Json(json!({ "status": "ok" })))
}A mobile-to-backend exchange endpoint
The pattern used by Square products: a mobile app sends the session it got from Base IdP, and the Rust backend verifies it, upserts a user, and issues its own session token.
use axum::{extract::State, http::StatusCode, response::Json};
use base_idp::VerifyOptions;
use serde::{Deserialize, Serialize};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct ExchangeRequest {
pub access_token: String,
pub refresh_token: Option<String>,
}
#[derive(Serialize)]
pub struct ExchangeResponse {
pub user_id: String,
pub session_token: String,
}
pub async fn idp_exchange(
State(state): State<AppState>,
Json(req): Json<ExchangeRequest>,
) -> Result<Json<ExchangeResponse>, StatusCode> {
let principal = state
.base_idp
.verify_access_token(&req.access_token, VerifyOptions::default())
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// Your application logic:
let user_id = upsert_user(&principal).await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let session_token = mint_session(&user_id).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(ExchangeResponse { user_id, session_token }))
}
async fn upsert_user(_p: &base_idp::Principal) -> anyhow::Result<String> {
// Insert/update in your database, return the user id.
Ok("usr_01HRZ".into())
}
fn mint_session(_user_id: &str) -> anyhow::Result<String> {
// Sign your own JWT or store a session row, return the token.
Ok("sess_01HRZ".into())
}This endpoint is public — the Base IdP access token itself is what proves the request is legitimate. Adding the auth middleware would lock mobile apps out.
A complete service
src/
├── main.rs
├── state.rs
├── middleware/
│ ├── mod.rs
│ └── auth.rs
└── routes/
├── mod.rs
├── healthz.rs
├── idp_exchange.rs
├── me.rs
└── admin.rsFive route files, one middleware, one state module. The pattern scales — add more handlers, the auth and state code stay the same.
Errors you might see
failed to fetch public keys
The service could not reach /v1/keys/paseto-v4-public. Check network, check
that the issuer URL is correct.
Every request returns 401
Decode the incoming token: npx base-idp token <token>. Check aud. If it
does not match the client id you pinned, the mobile app is using the wrong
client id.
signature error: Verification equation was not satisfied
The verifier is using the wrong public key, the wrong implicit assertion, or
the wrong audience. The SDK handles all of this — if you see this error,
you are probably not on the official SDK. Switch to the base-idp crate.