Full-stack authentication for Rust (Axum), React & Next.js
JWT · MFA · OAuth · Sessions · Multi-Tenant · Pure-Rust Crypto · WebAssembly Edge Verification
GitHub · Issues · Quick Start · docs.rs · API Reference · Examples
Important
🚧 Early development. This repository is under active construction. The crates are not yet published to crates.io or npm, and the API described below is the target surface the project is building toward — not all of it exists yet. Badges, version numbers, and install commands reflect the intended 1.0 release. See the development plan for current status.
bymax-auth is a complete authentication and authorization solution shipped as a lean Cargo workspace (crates.io) plus a matching npm package — covering everything from an Axum backend to React hooks, Next.js route handlers, and an edge JWT verifier compiled to WebAssembly.
Instead of stitching together a dozen crates and packages for JWT, MFA, OAuth, sessions, password reset, and brute-force protection, you add one library and get a production-ready auth system that spans your entire stack — with the frontend types generated from the Rust source, so the server and the client can never drift.
- 🎯 One workspace, full stack — A backend crate (
bymax-auth) and an npm package (@bymax-one/rust-auth) in lockstep. Shared types and constants are generated from Rust viats-rsand re-checked in CI — zero manual synchronization, zero drift. - 🔌 Your database, your rules — The library defines async traits (
UserRepository,EmailProvider). You implement them withsqlx,SeaORM,Diesel, or anything else. No ORM dependency, no driver baked in. - 🦀 Pure-Rust crypto only — Password hashing, MFA encryption, TOTP, and token generation run entirely on RustCrypto (
scrypt,argon2,aes-gcm,hmac,sha2,subtle). Noring, no OpenSSL, no C bindings, and#![forbid(unsafe_code)]on every first-party crate. - ⚡ Pay for what you use — A tiny always-compiled core; every heavy integration (Redis, Axum,
reqwest, MFA) is a Cargo feature or a trait you plug. A per-feature dependency budget is enforced in CI, so a minimal build pulls a minimal tree. - 🏢 Multi-tenant ready — Every operation is scoped by
tenant_id, taken from a resolver and never the request body. A separate platform-admin identity domain is isolated from tenant users from day one. - 🌐 Edge-native — The exact same HS256 verifier that runs on the server compiles to
wasm32-unknown-unknownand runs in the Next.js Edge runtime with no network call — one implementation, server and edge, proven by tests.
cargo add bymax-auth --features "argon2,sessions,mfa,oauth,oauth-reqwest,redis,axum"
pnpm add @bymax-one/rust-authNote
Production status. Bymax Live — a Rust-backend + React/Next.js application — is the project's first production consumer (the dogfood target). It will run the library as its authentication and authorization layer (sessions, MFA, OAuth, platform admin, Redis), so the wire contract, the security invariants, and server/edge JWT parity are validated against real traffic rather than synthetic tests alone.
- ✅ Registration & Login — Email/password hashed with scrypt and Argon2id (configurable, rehash-on-verify, self-describing PHC strings)
- ✅ HS256 Access + Opaque Refresh Tokens — Atomic rotation with a grace window for concurrent requests; refresh tokens are opaque and never JWTs
- ✅ Multi-Factor Authentication — TOTP with
otpauth://URI + QR, hashed recovery codes, and a temp-token challenge flow - ✅ OAuth 2.0 — Google built-in, PKCE + single-use
state, account create/link/reject decisioning, MFA branch - ✅ Password Reset — Token-link or OTP, configurable per deployment, uniformly anti-enumerating
- ✅ Email Verification — OTP-based with atomic resend cooldown
- ✅ Pure-Rust Crypto — RustCrypto only (scrypt/Argon2id, AES-256-GCM, HMAC-SHA1 TOTP, HS256); no
ring/OpenSSL/C on any path - ✅ Brute-Force Protection — Fixed-window Redis counters keyed on
HMAC(tenant:email)— no PII in keys - ✅ Session Management — Active-session tracking with FIFO eviction, device/IP metadata, and new-session alerts
- ✅ HttpOnly Cookies —
Secure-by-default, refresh cookie path-scoped withSameSite=Strict, plus a non-HttpOnlyhas_sessionsignal - ✅ Constant-Time Comparisons — Every secret/token/OTP/recovery-code compare goes through
subtle— never==on secret bytes - ✅ JWT Revocation — Instant access-token revocation via a Redis
jtiblacklist - ✅ Anti-Enumeration — Identical status, body, and timing for known vs. unknown accounts, with an always-run sentinel hash
- ✅ Refresh-Token Reuse Detection — Replaying a consumed token revokes that login's whole lineage, and only that lineage
- ✅ Bulk Access-Token Revocation — A password reset advances a per-user token epoch, invalidating every outstanding access token in one write
- ✅ Absolute Session Lifetime — Optional hard cap on how long one login can be extended by rotation
- ✅ Cross-Site Request Refusal — Cookie-authenticated writes from an untrusted origin are rejected (matters under
SameSite=None) - ✅ Breached-Password Refusal — Optional Have I Been Pwned check by k-anonymity range, over the crate's own
HttpClientseam - ✅ Per-Route Rate Limiting — A governor layer per route, values pinned to the shared cross-implementation contract
- ✅ Tenant Isolation — All operations scoped by
tenant_idvia a configurableTenantIdResolver(anti-spoofing: the body is ignored) - ✅ Platform Admin Auth — A separate identity domain with its own claims, sessions, and role hierarchy — fully isolated from tenant users
- ✅ User Invitations — Single-use tokenized invites with role + tenant assignment and re-validation on accept
- ✅ Role-Based Access Control — Hierarchical roles enforced by the
RequireRole<R>extractor
- ✅ Full-Stack Typed — Rust domain types → TypeScript via
ts-rs, drift-gated in CI - ✅ Two Published Artifacts —
bymax-authon crates.io,@bymax-one/rust-authon npm, versioned in lockstep - ✅ Builder + Validated Config — Assemble the engine with
AuthEngineBuilder;build()fails fast with a typedConfigError - ✅ Trait-Pluggable — Bring your own database, email transport, HTTP client, and stores
- ✅ Axum-Native Extractors —
FromRequestPartsguards (AuthUser,RequireRole<R>, …) — no middleware soup, no Passport equivalent
The backend ships on crates.io; the frontend ships on npm. The WebAssembly edge verifier is bundled inside the npm package (never published as a standalone crate).
A thin facade over a set of focused internal crates. You enable only the capabilities you need; the always-compiled core stays tiny.
| Feature | Enables |
|---|---|
scrypt (default) |
Default password KDF (parity baseline) — drop-in scrypt |
argon2 |
Argon2id KDF + the hardened AuthConfig::secure_defaults() profile |
sessions |
Redis-backed session tracking + FIFO eviction |
mfa |
TOTP setup/verify/challenge, recovery codes (AES-256-GCM secret at rest) |
oauth |
OAuth 2.0 + PKCE orchestration (transport injected — zero HTTP deps) |
oauth-reqwest |
Bundled reqwest-backed HttpClient (opt-in; omit to supply your own) |
platform |
Platform-admin identity domain (login/MFA/sessions) |
invitations |
Tokenized user invitations |
redis |
Canonical Redis stores (redis + deadpool-redis) with atomic Lua |
axum |
HTTP router, extractors, DTO validation, cookie delivery, per-route rate limiting, WebSocket tickets |
client |
Native Rust typed auth client |
full |
Every backend feature above |
Note
There is no core feature — the engine is always compiled and individual flows are runtime toggles. When a feature is disabled, its crates and dependencies are never linked (a no-MFA build pulls in none of aes-gcm/sha1).
One package, four entry points — import only what your app needs:
| Subpath | Import | Purpose | Peer deps |
|---|---|---|---|
| Shared | @bymax-one/rust-auth/shared |
Types, constants, error codes — generated from Rust | None |
| Client | @bymax-one/rust-auth/client |
Native-fetch client with single-flight refresh |
None |
| React | @bymax-one/rust-auth/react |
AuthProvider + hooks |
React 19 |
| Next.js | @bymax-one/rust-auth/nextjs |
Proxy, route handlers, WASM-backed edge JWT verify | Next.js 16 |
shared (generated, zero deps)
↗ ↖
client (Rust crate: bymax-auth-client)
↑
react
↑
nextjs ──→ bymax-auth-wasm (edge HS256 verifier)
Tip
Prefer to learn from working code? The examples/ directory ships runnable apps — axum-minimal, axum-mfa, axum-oauth-google, react-vite, and nextjs — each built and linted in CI so they never rot.
# Cargo.toml
[dependencies]
bymax-auth = { version = "1", features = ["argon2", "sessions", "mfa", "oauth", "oauth-reqwest", "redis", "axum"] }The library defines what it needs — your app provides how. Map the abstract AuthUser contract onto your own schema; the only invariant is that password_hash is persisted exactly as the library produced it (a self-describing PHC string).
use async_trait::async_trait;
use bymax_auth::{
AuthUser, CreateUserData, CreateWithOAuthData, RepositoryError, UpdateMfaData, UserRepository,
};
pub struct PgUserRepository {
pool: sqlx::PgPool,
}
#[async_trait]
impl UserRepository for PgUserRepository {
async fn find_by_email(
&self,
email: &str,
tenant_id: &str,
) -> Result<Option<AuthUser>, RepositoryError> {
sqlx::query_as::<_, AuthUser>(
"SELECT * FROM users WHERE email = $1 AND tenant_id = $2",
)
.bind(email.to_lowercase())
.bind(tenant_id)
.fetch_optional(&self.pool)
.await
.map_err(RepositoryError::backend)
}
async fn create(&self, data: CreateUserData) -> Result<AuthUser, RepositoryError> {
// INSERT … RETURNING *, storing data.password_hash verbatim.
todo!()
}
async fn update_password(&self, id: &str, password_hash: &str) -> Result<(), RepositoryError> {
todo!()
}
async fn update_mfa(&self, id: &str, data: UpdateMfaData) -> Result<(), RepositoryError> {
todo!()
}
async fn find_by_oauth_id(
&self,
provider: &str,
provider_id: &str,
tenant_id: &str,
) -> Result<Option<AuthUser>, RepositoryError> {
todo!()
}
async fn create_with_oauth(
&self,
data: CreateWithOAuthData,
) -> Result<AuthUser, RepositoryError> {
todo!()
}
// … link_oauth, update_status, update_email_verified, etc.
}Email delivery is fully delegated — the library never imports a mailer SDK. It passes structured data (token, OTP, session info, invite data), never rendered HTML.
use async_trait::async_trait;
use bymax_auth::{EmailProvider, InviteData, SessionInfo};
pub struct ResendEmailProvider { /* client, from, app_url */ }
#[async_trait]
impl EmailProvider for ResendEmailProvider {
async fn send_password_reset_token(&self, email: &str, token: &str, locale: Option<&str>) {
// Render and send with your transport of choice (Resend/SES/SMTP).
}
async fn send_email_verification_otp(&self, email: &str, otp: &str, locale: Option<&str>) {
// …
}
async fn send_new_session_alert(&self, email: &str, info: &SessionInfo, locale: Option<&str>) {
// …
}
async fn send_invitation(&self, email: &str, data: &InviteData, locale: Option<&str>) {
// …
}
// … password-reset OTP, MFA enabled/disabled notifications, etc.
}Warning
Any consumer-supplied value (display name, tenant name, inviter name, device string) interpolated into an HTML email body MUST be escaped to prevent stored XSS. Tokens and OTPs are library-generated and safe; the placeholders you fill in are not.
build() runs full startup validation and returns Result<AuthEngine, ConfigError> — a misconfiguration fails fast at boot, never at the first request.
use std::sync::Arc;
use bymax_auth::{
auth_router, AuthConfig, AuthEngine, AxumAuthConfig, Environment, RedisStores,
};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let engine = AuthEngine::builder()
.config(AuthConfig::secure_defaults()) // or AuthConfig::nest_compat_defaults()
.environment(Environment::Production) // resolves Secure cookies + prod redirect checks
.user_repository(Arc::new(PgUserRepository { pool }))
.email_provider(Arc::new(ResendEmailProvider { /* … */ }))
.redis_stores(Arc::new(RedisStores::connect("redis://127.0.0.1").await?))
.oauth_provider(Arc::new(bymax_auth::GoogleProvider::new(/* client id/secret */)))
.http_client(Arc::new(bymax_auth::ReqwestHttpClient::default()))
.build()?;
let app = auth_router(AxumAuthConfig::new(Arc::new(engine)));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}Guards are Axum FromRequestParts extractors — compose them straight into a handler signature. They read the token from a cookie or Authorization: Bearer header — never from the query string.
use axum::Json;
use bymax_auth::{AuthUser, RequireRole, SafeAuthUser, SelfOrAdmin};
// Any authenticated dashboard user.
async fn profile(user: AuthUser) -> Json<SafeAuthUser> {
Json(user.into_safe())
}
// Admins (and anything above them in the hierarchy) only.
async fn list_users(_: RequireRole<{ "admin" }>) -> &'static str {
"ok"
}
// The resource owner, or an admin.
async fn update_user(_: SelfOrAdmin) -> &'static str {
"ok"
}Build an AuthClient once, hand it to AuthProvider; the hooks read the context it populates — exactly as in @bymax-one/nest-auth, so existing consumers change only the import specifier.
// app/providers.tsx
'use client'
import { AuthProvider } from '@bymax-one/rust-auth/react'
import { createAuthClient } from '@bymax-one/rust-auth/client'
const authClient = createAuthClient({
// Same-origin calls flow through the Next.js proxy under `/api/auth/*`.
// Set `baseUrl` only for a cross-origin API.
})
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AuthProvider client={authClient} onSessionExpired={() => (location.href = '/login')}>
{children}
</AuthProvider>
)
}// app/(dashboard)/profile.tsx
'use client'
import { useAuth, useSession } from '@bymax-one/rust-auth/react'
export function Profile() {
const { user, status } = useSession()
const { logout } = useAuth()
if (status === 'loading') return <div>Loading…</div>
if (status === 'unauthenticated') return <div>Please log in</div>
return (
<div>
<p>Welcome, {user.name}!</p>
<button onClick={() => logout()}>Sign out</button>
</div>
)
}Mount the auth proxy at the project root and expose the /api/auth/* route handlers. The proxy verifies the access token at the edge using the WebAssembly build of the same HS256 verifier the backend uses — no network round-trip.
// proxy.ts — Next.js 16 Edge middleware
import { createAuthProxy } from '@bymax-one/rust-auth/nextjs'
export const { proxy } = createAuthProxy({
publicRoutes: ['/', '/auth/login', '/auth/register'],
protectedRoutes: [
{ pattern: '/dashboard/:path*', allowedRoles: ['admin', 'member'] },
{ pattern: '/admin/:path*', allowedRoles: ['admin'] },
],
loginPath: '/auth/login',
apiBase: process.env.API_BASE_URL!,
jwtSecret: process.env.JWT_SECRET, // verified at the edge via WASM
cookieNames: { access: 'access_token', refresh: 'refresh_token', hasSession: 'has_session' },
blockedUserStatuses: ['BANNED', 'INACTIVE', 'SUSPENDED'],
})
export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }Important
The WASM module and verifyJwtToken are server/edge only — never import them into a Client Component. The HS256 secret must never reach the browser; an accidental client import is a security defect (a server-only guard catches it at build time).
// app/api/auth/logout/route.ts
import { createLogoutHandler } from '@bymax-one/rust-auth/nextjs'
export const POST = createLogoutHandler({
apiBase: process.env.API_BASE_URL!,
mode: 'redirect',
loginPath: '/auth/login',
cookieNames: { access: 'access_token', refresh: 'refresh_token', hasSession: 'has_session' },
})Everything is configured through AuthConfig. Two ready-made profiles bundle sensible choices; Default equals nest_compat_defaults().
| Profile | Posture |
|---|---|
AuthConfig::nest_compat_defaults() |
Behavioral parity with @bymax-one/nest-auth out of the box (scrypt, email verification required, brute-force max_attempts = 5) — the Default |
AuthConfig::secure_defaults() |
Hardened opt-in profile (Argon2id, stricter cookies) — available under the argon2 feature |
| Group | Key options | nest-compat default |
|---|---|---|
| jwt | secret (required, ≥ 32 chars), previous_secrets, access_ttl, refresh_expires_in_days, absolute_session_lifetime_days, issuer, audience |
15m, 7d, off, HS256 (pinned), both off |
| password | active_algorithm, scrypt cost_factor / Argon2id memory_kib |
scrypt N=2¹⁷, r=8, p=1 |
| token_delivery | Cookie | Bearer | Both |
Cookie |
| cookies | names, refresh_cookie_path, same_site, trusted_origins, resolve_domains |
HttpOnly, Secure, Strict, [] |
| mfa | encryption_key (32 bytes), previous_encryption_keys, issuer, totp_window, recovery_code_count |
— |
| sessions | enabled, default_max_sessions, max_sessions_resolver |
false, 5 |
| brute_force | max_attempts, window_seconds |
5, 900 |
| rate limiting | AxumAuthConfig::rate_limits — per-route governor limits, pinned to the shared contract |
on, per-route |
| password_reset | method (Token | Otp), otp_length, token_ttl |
Token, 600 s |
| platform | enabled (requires roles.platform_hierarchy) |
false |
| invitations | enabled, token_ttl |
false, 48 h |
| roles | hierarchy (required), platform_hierarchy |
— |
| oauth | google, redirect_allowlist, *_redirect_url |
— |
| controllers | per-group route toggles | feature-driven |
Note
build() validates every cross-field invariant (secret length/entropy, role referential integrity, parameter floors, SameSite=None ⇒ Secure, trusted_origins reachable under the cookie posture, OAuth redirect allow-listing, required stores) and rejects an invalid config with a precise ConfigError.
Tip
Binding tokens to an issuer and an audience. jwt.issuer and jwt.audience are None
by default. Set either and its value is stamped on every token this backend mints — dashboard,
platform and MFA challenge alike — and required on every token it verifies: one carrying a
different value, or none at all, is rejected. That matters with HS256, where the verifier can
also sign: every service holding the secret to check a token can mint one, so audience binding
is what stops a token minted for one service being replayed at another that trusts the same
secret. The check sits at the single verification chokepoint, so a retired signing key does not
waive it.
Two things to know before switching it on. Both backends of a shared deployment must carry the same pair, or they stop accepting each other's tokens. And enabling it invalidates the access tokens already in flight — a window of one access-token lifetime, which clients close by refreshing. An empty string reads as unconfigured rather than as "require the empty issuer".
Rotating the signing secret.
jwt.previous_secretslists secrets retired by a rotation, accepted for verification only. Without it, changingjwt.secretsigns every user out the moment the new configuration rolls out and invalidates every stored recovery-code digest — those are keyed by an HMAC derived from the secret, so users lose the codes they printed and filed. With it, both keep working while tokens issued under the old secret drain, and a rotation becomes a rollout. Remove the entry once the longest-lived token signed under it has expired: every entry is a key that still opens the door.mfa.encryption_keyrotates the same way, through its own list — see below.
Tip
Rotating the MFA encryption key. mfa.previous_encryption_keys lists AES-256 keys retired
by a rotation of mfa.encryption_key. The stored ciphertext carries no key identifier, so
without the list a change of key makes every enrolled user's TOTP secret undecryptable at once,
with no way back — their authenticator simply stops matching. With it, a stored secret that
opened under a retired key is re-encrypted under the current one on the next successful
challenge, so the rotation drains on its own instead of requiring the retired key to stay
configured forever. build() holds each entry to the same bar as the current key (base64,
exactly 32 bytes, and never equal to the current key or to another entry), because a malformed
one would otherwise surface at a user's first challenge rather than at boot. Drop the entry
once your enrolled users have had time to authenticate at least once. nest-auth exposes the
identical option as mfa.previousEncryptionKeys.
Important
The parameters that carry a control's strength are bounded by build(). mfa.totp_window
must be 0..=10 (TotpWindowRange): the window counts 30-second steps on either side of
now, so 2n + 1 codes are valid at once — three at 1, but 121 at 60, which makes a six-digit
code a hundred times easier to guess while the configuration still reads as "MFA enabled".
mfa.recovery_code_count must be 1..=50 (RecoveryCodeCountRange), because zero enrols an
account with no way back if the authenticator is lost. password.scrypt.block_size must be
at least 8 (ScryptBlockSize) and password.scrypt.parallelization at least 1
(ScryptParallelization): the memory cost is 128 * N * r, so a smaller block size divides
the hardness that the cost-factor floor exists to guarantee — invisibly, since the bounded
parameter is still intact. nest-auth enforces the identical ranges.
Important
jwt.access_expires_in must not exceed 30 days, the window a store keeps a bumped token
epoch readable. The epoch is what makes a stateless access token revocable: a password reset
advances it and every token stamped below it stops verifying — but only while the bumped value
is still there. A longer-lived access token would outlive it, current_epoch would fall back
to 0, and a token the reset revoked would verify again. build() refuses the configuration
(ConfigError::AccessLifetimeExceedsEpochRetention) rather than letting it fail open, and
nest-auth enforces the identical bound.
Two options are deliberately off by default, because switching either on changes behaviour for sessions and origins that already exist:
jwt.absolute_session_lifetime_dayscaps how long one login can be extended by rotation. Without it, a client refreshing every fifteen minutes keeps a session alive forever.cookies.trusted_originsis required as soon assame_siteisNone— that is the setting where the browser sends the session cookie cross-site, so an origin has to be authorized. It is also accepted underLax/Strictwhencookies.resolve_domainsis configured: those withhold the cookie cross-site, not cross-origin, so a deployment servingapp.example.comandapi.example.comfrom one.example.comcookie is same-site and the browser does send it. Without a shared cookie domain the list can never be consulted, and is still refused.
The breached-password check is opt-in for a different reason: it is the only part of the
credential path that reaches the network, and a library should not start talking to a third
party because it was upgraded. It rides the crate's own HttpClient seam, so a deployment
supplies the transport it already has:
// Cargo.toml: bymax-auth = { version = "…", features = ["breach"] }
use bymax_auth::HibpBreachChecker;
let engine = AuthEngine::builder()
.config(config)
.user_repository(users)
.redis_stores(stores)
.breach_checker(Arc::new(HibpBreachChecker::new(http_client)))
.build()?;The checker fails open by contract: an unreachable corpus must never stop someone changing their password — least of all during an incident, when changing it is the urgent thing.
The library is a set of crates you embed in your Axum service — a framework-agnostic core wrapped by thin adapters.
┌──────────────────────────────────────────────────────────┐
│ Your Axum Application │
│ │
│ bymax-auth-axum → router · extractors · rate-limit │
│ │ │
│ bymax-auth-core → AuthEngine · services · 14 hooks │
│ ╱ │ ╲ │
│ -crypto -jwt -types (pure-Rust, wasm-safe) │
│ │ │
│ ┌────▼─────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ User │ │ Email │ │ SessionStore │ │
│ │ Repository│ │ Provider │ │ (bymax-auth- │ │
│ │ (yours) │ │ (yours) │ │ redis) │ │
│ └──────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
bindings/bymax-auth-wasm → edge HS256 verifier (npm)
| Principle | Description |
|---|---|
| 🔌 Trait-Driven | Define contracts, inject implementations — works with sqlx, SeaORM, Diesel, or any store |
| 🔒 Secure by Default | scrypt/Argon2id, HttpOnly cookies, jti blacklist, brute-force, anti-enumeration — all on out of the box |
| 🪶 Tiny Mandatory Tree | A small always-compiled core; Redis, Axum, reqwest, and MFA are feature-gated, with a CI dependency budget |
| 🦀 One Crypto, Everywhere | A single pure-Rust HS256 primitive runs on the server and at the edge (WASM) — never two implementations |
| 🌳 wasm-clean boundary | bymax-auth-wasm depends only on -crypto/-jwt/-types; a CI job builds wasm32 to prove it |
| ⚡ Fast & measured | No GC and no FFI boundary; hot paths run in ns–µs while memory-hard KDFs stay tunable — tracked with Criterion benches (details) |
The security architecture is codified as a set of inviolable invariants that the quality gates exist to protect — relaxing one is treated as a vulnerability, not a feature.
Every token carries a type claim that extractors validate before acceptance:
| Token type | Issued when | Accepted by |
|---|---|---|
dashboard |
Successful login or MFA challenge | AuthUser |
platform |
Platform-admin login or MFA challenge | PlatformUser |
mfa_challenge |
Login with MFA enabled (pre-verification) | MFA challenge endpoint |
This prevents token-type-confusion attacks. A dashboard token presented to a platform route is rejected with PlatformAuthRequired (not the generic TokenInvalid), so clients can distinguish wrong-context from expired.
Verification pins header.alg == "HS256" before any signature math — none, RS256, ES256, and every asymmetric algorithm are rejected, and only a symmetric key exists. Refresh tokens are opaque CSPRNG strings, inert without their server-side Redis record, persisted only as sha256(token) — never signed or parsed as JWTs.
Platform admins and tenant users are fully isolated — separate repositories, claims, extractors, and routes. tenant_id is always taken from the configured TenantIdResolver, never the request body, preventing tenant spoofing at the architecture level.
Every read→decide→write on contended state — refresh rotation + grace, ownership-checked revoke, brute-force increment, OTP verify+consume, single-use WebSocket ticket — runs as a single atomic Lua script, closing the concurrency races those flows would otherwise expose.
When integrating bymax-auth in production, verify each of the following:
cookies.resolve_domainsvalidates against an allow-list — theHostheader is never used to derive a parent domainoauth.redirect_allowlistis set so no redirect/callback URL is request-derived (no open redirect)- The MFA
encryption_keyis 32 bytes from a secret manager; the JWTsecretis 32 random bytes, high-entropy - HS256 pinning, constant-time comparisons (
subtle), and CSPRNG (OsRng) are never bypassed - HttpOnly +
Secure(outside development); refresh cookie path-scoped withSameSite=Strict
| Layer | Implementation |
|---|---|
| Password Hashing | RustCrypto scrypt (N=2¹⁷, r=8, p=1 — OWASP's recommended minimum) or argon2 Argon2id (PHC) |
| MFA Encryption | aes-gcm AES-256-GCM with a fresh 12-byte CSPRNG IV per call |
| TOTP | hmac + sha1 per RFC 4226/6238, ±1 step window, anti-replay marked |
| Recovery Codes | Keyed HMAC-SHA-256 digests (never plaintext, never reversible) |
| Token Generation | getrandom/OsRng CSPRNG — 256 bits of entropy |
| Secret Comparison | subtle constant-time — never == on secret bytes |
| JWT | Hand-rolled HS256 (hmac + sha2), jti blacklist via Redis |
| Cookies | HttpOnly, Secure-by-default, SameSite=Strict, path-scoped refresh |
| Brute-Force | Redis atomic fixed-window counters per HMAC(tenant:email) |
| CSRF (OAuth) | 64-hex single-use state (GETDEL) + PKCE code_verifier (S256) |
| Refresh Rotation | Single-use tokens with a grace window; a replay past it revokes that login's whole family lineage |
| Cross-Site Writes | Origin / Sec-Fetch-Site check on cookie-authenticated writes — the gap SameSite=None leaves open |
| Breached Passwords | Optional Have I Been Pwned range check by k-anonymity; only a 5-char SHA-1 prefix leaves the process |
| Rate Limiting | Per-route tower_governor layer, in-process; the limits themselves are pinned to the shared contract |
| Session Lifetime | Optional absolute cap on how long one login can be extended by rotation |
| Edge Verify | Same HS256 primitive compiled to WebAssembly — no network call |
Important
All cryptography uses RustCrypto — no ring, no OpenSSL, no C/C++ bindings on any target — and every first-party crate is #![forbid(unsafe_code)] (the sole exception being the wasm-bindgen glue in the edge binding).
Rust's advantage over a managed runtime (Node, the JVM) is per-operation cost with no garbage collector and no FFI marshalling. bymax-auth is built to spend that advantage deliberately — and to prove it with benchmarks rather than assert it.
| Lever | What it buys |
|---|---|
| No runtime, no GC | Auth work is synchronous CPU over owned bytes — no allocator pauses, no event-loop hops, predictable tail latency under load |
| Pure-Rust RustCrypto | Every primitive is inlined Rust — no C bindings and no per-call FFI boundary to cross (the cost a native Node addon pays on every call) |
| Static where it's hot | Internal services are monomorphized; dynamic dispatch (Arc<dyn _>) is paid only at the host-pluggable trait boundary, never in the inner loop |
| Allocation-aware | Digests return a stack [u8; 32]; fixed-size randomness uses a stack [u8; N] instead of a Vec; transient key material lives in Zeroizing buffers wiped on drop |
| Pay for what you use | A bare build is a tiny core — Redis, Axum, OAuth, and MFA are feature-gated behind a CI dependency budget, so unused capabilities cost zero binary and zero attack surface |
| Tiny edge | The Next.js middleware verifies JWTs through a wasm-bindgen module — the same HS256 code as the server, with no network round-trip |
Password hashing is deliberately expensive: scrypt and Argon2id are memory-hard so an attacker who exfiltrates the hash store cannot brute-force it cheaply. Their cost is a security knob (cost_factor / memory_kib), not a bottleneck — and it is the one operation the engine hands to tokio::task::spawn_blocking, so a burst of logins never stalls the async runtime. Everything around the KDF stays in the nanosecond–microsecond range.
Tracked with Criterion so a regression surfaces as a number — the same discipline applied to the coverage gate.
| Primitive (per call) | Median | Role |
|---|---|---|
SHA-256 (mac::sha256) |
~110 ns | high-entropy token → Redis key suffix |
| Keyed HMAC-SHA-256 | ~430 ns | low-entropy identifier / recovery-code hashing |
| Secure token (32 B → hex) | ~870 ns | dominated by the OS CSPRNG syscall, not allocation |
| AES-256-GCM encrypt / decrypt | ~2.1 µs / ~1.3 µs | TOTP secret encrypted at rest |
| TOTP generate / verify (±1 window) | ~200 ns / ~710 ns | RFC 6238, constant-time |
| scrypt hash / verify (N=2¹⁷) | ~150 ms | memory-hard — tunable security cost, at OWASP's recommended minimum |
| Argon2id hash / verify (19 MiB) | ~10 ms | memory-hard — tunable security cost |
Indicative medians on an Apple M4 Max, release profile, Rust 1.96. Reproduce with cargo bench -p bymax-auth-crypto --bench crypto --all-features. Absolute figures are hardware-dependent — the point is the order of magnitude and that the numbers are tracked, not hand-waved.
Note
The cheap operations cost nanoseconds to microseconds; the only deliberately slow step is the memory-hard KDF, which is a security control you dial in — not a hot loop. Optimisation is a standing project premise, but it never outranks a constant-time, zeroize, or no-oracle guarantee.
Authentication is critical infrastructure, so the suite is held to a bar beyond "it compiles" — every behavior is pinned so a regression fails a test.
- ✅ 100% line and function coverage — 19,598 lines and 2,309 functions, enforced as a release gate via
cargo-llvm-cov --fail-under-lines 100across the fullcargo-hackfeature matrix. Regions — finer than lines, one per branch inside an expression — sit at 96.71% and are deliberately not a gate - ✅ Mutation gate —
cargo-mutantsseeds faults into the source and the suite must catch them. The last full sweep (9 h, 2026-07-28): 1,652 mutants — 1,269 caught, 89 detected by timeout, 293 unviable, 1 survivor, which is closed on the branch that follows it. The timeouts are all container-backed Redis stores, where a mutation is detected by the test hanging rather than failing: real detection, but weaker than an assertion, so the two are reported apart rather than summed into one number. Mutants no test can kill are recorded in.cargo/mutants.tomlwith the reason each is equivalent. The sweep runs post-merge onmain, never on a PR — it takes ~9 hours - ✅ Property tests + fuzzing —
proptestround-trips andcargo-fuzzsmoke runs over the trust-boundary parsers (JWT, PHC, base32) - ✅ Real-Redis E2E — atomic Lua, rotation/grace, and revocation proven against
redis:8viatestcontainers - ✅ Edge parity —
wasm-bindgen-testconfirms the WASM verifier accepts a token signed by the backend - ✅ Supply chain —
cargo-deny(advisories/licenses/bans/sources),cargo-vet, a dependency budget, andcargo-public-api+cargo-semver-checkson every PR
cargo test --workspace --all-features # unit + integration
cargo llvm-cov --workspace --all-features # 100% line/region gate
cargo mutants # mutation testing
cargo deny check # supply-chain policy
wasm-pack test --node bindings/bymax-auth-wasmNote
Line coverage proves a line executed; mutation testing proves a test would fail if that line were wrong. Both run in CI, alongside a wasm32-unknown-unknown build that guarantees the edge crate stays free of server dependencies.
Route groups mount only when their feature and runtime toggle are enabled, so an unconfigured group contributes zero routes. Paths are shown under the default auth prefix.
| Method | Path | Guard / Auth | Description |
|---|---|---|---|
| POST | /auth/register |
Public | Register a dashboard user and issue tokens |
| POST | /auth/login |
Public | Authenticate (may return an MFA challenge) |
| POST | /auth/logout |
AuthUser |
Revoke the access jti and the refresh session |
| POST | /auth/refresh |
Public (refresh cookie) | Rotate the refresh token, issue a new access token |
| GET | /auth/me |
AuthUser |
Current dashboard user |
| POST | /auth/verify-email |
Public | Verify email with an OTP |
| POST | /auth/resend-verification |
Public | Resend the email-verification OTP |
| POST | /auth/password/forgot-password |
Public | Request a password reset (token or OTP) |
| POST | /auth/password/reset-password |
Public | Submit a new password |
| POST | /auth/password/verify-otp |
Public | Verify a password-reset OTP |
| POST | /auth/password/resend-otp |
Public | Resend the password-reset OTP |
| POST | /auth/password/change |
AuthUser + UserStatus |
Change the password, proving the current one |
| POST | /auth/mfa/setup |
AuthUser |
Generate the TOTP secret + recovery codes |
| POST | /auth/mfa/verify-enable |
AuthUser |
Confirm setup and enable MFA |
| POST | /auth/mfa/challenge |
Public (MFA temp token) | Submit a TOTP / recovery code after login |
| POST | /auth/mfa/disable |
AuthUser |
Disable MFA |
| POST | /auth/mfa/recovery-codes |
AuthUser |
Regenerate recovery codes (TOTP-gated) |
| GET | /auth/sessions |
AuthUser, UserStatus |
List active sessions |
| DELETE | /auth/sessions/all |
AuthUser, UserStatus |
Revoke all sessions |
| DELETE | /auth/sessions/:id |
AuthUser, UserStatus |
Revoke a specific session (ownership-checked) |
| POST | /auth/invitations |
AuthUser |
Create a tenant invitation |
| POST | /auth/invitations/accept |
Public | Accept an invitation and create the user |
| POST | /auth/invitations/revoke |
AuthUser |
Withdraw a pending invitation |
| POST | /auth/email/change |
AuthUser |
Request an address change (re-proves the password) |
| POST | /auth/email/change/confirm |
Public | Confirm it with the token sent to the new address |
| POST | /auth/platform/login |
Public | Platform-admin login (separate context) |
| POST | /auth/platform/mfa/challenge |
Public | Platform-admin MFA challenge |
| GET | /auth/platform/me |
PlatformUser |
Current platform admin |
| POST | /auth/platform/logout |
PlatformUser |
Revoke platform tokens |
| POST | /auth/platform/refresh |
Public (platform refresh cookie) | Rotate the platform refresh token |
| DELETE | /auth/platform/sessions |
PlatformUser |
Revoke all platform sessions |
| GET | /auth/oauth/:provider |
Public | Initiate the OAuth authorize redirect |
| GET | /auth/oauth/:provider/callback |
Public | Handle the callback, exchange the code, issue tokens |
| POST | /auth/ws-ticket |
AuthUser, UserStatus, MfaSatisfied |
Mint a single-use WebSocket upgrade ticket |
GET /auth/oauth/:providerplants an HttpOnlyoauth_statecookie carrying the flow'sstate, and the callback refuses any request that does not send it back — the binding RFC 6749 §10.12 requires, without which an attacker can hand a victim a callback URL and have the victim's browser complete the attacker's login. The router's cookie layer handles this; a custom mount must keepCookieManagerLayerin place.
| Extractor | Purpose |
|---|---|
AuthUser |
Validates the dashboard JWT (cookie or Authorization header) |
OptionalAuthUser |
Routes that differ for anonymous vs. authenticated users |
RequireRole<R> |
Hierarchical role check |
PlatformUser |
Platform-admin JWT validation |
RequirePlatformRole<R> |
Platform role hierarchy enforcement |
CurrentUser |
Extracts the validated claims |
SelfOrAdmin |
Ownership-or-admin access |
UserStatus |
Blocks inactive/banned users (Redis-cached status) |
MfaSatisfied |
Enforces a completed MFA verification on the request |
| Hook | Returns |
|---|---|
useSession() |
{ user, status, isLoading, refresh(), lastValidation } — session state + revalidate |
useAuth() |
{ login(), logout(), register(), forgotPassword(), resetPassword() } — auth actions |
useAuthStatus() |
{ isAuthenticated, isLoading } — derived state |
| Factory | Type | Purpose |
|---|---|---|
createAuthProxy() |
Proxy config | Auth-aware edge proxy for proxy.ts |
createSilentRefreshHandler() |
GET handler | iframe-based token refresh |
createClientRefreshHandler() |
POST handler | Client-triggered token refresh |
createLogoutHandler() |
POST handler | Clear tokens and session |
verifyJwtToken() |
Edge helper | WASM-backed HS256 verification (server/edge only) |
The items below are on deck for future releases. None ship today — the list exists so contributors can see where the workspace is headed and where help is most useful. Open an issue to discuss priorities or propose a design.
| Area | Item | Status |
|---|---|---|
| Registry publishing | OIDC trusted publishing, the release workflow, SBOM/attestation publishing, and the tag ↔ version gate (P12's second half) |
Deferred |
| OAuth providers | A provider trait implementation set beyond Google — GitHub, Microsoft, Apple — behind the existing OAuthProvider seam |
Planned |
| Error-message i18n | Locale presets for AuthError's user-facing messages (defaults are English) |
Planned |
| Passwordless / magic link | Single-use link flow reusing generate_secure_token and the EmailProvider seam |
Exploring |
| Passkeys / WebAuthn | WebAuthn as an MFA method, and eventually a first factor, behind its own feature | Exploring |
| Per-tenant configuration | Per-tenant overrides for session limits, MFA enforcement, and password policy, resolved per request | Exploring |
| Pluggable password policy | A PasswordPolicy seam for complexity classes and per-tenant rules (the breach check already ships as PasswordBreachChecker) |
Planned |
| Additional adapters | An actix-web adapter alongside bymax-auth-axum, sharing the same engine and wire contract |
Exploring |
Track progress and discuss proposals on the issues board. Phase-level status for the work already delivered lives in docs/development_plan.md.
Contributions are welcome! Please read the contributing guidelines before opening a pull request.
# Clone the repository
git clone https://github.com/bymaxone/rust-auth.git
cd rust-auth
# Build the workspace
cargo build --workspace --all-features
# Run the test suite
cargo test --workspace --all-features
# Lint + format
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo fmt --all -- --check
# Frontend package
cd packages/rust-auth && npm ci && npm run build:wasm && npm run buildRunnable apps live under examples/ (three Axum services, a
React + Vite SPA, a Next.js edge app, and a dogfood integration). The release and
publishing process — and the one-time OIDC / protected-environment setup it
requires — is documented in docs/RELEASE.md.
If you discover a security vulnerability, please do not open a public issue. Email support@bymax.one with the details. We take security seriously and will respond promptly. See SECURITY.md.
Built with ❤️ and 🦀 by Bymax One