Rust email/password authentication with Axum
How ShipRust implements Rust email/password authentication with Axum, Argon2id, email verification, and Postgres sessions. Code and trade-offs explained.
ShipRust implements Rust email/password authentication with Axum handlers, Argon2id password hashes, and server-side sessions in Postgres. New registrations stay in a pending table until the user enters a six-character email code in the same browser. Only then does the app create the account and issue a session cookie.
The sign-in form is the small part. The interesting decisions are what happens before an account exists, where expensive password work runs, and what the server trusts on the next request.
This is a walkthrough of the implementation that ships in generated ShipRust projects, reviewed on October 9, 2026. The website uses the same authentication and core crates. The excerpts below come from that code; they are not a standalone copy-and-paste authentication library.
In this article
- The signup flow
- Argon2id without blocking async workers
- Email verification as pending state
- Postgres sessions instead of browser-held claims
- Protecting an Axum handler
- Configuration and failure cases
- Tests and current boundaries
The signup flow: an email address is not an account
We keep three states separate: an anonymous visitor, a pending signup, and an authenticated session.
- The visitor submits an email and password to
/sign-up. - The app validates the input, checks signup budgets, hashes the password, and saves a pending signup.
- Mailgun is asked to send a code. A separate signup cookie binds the challenge to this browser.
- The visitor submits the code to
/verify-email. - A database transaction checks the challenge, creates the user, and consumes the pending signup.
- After that transaction commits, the handler creates a session and redirects to
/dashboard.
Submitting credentials does not sign you in. The account is created at verification; the session is issued afterward.
That ordering avoids a common source of ambiguity: an account that exists but is not supposed to use the product yet. In this signup flow, an unverified visitor has no user row and no authentication session. There is nothing for a signed-in-only route to accept.
The distinction also affects failure handling. If the verification transaction succeeds but session creation fails, the account already exists. The user can sign in normally; the app has not made user creation and session issuance one atomic operation.
For the broader application structure, see our introduction to the ShipRust stack.
Argon2id without blocking Axum's async workers
ShipRust stores a password hash, never the plaintext password. The shared crates/auth/src/password.rs uses RustCrypto's Argon2 implementation. At the reviewed revision, the lockfile pins argon2 0.6.0, whose default algorithm is Argon2id.
Here is the hashing path, with its relevant import, from that module:
use argon2::{Argon2, PasswordHasher};
fn hash_sync(password: &str) -> anyhow::Result<String> {
let hash = Argon2::default()
.hash_password(password.as_bytes())
.map_err(anyhow::Error::msg)?;
Ok(hash.to_string())
}
pub async fn hash(password: String) -> anyhow::Result<String> {
shiprust_core::blocking::run(move || hash_sync(&password)).await?
}
The Argon2 0.6 API generates the random salt and returns an encoded PHC string containing the algorithm, parameters, salt, and hash. Older tutorials using a different version may show different method signatures.
The current defaults are 19 MiB of memory, two iterations, and parallelism one. Those match one of the Argon2id configurations in OWASP's password-storage guidance. They are a starting point to assess on your hardware, not a promise about request latency.
Password hashing deliberately consumes resources. Running it directly inside an async handler would occupy a worker that should be driving other requests. Our wrapper moves it to Tokio's blocking pool and places a semaphore in front of that pool.
The important part of crates/core/src/blocking.rs is:
let permit = capacity.acquire().await
.expect("CPU job semaphore is never closed");
tokio::task::spawn_blocking(move || {
let _permit = permit;
job()
})
.await
Here, capacity is the shared semaphore and job is the CPU-heavy closure. The full helper allows two concurrent CPU jobs per process, shared with other work using the helper. Waiting for a slot yields instead of blocking an async worker.
Notice where the permit lives: inside the blocking closure. A cancelled HTTP request does not stop already-running password work. Releasing the permit with the request would let another job start while the old one was still consuming CPU and memory. Tokio documents both the need to bound CPU-heavy spawn_blocking work and its cancellation behavior in the Tokio 1.53.2 API reference.
Sign-in uses the same bounded helper for password verification. For an unknown email, it still verifies against a dummy hash and returns the same “Wrong email or password” message. This reduces an obvious timing distinction; it is not a guarantee that all responses take identical time.
Email verification is a small state machine
The verification module lives in app/src/verification.rs in a generated project. Its pending record holds the password hash, a hash of the browser token, a keyed code digest, expiry, attempt count, and delivery state.
Creating the account only after verification is one design choice. Djass's Django allauth guide describes another: create an unverified account first, then block login until verification succeeds. The distinction matters when handling failed email delivery: decide which state survives and how the user can retry, without granting access before verification.
The browser token and the emailed code have different jobs:
| Value | Where it goes | What the database keeps |
|---|---|---|
| Password | Submitted to the server | Argon2id hash |
| Signup token | Separate HttpOnly signup cookie | SHA-256 digest |
| Six-character code | Email, then verification form | HMAC-SHA256 digest bound to the signup token |
| Session token | Authentication cookie after success | SHA-256 digest |
The code uses a 32-character alphabet that avoids some easily confused characters. Verification normalizes surrounding whitespace and letter case. HMAC is a keyed message authentication code: checking it requires the secret as well as the input. Here it uses an independent server secret, EMAIL_VERIFICATION_KEY, and includes the browser token in its input. A code alone is therefore insufficient to finish signup from a different browser.
These are the limits enforced by the current implementation:
| Control | ShipRust behavior |
|---|---|
| Code lifetime | Ten minutes |
| Wrong-code attempts | Five per issued challenge |
| Resend cooldown | At least 60 seconds |
| Per-email budget | Five signup/resend requests per hour |
| Global budget | 200 signup/resend requests per hour |
| Replacement code | Invalidates the previous code and resets its expiry and attempts |
The limits live in Postgres. Restarting the app does not erase the send budgets, and separate replicas consult the same state. These are request budgets, not counts of emails that reached an inbox.
Lock the challenge before consuming it
Two verification requests can arrive at nearly the same time. Checking a code and deleting it in unrelated database operations would leave room for both requests to act on the same challenge.
ShipRust selects the pending row inside a transaction with this query from verification::verify:
SELECT email, password_hash, code_hash
FROM pending_signups
WHERE token_hash = $1
AND expires_at > now()
AND attempts < 5
AND delivered
FOR UPDATE
The row lock serializes competing attempts. A wrong code increments the attempt count before committing. A correct code inserts the user and deletes the pending record in that transaction. The unique email constraint and ON CONFLICT (email) DO NOTHING prevent replacing an existing account.
In the generated template, the same transaction also enqueues a welcome-email job. The verification code itself is sent synchronously because the signup response needs the provider's result. Those are different kinds of email with different retry needs.
A send failure must not authenticate anyone
The Mailgun client has an eight-second timeout. Verification requires the pending row to be marked delivered after the provider accepts the request. Provider acceptance does not mean the message has arrived in the inbox.
If the send fails or its outcome is ambiguous, the signup stays pending. The app does not automatically retry that code email; it offers a rate-limited resend. Submitting an existing account's email also does not replace its password or authenticate the visitor. The page uses generic wording, but these paths are not claimed to be timing-identical.
Postgres sessions keep the browser token small
After verification, or a successful password sign-in, ShipRust generates 32 random bytes and hex-encodes them into a session token. The browser receives the raw token. The sessions table stores its SHA-256 digest, a user ID, and an expiry.
On subsequent requests, the server hashes the presented token and performs an indexed lookup joined to the user. The expiry predicate is part of the query. Deleting a session row revokes that session without waiting for a browser-held claim to expire.
This suits ShipRust's server-rendered architecture: there is already a Postgres-backed request path, so authentication uses it too. The trade-off is a database lookup, rather than locally verifying a self-contained token. A database lookup failure is treated as unauthenticated, not as permission to continue.
The session cookie is HttpOnly, SameSite=Lax, and scoped to /. HTTPS in SITE_URL enables Secure. HttpOnly prevents JavaScript from reading the cookie directly; it does not make an application immune to cross-site scripting. OWASP explains these distinctions in its session-management guidance.
The pending-signup cookie is separate and uses SameSite=Strict. It is not a temporary login session.
The default session lifetime is 30 days, configurable through SESSION_TTL_DAYS. Expiration is set when the session is created; reads do not extend it. Sign-out deletes the server-side session and expires the cookie. An hourly cleanup removes expired session rows, but rejection of an expired token does not depend on that cleanup having run.
Protect an Axum handler with an extractor
Handlers that require authentication take CurrentUser. Public pages that optionally show account information use MaybeUser.
This minimal handler illustrates the pattern; wire it into your generated app's router and state:
use shiprust_auth::CurrentUser;
async fn account(CurrentUser(user): CurrentUser) -> String {
format!("Signed in as {}", user.email)
}
CurrentUser implements Axum's FromRequestParts, checked here against Axum 0.8.9. It resolves the session before the handler runs. If there is no live session, it redirects to /sign-in.
The extractor answers “who is making this request?” It does not answer “may this user read this project?” Your resource queries still need ownership or role checks. Adding CurrentUser to a route that fetches any record by an arbitrary ID is not sufficient authorization.
The browser routes also use a shared Fetch Metadata/Origin guard for state-changing requests. Signup, verification, and resend additionally require an exact matching Origin. This is not a claim that every POST requires that header: the general guard permits clients that send neither Fetch Metadata nor Origin.
Configure email before testing signup
Generated projects include the flow even when optional blog and docs features are disabled. They do not include our Mailgun credentials. After following the local setup guide, configure these four runtime settings:
| Setting | Purpose |
|---|---|
MAILGUN_SENDING_KEY | Your domain-scoped sending key |
MAILGUN_DOMAIN | Your sending domain, such as mg.example.com |
MAIL_FROM | Your sender address on that domain |
EMAIL_VERIFICATION_KEY | An independent random secret used for verification digests |
Use the generated email-verification guide for the full setup. The current client targets Mailgun's US API. Keep secrets in local environment configuration or your hosting provider's secret store, not in generation answers or committed files.
With all four settings absent, signup is disabled; existing users can still sign in. A partial configuration prevents startup. This makes a missing email setup visible instead of silently bypassing verification.
When deploying, set the correct public HTTPS SITE_URL: both secure-cookie behavior and origin checks depend on it. The Docker hosting guide covers the deployment starting point.
Test the failures, and keep the boundaries visible
The integration tests in app/tests/email_verification.rs use fresh Postgres databases and a local HTTP email double. They exercise pending signup, browser binding, code reuse, expiry, resend cooldowns, attempt exhaustion, concurrent verification, provider failure, and existing-account protection. You do not need real recipients to test those paths.
The shared crates separately test password round-trips, session cleanup, and cancellation of bounded CPU work. Together, these tests check the transitions that a happy-path signup test would miss. They do not constitute a security audit.
A few current boundaries matter when adapting the starter:
- Signup budgets are not a login rate limiter. The reviewed application does not have an equivalent sign-in attempt limiter. Evaluate abuse controls for your deployment; a CPU semaphore is not a substitute.
- Email verification proves access during signup. Existing accounts are not retroactively verified, and changing an email in settings does not currently verify the replacement address.
- Password policy is explicit but limited. The current minimum is eight Unicode characters and the maximum is 256 bytes. These are implementation limits, not a claim that every accepted password is strong.
- Sessions are authentication, not your product's permission model. Ownership and paid-feature checks belong in the application.
If you want another sign-in method after account creation, ShipRust also supports passkeys. They finish with the same session mechanism rather than creating a second definition of “signed in.”
Where to read the implementation
In a generated project, start with crates/auth/src/password.rs, crates/core/src/blocking.rs, app/src/verification.rs, crates/auth/src/sessions.rs, and crates/auth/src/extract.rs. The HTTP handlers are in app/src/routes.rs. In the ShipRust source repository, those files sit under template/.
This walkthrough was checked against template code at revision f1770f0 and the dependency versions named above. The file map makes the article useful alongside a downloaded project without requiring access to our private repository.
The part to carry into your own app is the separation of responsibilities: pending signup owns email proof, the password module owns expensive credential checks, and the session extractor owns request authentication. Start with a ShipRust project, read those paths, and build your product's permissions on top.