Skip to content

Sign-in#

Use Monosecret to manage the server and deployment credentials in this guide. New projects resolve their secret manifest automatically during development.

Sign-in is the part of an app where small mistakes are the most expensive. Session tokens get stored as they are, so a leaked database backup signs everyone in. A form says "no account with that email", which tells an attacker whose accounts exist. A code can be guessed because nothing counts the guesses. And one server function forgets to check who is asking.

leptoskit::auth handles all of these in one place, in your app's own database. It keeps only hashes of tokens and codes, gives the same answer for every address, and limits every attempt. It also tells your access policies who is signed in, so each table's rules apply on every query.

Start from a template#

Wiring sign-in by hand means a config table, a user table, server routes, and two screens, before anyone can sign in at all. leptoskit writes all of it for you, and the rest of this page explains each piece.

For a new app, pass --auth:

leptoskit new my_app --auth
cd my_app
leptoskit db migration create init
export AUTH_ENCRYPTION_KEYS="$(date +%Y-%m):$(openssl rand 32 | basenc --base64url | tr -d =)"
leptoskit dev

The app opens on a sign-in screen. Type any address, then open /_leptoskit/mail to read the code, which a debug build keeps instead of sending. Once you're signed in, the bar at the top lists where you're signed in, opens the security screen for passkeys, two-step verification, and connected accounts, and signs you out. The project has a SQLite database unless --db postgres picks Postgres, a User table that gets a row for each new account, and the server's routes and layer.

For an app you already have, generate the screens:

leptoskit generate auth

It writes three screens into your app's src/auth/ folder: a sign-in screen, with an emailed code, a passkey button and the email field's passkey suggestions, a "Continue with …" button for each provider, and the second step when an account has one; a "where you're signed in" screen; and a security screen for passkeys, an authenticator app with its QR code, connected accounts, and merging accounts. It also adds what they need:

  • the [auth] table in leptoskit.toml, and the auth, leptos, and query features of leptoskit;
  • /sign-in, /devices, and /security routes, if your app uses the file router;
  • a src/sign_in.rs module that builds the service, if your app has a server and a database;
  • for an app with iOS, Android, or macOS hosts, the http and secure-store plugins those hosts need, granted in leptoskit.toml and added to each host's Cargo.toml.

Then it lists the few lines it can't add for you, such as putting the sign-in screen in front of your app. Running it again changes nothing, and a screen you've edited is kept unless you pass --force. With --idp, it also writes the screens for other apps signing in with yours.

The screens are built from leptoskit's own components and Tailwind classes, on the sign-in client, so the same files build and render in the browser and in native apps on every platform. They show a message while they load, show the server's error messages, and after too many attempts disable the button until the server will take another. They're yours to restyle.

In a native app the screens reach the server through the http plugin, keep the session with secure-store, and sign in with passkeys and other sites through passkeys and web-auth (see native apps). A project made with --auth grants all four, lets the HTTP plugin reach the development server, and looks for its server at the SERVER address in my_app_app/src/lib.rs, http://localhost:3000 until you change it. Before you ship, set SERVER to your site and list it in the HTTP plugin's origins, as Build the sign-in screen shows.

What happens on each platform:

  • Web: signing in works as soon as the server runs.
  • iOS, Android, and macOS: the generated apps register the plugins, so signing in works once the app can reach SERVER.
  • Windows, Linux, and the terminal: these runners can't host plugins yet. They build without them, leptoskit build says so, and the screens show that sign-in isn't set up: every client call answers the error code config.

Add the auth tables#

Sign-in keeps its tables in your database as a schema pack. Turn on the auth feature next to your database feature, then add the pack in the crate that declares your tables:

leptoskit = { version = "…", features = ["auth", "db-postgres"] }
leptoskit::db::packs!(leptoskit_auth);

Your next leptoskit db migration create adds the tables, in the auth namespace:

TableWhat it holds
accountsOne row per person: when they joined, and whether the account is disabled.
emailsThe addresses each account has proved it receives mail at.
sessionsSigned-in devices: hashes of their tokens, their lifetimes, and a label.
challengesCodes in flight, as keyed hashes. They expire and work once.
eventsThe audit trail: sign-ups, sign-ins, failures, and revocations, with IP address.
rate_limitsAttempt counters, keyed by a hash, so the table holds no addresses.
totpEach account's authenticator app secret, encrypted with the server's keys.
recovery_codesEach account's recovery codes, as keyed hashes. Each works once.

Connect your user table#

Give your user table the account's key, so Id<auth::Account> names the user too. Deleting an account then deletes its user:

#[db::table(name = "users", public)]
pub struct User {
	#[db(on_delete = cascade)]
	pub id: Id<auth::Account>,
	pub name: String,
}

Create the user's row when the account is created, with OnSignUp. It runs inside the transaction that creates the account, so if it fails, nothing is created:

impl auth::OnSignUp for User {
	async fn on_sign_up(tx: &db::Tx, account: Id<auth::Account>, profile: &auth::Profile) -> auth::Result<()> {
		let name = profile.name.clone().unwrap_or_else(|| "New user".into());
		NewUser { id: account, name }.insert(tx).await?;
		Ok(())
	}
}

Start the service#

Describe sign-in in leptoskit.toml. Secrets never go in the file; each setting that needs one names the environment variable that holds it:

[auth]
origin = "https://myapp.com"
keys_env = "AUTH_ENCRYPTION_KEYS" # the default
idle_days = 14 # the default
absolute_days = 90 # the default
access_minutes = 15 # the default: how long a native app's access token works

[auth.mail]
from = "My App <[email protected]>"
smtp_url_env = "SMTP_URL"

Build one Auth from it when the server starts, add your sign-up hook, and share it:

// Embedded, so the server needs no file at runtime.
let config = leptoskit::config::LeptoskitConfig::from_toml(
	include_str!("../../leptoskit.toml"),
	std::path::Path::new("my_app"),
)?;
let auth = auth::Auth::from_config(&db, &config)?
	.on_sign_up::<User>()
	.build()?;

AUTH_ENCRYPTION_KEYS holds the server's keys, newest first, as id:key pairs, where each key is 32 random bytes in base64url:

echo "AUTH_ENCRYPTION_KEYS=$(date +%Y-%m):$(openssl rand 32 | basenc --base64url | tr -d =)"

The newest key hashes new codes and encrypts new authenticator secrets, and any listed key checks or decrypts one. To rotate, put a new key first, keep the old one listed, and run leptoskit auth rotate-keys to re-encrypt what's stored. Recovery codes hashed with a removed key stop working, so ask people to make new ones before you remove it. Keep the variable in your secret store.

Auth::builder(&db, keys) configures the same things in code, for tests and for apps without a leptoskit.toml.

Send email#

[auth.mail] picks one way to send:

  • SMTP (the auth-smtp feature): smtp_url_env names a variable holding a URL such as smtps://user:[email protected], and from is who email comes from.
  • A webhook (the auth-webhook feature): webhook_url_env and webhook_secret_env name a URL you control and its signing secret. Each email arrives there as JSON, { "type": "email", "from", "to", "subject", "text" }, signed the way Standard Webhooks describes, and your endpoint sends it through any provider. Check the webhook-signature header before you trust a request.

With neither, a debug build keeps every email in the development mailbox, and a release build refuses to start rather than silently send nothing. Any other provider takes a few lines: implement auth::Mailer, and pass it to .mailer(…).

Sign in with an emailed code#

One flow both signs in and signs up. Ask for a code, and the person receives one by email:

auth.send_email_code(&address, &request).await?;

The answer is the same for every valid address, whether or not it has an account, so the form can't be used to find out who has signed up. When the person types the code, check it:

match auth.verify_email_code(&address, &code, &request).await? {
	auth::SignInStep::SignedIn(sign_in) => {
		// sign_in.token goes to the device; sign_in.signed_up says whether the account is new
	}
	auth::SignInStep::SecondFactor(step) => {
		// the account has two-step verification: see below
	}
}

A code proves the address. If the address has an account, this signs in to it. If it doesn't, this creates the account and runs your OnSignUp. The rules on codes:

  • A code is eight letters and digits, such as K7QM-R2XD. Case, spaces, and the dash don't matter.
  • It works once, for 10 minutes, for its own address only. Asking for a new code retires the old one.
  • After five wrong guesses it stops working. A wrong code, an expired code, and an address with no code all fail the same way, with AuthError::InvalidCode.

request is an auth::RequestInfo: the client's IP address and user agent. They're used for rate limits, the audit trail, and the device's label in a list of sessions, such as "Firefox on macOS".

Serve sign-in#

With the server feature, Auth serves the whole flow over HTTP. Add its routes, and its layer last, so that it wraps every route:

let auth = auth::Auth::builder(&db, keys)
	.origin("https://myapp.com")
	.mailer(my_mailer)
	.on_sign_up::<User>()
	.build()?;

let app = my_app_backend::router()
	.merge(auth.router())
	.leptos_routes(&options, routes, move || shell(app_options.clone()))
	.layer(auth.layer());

The layer resolves each request's session before any handler runs. Handlers then ask for what they need:

async fn drafts(me: auth::SignedIn, Extension(db): Extension<Db>) -> Result<Json<Vec<PublicPost>>, StatusCode> {
	// `db` acts for this request: its policies see `me`
	…
}
  • auth::SignedIn answers 401 for a signed-out request, and Option<auth::SignedIn> accepts both.
  • Extension<Db> is the database, acting for the request. Don't add your own Extension(db) as well.
  • In a leptos server function, auth::require_signed_in()? and auth::request_db()? do the same.

The routes take and return JSON:

RouteBodyDoes
POST /auth/email/send{ "email" }Emails a code.
POST /auth/email/verify{ "email", "code" }Signs in, and sets the session cookie.
POST /auth/refresh{ "refresh_token" }Trades a native app's refresh token for new tokens.
GET /auth/sessionThe current session, or null.
POST /auth/sign-outEnds the current session, and clears the cookie.
GET /auth/sessionsThe person's signed-in devices, this one marked current.
POST /auth/sessions/revoke{ "id" }Ends one of them.
POST /auth/sessions/revoke-othersEnds all but the current one.
POST /auth/sessions/revoke-allEnds every one, this one included, and clears the cookie.
POST /auth/second-factor{ "code" }Finishes signing in with an authenticator or recovery code.
GET /auth/mfaWhether an authenticator is on, and recovery codes left.
POST /auth/step-up{ "code" }Confirms it's still the signed-in person.
POST /auth/totpStarts turning an authenticator app on.
POST /auth/totp/confirm{ "code" }Turns it on with a code from it.
POST /auth/totp/disableTurns it off, after step-up.
POST /auth/recovery-codesReplaces the recovery codes, after step-up.

A failure answers with a status and { "error", "message" }. error is a code to match on, such as invalid_code or rate_limited, and a rate-limited answer also says retry_after. The server's own failures, such as a database outage, say only that something went wrong.

On the web, the session lives in a cookie that scripts can't read: HttpOnly, SameSite=Lax, and over HTTPS, a __Host- cookie that only your exact origin can set. On plain http://localhost in development it is a plain session cookie, since browsers refuse Secure cookies there.

In a native app on iOS, Android, macOS, Windows, or Linux there are no cookies to lean on, so the app holds two tokens instead. The sign-in client does all of the following for you; it matters only to code that calls the routes itself. Send "transport": "bearer" with the code, and optionally a "device_name" such as "Ada's iPhone" for the list of devices. The answer carries tokens:

{ "access_token": "…", "refresh_token": "…", "expires_in": 900 }
  • Send the access token with each request as Authorization: Bearer …. It works for 15 minutes; access_minutes changes that, up to an hour.
  • Keep the refresh token in the platform's secure store, never in plain storage. Before the access token runs out, or after a 401, trade it at POST /auth/refresh for a new pair. It works once: the old access token stops working at the same moment.
  • Refresh one request at a time. If a refresh's response is lost, retrying with the same refresh token works for 30 seconds. After that, the server takes a second use as a sign that someone copied it, ends the session for every holder, and records a refresh_reused event; the app has to sign in again.

Requests from other sites that would change something are refused with 403. A browser's POST must carry your origin in Origin, or Sec-Fetch-Site: same-origin. A request that has neither is allowed only if it has no session cookie, which is how native apps and scripts call. A request with Authorization: Bearer and no session cookie is allowed from any origin, because browsers never send that header on their own. Another origin may also ask for a code and sign in with "transport": "bearer", which sets no cookie; only signing in for a cookie must come from your own site. That is how a browser extension signs in and calls from its own origin. Set origin to your site's URL; without it, a request must come from the host it was sent to.

The client's IP address, for rate limits and the audit trail, is the connection's peer address. Behind a proxy, name the header it sets, such as .client_ip_header("fly-client-ip"). Only name a header your proxy always overwrites, since anyone can send one.

In development#

Two builder options make sign-in easy to try. Release builds don't have either, so they can't ship by accident:

  • .dev_mailbox() keeps every email instead of sending it, and shows them at /_leptoskit/mail.
  • .dev_sign_in() adds POST /auth/dev/sign-in, which takes { "email" } and signs in to that address without a code, creating its account if needed.

Build the sign-in screen#

The sign-in screen is yours to design; leptoskit::auth::client supplies the behaviour, as query and mutation scopes. They are the same functions, taking the same arguments, on the web and in native apps on iOS, Android, macOS, Windows, and Linux. One screen works everywhere, and your code never asks which platform it's on.

First, tell the client where your server is. Call configure once, when the app starts, on every platform:

use leptoskit::auth::client::ClientConfig;
use leptoskit::auth::client::configure;

configure(
	ClientConfig::new()
		.origin("https://myapp.com")                 // where the server is
		.device_name("Ada's iPhone"),                // optional: this device in the device list
);
  • On the web, the origin can be left out: the client talks to the page's own origin. If you set it, it must be that origin, because the session cookie belongs to it. Browsers are named in the device list from their user agent, so the device name is ignored.

  • In a native app, the origin is required. Without it, every call fails with the error code config. The app also needs the http, secure-store, passkeys, and web-auth plugins, with the same origin in the HTTP plugin's origins. leptoskit new --auth and leptoskit generate auth grant them; add your site's origin before you ship:

    [native]
    plugins = ["http", "secure-store", "passkeys", "web-auth"]
    
    [native.plugin-options.http]
    origins = ["https://myapp.com", "http://localhost:3000"]

    The generated iOS, Android, and macOS hosts register the plugins. The Linux, Windows, and terminal runners can't host plugins yet: they build without them, with a warning, and the client answers config there. An app with its own Windows or Linux host can register them by hand; see the plugin support matrix.

Then build the screen:

use leptoskit::auth::client::EmailCode;
use leptoskit::auth::client::send_code;
use leptoskit::auth::client::sign_out_mutation;
use leptoskit::auth::client::use_auth;
use leptoskit::auth::client::verify_code;
use leptoskit::query::use_mutation;

let auth = use_auth();                          // the session query
let send = use_mutation(send_code());
let verify = use_mutation(verify_code());
let sign_out = use_mutation(sign_out_mutation());

send.mutate(email.clone());                     // step 1: email a code
verify.mutate(EmailCode { email, code });       // step 2: sign in, or SignInStep::SecondFactor
  • auth.data() is Some(Some(session)) when signed in, and Some(None) when signed out.
  • Signing in and out update that query, so everything that reads use_auth() re-renders, with no refetch.
  • A failed step's error() is an ErrorResponse. Show its message, or match on its error code: for example, after rate_limited, disable the button for retry_after seconds. network means the server couldn't be reached.

What happens underneath depends on the platform, and none of it is yours to handle:

  • In the browser, the session lives in the HttpOnly cookie, which scripts can't read, including any injected into your page. The client keeps nothing itself; each call is a request to your own origin that carries the cookie.
  • While the server renders a page, use_auth() and use_devices() read the request's own session directly, so the first page already shows who is signed in. Signing in and out happen in the browser once the page loads.
  • In a native app, the client keeps the short-lived access token in memory, and the refresh token in the platform's secure store, such as the Keychain on Apple platforms, so the person stays signed in when the app restarts. When the access token runs out, the client trades the refresh token for new ones and repeats the request, one trade at a time. If the session has ended, for example because the person signed out everywhere from another device, the client forgets its tokens, and use_auth() reports signed out. Signing out forgets the tokens even when the server can't be reached.

Show where they're signed in#

A "where you're signed in" screen lists each device, and lets the person end any of them: a lost phone, a shared computer, or everything at once after a scare. It works the same on every platform.

use leptoskit::auth::client::revoke_device;
use leptoskit::auth::client::revoke_other_devices;
use leptoskit::auth::client::sign_out_everywhere_mutation;
use leptoskit::auth::client::use_devices;

let devices = use_devices();                    // Vec<Device>, most recently used first
let revoke = use_mutation(revoke_device());
let others = use_mutation(revoke_other_devices());
let everywhere = use_mutation(sign_out_everywhere_mutation());

revoke.mutate(device.session.id);               // end one device; `true` if it was still signed in
others.mutate(());                              // end every device but this one
everywhere.mutate(());                          // end them all, this one included

Each Device has the session's device label, when it signed in, when it was last used, and current, which marks the device you're on. Ending a device ends its session at once: its next request is signed out, and a native app's refresh token stops working with it. Each mutation refreshes the list, and signing out everywhere also clears the session query.

Two-step verification#

An emailed code proves someone can read the inbox, so whoever gets into a person's email can also get into their account. Two-step verification closes that gap: the person turns on an authenticator app, such as 1Password, Google Authenticator, or the iPhone's Passwords app, and from then on signing in asks for the six-digit code it shows after the emailed one.

It's optional unless you require it, and each person turns it on for themselves. The generated security screen does all of it; this section explains what that screen calls.

Turn it on#

use leptoskit::auth::client::confirm_totp;
use leptoskit::auth::client::enroll_totp;
use leptoskit::auth::client::use_mfa;

let status = use_mfa();                         // MfaStatus { totp, recovery_codes }
let enroll = use_mutation(enroll_totp());
let confirm = use_mutation(confirm_totp());

enroll.mutate(());                              // TotpSetup { secret, uri }
confirm.mutate(code_from_the_app);              // TotpConfirmation { session, recovery_codes }
  1. enroll_totp() makes a new secret. Show its uri as a QR code for the phone's camera, and its secret, grouped for typing. auth::client::qr_code(&setup.uri) makes the code on the device, so the secret never goes to another service; draw its path() in a Vector (every platform but HarmonyOS draws one), or use its svg() on the web.
  2. They add it to their app, and type the code it shows. confirm_totp() turns the authenticator on only if that code matches, so a mistyped secret never locks anyone out.
  3. The first time, they also get ten recovery codes, such as K7QMR-2XDPA. Each signs in once, in place of the app, if the phone is lost. Show them once and ask the person to save them; the server keeps only their hashes.

Turning it on also emails the account, so the owner hears about it if it wasn't them.

Starting needs a recent sign-in, within ten minutes, so a session left open on a shared computer can't turn on an authenticator that locks the owner out. Otherwise it answers reauthenticate, and the screen asks the person to sign in again.

Signing in with it#

Once it's on, verify_code() answers SignInStep::SecondFactor instead of signing in. Finish with the code from the app, or a recovery code:

use leptoskit::auth::client::SignInStep;
use leptoskit::auth::client::complete_sign_in;

let finish = use_mutation(complete_sign_in());

// after verify answered SignInStep::SecondFactor(prompt):
finish.mutate(code);                            // the session query now holds the session
  • The second step lasts five minutes and allows five attempts; then it answers sign_in_expired, and the person starts again from the email.
  • An authenticator code works once, even within its 30 seconds, and codes from a phone whose clock is up to 30 seconds off still work.
  • Using a recovery code signs out every other device and emails the account, since it usually means a lost phone.
  • In the browser, the step is held in an HttpOnly cookie for its five minutes; in a native app, the client holds it in memory. Either way your code only passes the code.

Sensitive actions: step-up#

Some actions should need more than a session that was opened days ago: changing how someone signs in, deleting the account, or moving money. Guard them with step-up, which asks for a code from the authenticator again:

#[server]
pub async fn delete_account() -> Result<(), ServerFnError> {
	let me = auth::require_step_up(auth::STEP_UP_WINDOW)?;   // a code within the last ten minutes
	// …
	Ok(())
}

Without a recent code it fails with step_up_required. The screen then asks for a code, calls step_up(), and tries the action again:

use leptoskit::auth::client::step_up;

let confirm = use_mutation(step_up());
confirm.mutate(code);                           // then retry what asked for it

Step-up gives the session a new token, so one copied before stops working. In handlers outside server functions, signed_in.require_step_up(auth::STEP_UP_WINDOW)? does the same check.

Turn it off, or make new codes#

disable_totp() turns the authenticator off and deletes the recovery codes; regenerate_recovery_codes() replaces the codes, and the old ones stop working. Both need step-up first. Turning it off also signs out every other device and emails the account.

On every platform#

Authenticator codes and recovery codes work the same in the browser and in native apps: the same functions, the same screens, and the same error codes. The only difference is where the session lives, which the client handles.

How it's kept safe#

  • The authenticator's secret is encrypted in the database with AES-256-GCM-SIV under your AUTH_ENCRYPTION_KEYS, and bound to its account, so a copy of the database can't produce codes.
  • Recovery codes are stored as keyed hashes, and every code is compared in constant time.
  • Attempts are limited per account and per IP address, on top of the second step's own five.
  • Every change, failed code, and recovery code use is recorded in the audit trail, and the account is emailed about changes.

Require it#

mfa in [auth] decides who must use two-step verification:

[auth]
mfa = "required" # everyone; the default is "optional"
required_for = ["Admin"] # or only people with these roles

For required_for, tell sign-in how to read an account's roles:

impl auth::Roles for User {
	async fn roles(db: &db::Db, account: db::Id<auth::Account>) -> auth::Result<Vec<String>> {
		let user = User::get(db, account.into()).await?;
		Ok(vec![user.role.to_string()])
	}
}

let auth = auth::Auth::from_config(&db, &config)?
	.on_sign_up::<User>()
	.roles::<User>()
	.build()?;

Someone who must use it, and hasn't, still signs in with their emailed code, but their session can only set up a passkey or an authenticator app, read its own status, and sign out. Everything else answers mfa_required, and your policies see no one. use_mfa() reports required: true, so the generated screens send them to the security screen; once a factor is on, the session works normally. The last passkey or authenticator of an account that must have one can't be removed (last_sign_in_method).

Locked accounts#

After 20 wrong second factors in a day (authenticator codes, recovery codes, or passkeys, at sign-in or step-up), the account is locked for an hour, and its owner is emailed: getting that far needed their emailed code, or a device they're signed in on. Signing in answers locked until then. Wrong emailed codes don't count, since anyone who knows an address can get those wrong. Limits::failures_before_lock and Limits::lock_for change this, and auth.unlock(account, &request) lifts a lock early.

When someone is locked out of their own two-step verification, such as after losing both their phone and their recovery codes, an administrator can check who they are another way and call auth.reset_mfa(account, &request). It removes the account's authenticator, recovery codes, and passkeys, lifts any lock, signs out every device, and emails the account. The emailed code alone then signs them in.

Passkeys#

An emailed code can be phished: a fake site asks for it and passes it on. A passkey can't. It's a key pair the person's device makes for your site alone, unlocked with their fingerprint, face, or screen lock, and the browser only lets your origin use it. It's also faster than a code: one tap.

Passkeys need the auth-passkeys feature on your server's leptoskit dependency, and [auth] origin: its host is the relying party, so https://myapp.com makes passkeys that work on myapp.com and nowhere else. The feature builds OpenSSL from source the first time, which adds a minute or two to a clean build. Without it, the functions below still exist, and the server answers unsupported.

Add one#

From the security screen, after a recent sign-in (or step-up, once the account has a second factor):

use leptoskit::auth::client::add_passkey;

let add = use_mutation(add_passkey());
add.mutate(Some("Work laptop".into()));        // PasskeyAdded { passkey, session, recovery_codes }

The browser asks the person to confirm with their fingerprint, face, or screen lock. The passkey is named as given, or after the device, such as "Passkey on Safari on macOS". Like turning on an authenticator, adding one emails the account, moves the session to aal2 with a new token, and gives an account without recovery codes ten of them, shown once.

Sign in with one#

use leptoskit::auth::client::PasskeyMode;
use leptoskit::auth::client::passkey_sign_in;

let passkey = use_mutation(passkey_sign_in());
passkey.mutate(PasskeyMode::Prompt);           // "Sign in with a passkey"
passkey.mutate(PasskeyMode::Autofill);         // offered in the email field's suggestions

No email address is needed: the device offers the person's passkeys for your site. Autofill waits for them to pick one from the email field's suggestions, so start it when the screen opens, and give the field .autocomplete("username webauthn"). cancel_passkey_sign_in() stops it when they choose another way. A passkey that checks who's holding the device is two factors at once, so passkey sign-in starts an aal2 session, with no second step.

Once an account has a passkey, signing in with an emailed code asks for it as the second step, with Factor::Passkey first in the prompt's factors. complete_sign_in_with_passkey() finishes, and step_up_with_passkey() answers step_up_required. Recovery codes stand in for a lost passkey, as they do for a lost phone.

Manage them#

use_passkeys() lists them, newest first: each one's name, whether it's synced to the person's other devices, when it was added, and when it was last used. rename_passkey() renames one; remove_passkey() removes one after step-up, and signs out every other device.

On every platform#

PlatformPasskeys
WebEverything above; Autofill where the browser offers passkeys in suggestions
iOS and macOS appsEverything above with the system's passkey sheet, once your site vouches for the app; Autofill answers unsupported
Android appsEverything above with Credential Manager, once your site vouches for the app; Autofill answers unsupported
Windows and Linux appsThe same functions answer unsupported; listing, renaming, and removing still work
HarmonyOS (experimental)As Windows and Linux

The same passkey works on your site and in your apps, because they share the relying party: your site's host. A native app's text fields offer no passkey suggestions, so start Autofill anyway and let it fail quietly, as the generated screen does; the "Sign in with a passkey" button uses the sheet. Native apps says how your site vouches for its apps.

How it's kept safe#

  • The server stores only public keys. A copy of the database signs no one in.
  • Each ceremony's challenge is kept on the server, encrypted, for five minutes, and works once, even when the answer is wrong. The browser holds only a random token in an HttpOnly cookie.
  • Passkeys must verify their user, and a signature counter that goes backwards, which can mean a cloned key, is refused.
  • A passkey from another site, or registered to another account, is refused, and the attempt recorded.

Passwords#

Some people expect a password, and some apps have audiences that do. Passwords are off unless you turn them on: passkeys and emailed codes sign people in just as well, with nothing on your server worth stealing. When you do turn them on, a password is one more way to sign in with the account's email address; it never replaces the others.

[auth.passwords]
enabled = true
min_length = 15 # the default; 8 is only enough when everyone has two-step verification
breach_check = false # the default; see below

Turn on the auth-passwords feature on your server's leptoskit dependency. AuthBuilder::passwords(auth::PasswordOptions { … }) does the same in code.

Setting one, and signing in#

Someone signs up with an emailed code, as always, then sets a password while their sign-in is fresh (within ten minutes, or after step-up):

auth.set_password(&me, "correct horse battery staple", &request).await?;
let step = auth.sign_in_with_password("[email protected]", "correct horse battery staple", &request).await?;

sign_in_with_password answers like an emailed code: a session, or the second step when the account has two-step verification. A password is one factor, so two-step verification and step-up work as they do after an emailed code. remove_password(&me, …) turns it off again; the account keeps its other ways to sign in. The routes are POST /auth/password/sign-in, POST /auth/password (set or change), POST /auth/password/remove, POST /auth/password/reset, and GET /auth/password/config, which says whether passwords are on and how long they must be.

From the app#

The client has the same functions on every platform:

use leptoskit::auth::client::EmailPassword;
use leptoskit::auth::client::sign_in_with_password;
use leptoskit::auth::client::use_password_settings;

let settings = use_password_settings();              // PasswordSettings { enabled, min_length, max_length }
let sign_in = use_mutation(sign_in_with_password());
sign_in.mutate(EmailPassword { email, password });   // SignInStep, as `verify_code()` answers

set_password() and remove_password() change the signed-in person's password, answering step_up_required or reauthenticate when their sign-in isn't fresh, as turning on an authenticator does; reset_password() takes a PasswordReset { email, code, password }, with the code from send_code(). Errors are invalid_password, weak_password (its message says what's wrong), and rate_limited.

PlatformPasswords
WebEverything above, with the session in the HttpOnly cookie
iOS, Android, and macOSEverything above, with the session's tokens in the secure store
Windows and LinuxEverything above, for a host that registers the http and secure-store plugins; the generated runners can't host plugins yet and answer config
HarmonyOS (experimental)Everything above, as for emailed codes
Server renderingpassword_settings() reads in process; the others run in the browser and answer server

When [auth.passwords] enabled is set, leptoskit generate auth turns on auth-passwords for your server and gives its screens password fields. The sign-in screen offers Sign in with a password, and Forgot your password? resets one with an emailed code. The security screen sets, changes, and removes the password, with the server's minimum length beside the field. The fields are PasswordInputs, which hide what's typed on the web and in every native app, marked current-password or new-password so the browser's or the system's password manager fills and saves them. If you turn passwords on after generating the screens, run leptoskit generate auth again: it adds the fields to screens you haven't changed, and leaves the ones you have alone unless you pass --force.

The rules#

They follow NIST SP 800-63B:

  • at least min_length characters (15 by default) and at most 256, counted as characters, not bytes, with any characters at all, spaces and emoji included;
  • no rules about mixing upper case, digits, or symbols, which make passwords harder to remember without making them harder to guess;
  • not one of the most common passwords, from a list built into leptoskit, and not the email address, its name, or your app's name with a few digits added;
  • with breach_check = true, not a password that has appeared in a data breach, as Have I Been Pwned knows. It's off by default because it's a request to another service. What's sent is the first 5 characters of the password's SHA-1 hash, never the password, and the answer is padded, so its size says nothing either. If the service can't be reached, the password is allowed on the other rules.

A refused password fails with AuthError::WeakPassword, naming the problem: too short, too long, too common, too close to the address or the app's name, or breached.

Forgotten passwords#

A reset is an emailed code plus a new password: send a code with send_email_code (the one sign-in uses), then

let step = auth.reset_password("[email protected]", &code, "a new and longer phrase", &request).await?;

The new password is checked before the code is used, so a refused one leaves the code working. A reset ends every session the account has, and starts a new one. When the account has two-step verification, it gets the second step first, and nothing changes until that succeeds, so someone who gets into the inbox still can't reset their way past an authenticator app or a passkey.

How it's kept safe#

  • Passwords are hashed with Argon2id at OWASP's settings (19 MiB of memory, two passes), off your server's async threads. Each hash records its settings, so when they change, the hash is replaced the next time the person signs in.
  • Passwords are compared after Unicode normalization (NFKC), so the same password typed on two keyboards matches.
  • A wrong password and an address without an account or a password answer the same, invalid_password, in the same time.
  • Each address gets 10 tries every 15 minutes. After 20 wrong passwords in a day, signing in with a password is paused for an hour for that address, whether or not it has an account, and the account's owner is emailed. Emailed codes, passkeys, and providers keep working, so nobody can lock someone out by guessing.
  • Changing a password ends the account's other sessions; a reset ends all of them. Each change emails the account.

Sign in with other sites#

Many people would rather "Continue with Google" than wait for an email. leptoskit has built-in definitions for Google, Apple, Microsoft, GitHub, Facebook, X, Discord, LinkedIn, GitLab, Slack, Twitch, Spotify, Reddit, Amazon, Dropbox, Bitbucket, Notion, Zoom, TikTok, and Yahoo, and signs in with any OpenID Connect provider, such as Okta, Auth0, Keycloak, or your company's Entra tenant. Each has a setup page.

Add one from the command line, which writes its [auth.providers.<id>] table and prints its setup steps:

leptoskit auth provider add google
leptoskit auth provider add company --issuer https://sso.example.com
[auth.providers.google]
client_id_env = "GOOGLE_CLIENT_ID"
client_secret_env = "GOOGLE_CLIENT_SECRET"

[auth.providers.company]
name = "Company SSO"
issuer = "https://sso.example.com"
client_id_env = "COMPANY_CLIENT_ID"
client_secret_env = "COMPANY_CLIENT_SECRET"

Then turn on the auth-providers feature on your server's leptoskit dependency. Auth::from_config reads each provider's secrets from the variables it names; AuthBuilder::provider(auth::Provider::google(…)) does the same in code.

Signing in#

use leptoskit::auth::StartProvider;
use leptoskit::auth::client::provider_outcome;
use leptoskit::auth::client::sign_in_with_provider;
use leptoskit::auth::client::use_providers;

let providers = use_providers();               // [ProviderInfo { id: "google", name: "Google" }, …]
let start = use_mutation(sign_in_with_provider());
start.mutate(StartProvider { provider: "google".into(), redirect_to: Some("/notes".into()) });

The page goes to the provider's sign-in page, and comes back to redirect_to (a path on your site; anything else is refused) with the outcome in its query, which provider_outcome() reads. A native app opens the provider's page in the system browser instead, or Sign in with Apple's own sheet for Apple on an Apple device; the mutation resolves once it's back, and then provider_outcome() says the same:

OutcomeMeaning
SignedInSigned in, or signed up
SecondFactor(..)The account has two-step verification; finish with a code or a passkey, as after an email
EmailInUseAnother account already has the provider's email address; see below
LinkedConnected to the signed-in account
AlreadyLinkedIt was connected already
MergeIt belongs to another account here, which can be merged into this one
Failed(code)It didn't work: cancelled, sign_in_expired, provider, locked, …

Who someone is at a provider is the provider's own ID for them, never their email address: some providers let people set any address, and trusting it is how accounts get taken over. So a provider sign-in never signs in to an account just because the email matches. When it does match, the outcome is EmailInUse: the person can sign in to that account with their email and connect the provider from its security screen, or continue_provider_sign_up() for a separate account without that address. An address is only stored as verified when the provider vouches for it. Microsoft, Facebook, X, Spotify, Reddit, Amazon, Notion, and TikTok never do, so accounts made with them start without an address.

Connecting, disconnecting, and merging#

From the security screen, link_provider() connects a provider to the signed-in account, after a recent sign-in or step-up. use_identities() lists connected accounts, and unlink_identity() disconnects one, as long as the account keeps a way to sign in.

If the provider's account already has its own account here, say from signing up with GitHub months ago, the outcome is Merge. use_merge_offer() describes the other account, with a preview of its data, and merge_accounts() merges it after the person confirms, with confirm: true:

use leptoskit::auth::MergeMode;
use leptoskit::auth::client::MergeInput;
use leptoskit::auth::client::merge_accounts;

let merge = use_mutation(merge_accounts());
merge.mutate(MergeInput { mode: Some(MergeMode::BringData), confirm: true, code: Some(code_from_its_authenticator), ..MergeInput::default() });

Merging moves the other account's email addresses, connected accounts, passkeys, and wallets here, and either brings its data along, as your tables' on_account_merge strategies say, or deletes it. It can't be undone, and needs a recent sign-in or step-up on this account and the other account's second factor when it has one. Merging accounts covers both modes, the preview, and your own merge code.

Call a provider's API#

Signing in only needs to know who someone is, so leptoskit forgets a provider's tokens once sign-in is done. When your app also calls the provider's API for the person, such as listing their GitHub repositories, keep the tokens with store_tokens:

[auth.providers.github]
client_id_env = "GITHUB_CLIENT_ID"
client_secret_env = "GITHUB_CLIENT_SECRET"
scopes = ["read:user", "user:email", "repo"]
store_tokens = true

Then, on your server, ask for an access token whenever you need one:

let Some(github) = auth.provider_access_token(me.account, "github").await? else {
	// Not connected, or the grant ended: ask them to connect GitHub again with `link_provider()`.
	return Err(MyError::ConnectGitHub);
};
let repositories = client
	.get("https://api.github.com/user/repos")
	.bearer_auth(&github.access_token)
	.send()
	.await?;
  • The tokens are encrypted with AUTH_ENCRYPTION_KEYS and tied to the connected account they came with, so a copy of the database calls no one's API. Nothing reads them directly, not even their owner; leptoskit auth rotate-keys re-encrypts them with the rest.
  • An access token about to expire is refreshed first. Where a provider needs asking for a refresh token (Google's access_type=offline, the offline_access scope for Microsoft and OpenID Connect providers, X's offline.access, Dropbox's offline access, Reddit's permanent grant), sign-in asks for it when store_tokens is on. When the provider has ended the grant, the tokens are deleted and the answer is None.
  • Disconnecting the provider, merging its account into another, or auth.delete_account(account, &request) deletes the tokens, and revokes them at providers that let apps do that: Google, Apple, X, Discord, LinkedIn, GitLab, Twitch, Reddit, Zoom, TikTok, Yahoo, and any OpenID Connect provider whose discovery document names a revocation_endpoint. Apple asks apps to revoke its tokens when someone deletes their account.
  • It runs on the server only; the client functions never see the tokens, on any platform.

On every platform#

PlatformThird-party sign-in
WebEverything above
iOS and macOS appsEverything above, in an ASWebAuthenticationSession; Apple through Sign in with Apple's own sheet
Android appsEverything above, in Chrome's Auth Tab (Chrome 137 or later as the default browser); with another browser the outcome is Failed("unsupported")
Windows and Linux appsEverything above in the default browser, for a host that registers web-auth itself; the generated runners can't host plugins yet and answer config
HarmonyOS (experimental)sign_in_with_provider() and link_provider() answer config; listing and disconnecting work

Your providers need no extra setup for apps: the provider sends the browser back to your site's /auth/callback/… address, as for the web, and your site sends it on to the app.

How it's kept safe#

  • Every flow uses PKCE (S256), except with Notion and TikTok, whose web sign-ins don't take it, a random state, and, for OpenID Connect, a nonce. They're kept on the server, encrypted, for ten minutes, and the browser keeps only a random token in an HttpOnly cookie, so a sign-in only finishes in the browser that started it.
  • ID tokens must be signed by a key the provider publishes (RS256, PS256, ES256, ES384, or EdDSA; never none or a shared secret), for your client, from the right issuer (for Microsoft, the person's own tenant), unexpired, and with the flow's nonce.
  • Apple posts its answer from its own site, which SameSite cookies don't follow, so that post only gets a page that posts it again from your site; it never signs anyone in by itself.
  • Provider secrets come from environment variables; Apple's client secret is signed for each request from your .p8 key, so there's nothing to rotate.
  • A native app signs in in the system browser, never a web view inside the app, so the app never sees the person's password at the provider. Your site sends the browser back to the app with the provider's answer, and the app finishes with a token that never left it: an answer intercepted on the way, or a sign-in link someone else started, signs no one in. The app's return address must be one of its own: <package id>:/auth/native/return, your site's /auth/native/return, or a loopback address on the device.
  • Sign in with Apple on the device must return an ID token signed by Apple, for your app's bundle ID, with the nonce your server issued for that sign-in.

Native apps#

The apps sign in through two plugins, and your site vouches for them, so that their passkeys are your site's.

PluginWhat it doesiOS and macOSAndroidWindows and Linux
passkeysMakes and uses passkeys; Sign in with AppleASAuthorizationCredential Manager; no AppleUnavailable
web-authSigns in with other sites in the system browser, never a web viewASWebAuthenticationSessionAuth Tab, Chrome 137 or laterThe default browser, on loopback

leptoskit new --auth and leptoskit generate auth grant both. To make passkeys work in the apps, tell [native] who signs them, and serve what that generates from your site:

[native]
package_id = "com.example.notes" # the bundle ID and Android application ID
apple_team_id = "ABCDE12345" # the team that signs the iOS and macOS apps
android_certificates = [ # keytool -list -v prints these as SHA-256
	"AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89:AB:CD:EF:01:23:45:67:89",
]
let app = backend::router()
	.merge(auth.router())
	.merge(sign_in::associations()?)          // the generated sign-in module's
	.layer(auth.layer());

With these, your site serves /.well-known/apple-app-site-association and /.well-known/assetlinks.json, made from the IDs above, so they can't fall out of step with the apps. leptoskit::server::app_associations(&config.native) does the same for a server written by hand. The sign-in service reads the same [native] table: it accepts passkeys from the Android app signed with those certificates, and Sign in with Apple ID tokens for that bundle ID.

On each platform:

  • iOS and macOS: add the associated domain webcredentials:myapp.com (your site's host) to the app in Xcode, and the Sign in with Apple capability if you offer Apple. The generated Xcode projects don't add entitlements yet. Without the domain, passkey calls fail with passkey_failed; providers still work.
  • Android: list the certificate of every key that signs the app: your upload key, and Google Play's app signing key if Play signs your releases. Provider sign-in needs Chrome 137 or later as the default browser; on another browser it ends with Failed("unsupported"), and people can still sign in with an emailed code or a passkey.
  • Windows and Linux: the generated runners can't host plugins yet, so sign-in answers config. A host that registers plugins itself can register leptoskit_plugin_web_auth::desktop::WebAuth on a worker thread, for providers in the default browser. There are no native passkeys there: passkey calls answer unsupported.

Solana wallets#

People can link a Solana wallet to their account with a message their wallet signs for your site, show it on their profile, require a fresh signature before a payout, and, if they choose, sign in with it. Rules can name a wallet's owner with cx.wallets(). In the browser, any wallet that supports the Wallet Standard works; native apps manage linked wallets, and answer unsupported for asking a wallet to sign until a native wallet adapter arrives. See Solana wallets.

Check the configuration#

leptoskit auth doctor checks [auth] against the environment it runs in, and never prints a secret:

  • that each variable is set, and the keys parse;
  • the origin, and the relying party passkeys belong to;
  • where each provider sends people back, and whether its built-in definition was checked within the last year;
  • that an iOS app with third-party sign-in also offers Sign in with Apple, as the App Store expects.

With --release, it fails on what a release build mustn't have: a plain http origin, the development mailbox, or a plain http origin in [native.plugin-options.http] origins, which would send a native app's tokens unencrypted. Run it in CI before you ship.

leptoskit auth rotate-keys re-encrypts stored authenticator secrets, and sign-ins in progress, under the newest key in AUTH_ENCRYPTION_KEYS, so you can retire an old key. It's safe to run while the app is running, and again. Recovery codes and emailed codes are hashes, which can't be re-encrypted: keep the old key listed until people have made new recovery codes, or accept that their old ones stop working.

Sessions#

Every token is 256 random bits. The device keeps it, and the database keeps only its SHA-256 hash, so a copy of the database signs no one in. Tokens aren't signed, and carry nothing a script could read or forge: the server looks each one up on every request, so ending a session takes effect immediately, with no lifetime to wait out. The layer turns the token back into the person on each request; outside a request, auth.resolve(&token) does the same.

A session ends after 14 days unused, or after 90 days however often it's used; SessionLifetimes changes both, and how long a native app's access token works. A token that is unknown, expired, or revoked, or whose account is disabled, is signed out.

To end sessions from server code:

  • auth.sign_out(&token, &request) ends the current session.
  • auth.revoke(&signed_in, session_id, &request) ends one of the person's other sessions, such as a lost phone's. It never touches anyone else's.
  • auth.revoke_others(&signed_in, &request) ends every session but the current one.
  • auth.sign_out_everywhere(account, &request) ends every session an account has, the current one included. Use it for a "sign out everywhere" button, or when an account is stolen.
  • auth.devices(&signed_in) lists the person's live sessions, the current one marked.
  • auth.refresh(&refresh_token, &request) trades a native app's refresh token, as POST /auth/refresh does.
  • auth.rotate(&signed_in, &request) replaces the current token with a new one, so a token captured earlier stops working. Rotate whenever the person's privileges change.
  • auth.disable(account, &request) stops an account from signing in and ends every session it has. auth.enable(account) lets it sign in again.

Rules about the signed-in person#

The layer gives each request a database handle that knows who it's for. Outside the layer, build one yourself:

let db = db.with_context(auth::context(signed_in));

Policies read the signed-in person through cx.signed_in, which matches nothing for a signed-out request:

use leptoskit::auth::AuthContext;

#[db::table(name = "posts")]
#[db::policy(all, |post, cx| cx.signed_in(|me| post.author_id.eq(me.user_id())))]
pub struct Post {
	pub id: Id<Post>,
	pub author_id: Id<User>,
	pub body: String,
}

me.user_id() is the person's ID in your user table, since it shares the account's key.

cx.wallets() is the addresses of the person's linked wallets, so a rule can say "whoever holds this wallet". It's empty for a signed-out request, so the rule matches nothing then:

#[db::table(name = "vaults")]
#[db::policy(update, |vault, cx| vault.treasury.in_list(cx.wallets()))]
pub struct Vault {
	pub id: Id<Vault>,
	pub treasury: Pubkey,
}

auth.request_context(signed_in) builds a context with both, as the layer does; auth::context(signed_in) has the person without their wallets.

The auth tables have policies of their own. A signed-in person reads only their own account, emails, sessions, and events, and nobody reads codes or rate-limit counters. Only leptoskit_auth writes any of them.

Limits#

Every step is rate limited, by address and by IP address:

StepDefault limit
Sending code5 per address per 15 minutes; 20 per IP per hour
Checking one30 per IP per 15 minutes, and 5 guesses per code
Authenticator or recovery code10 per account per 15 minutes, and 5 per second step
Starting a passkey, provider, or wallet request120 per IP per 15 minutes
Signing in with a wallet30 per IP per 15 minutes
Wrong second factors20 per account per day, then locked for an hour
Passwords tried for one address10 per 15 minutes
Wrong passwords for one address20 per day, then password sign-in pauses for an hour

Change them with Limits. An attempt over the limit fails with AuthError::RateLimited, which says how long to wait. The counters live in auth.rate_limits, and concurrent attempts can't both take the last one. To count somewhere faster, such as Redis, implement auth::RateLimiter.

Test sign-in#

auth::Mailbox keeps the emails a test sends, so a test signs in the way a person would:

let mailbox = auth::Mailbox::new();
let auth = auth::Auth::builder(&db, auth::Keys::single("test", [7; 32]))
	.mailer(mailbox.clone())
	.on_sign_up::<User>()
	.build()?;

auth.send_email_code("[email protected]", &auth::RequestInfo::default()).await?;
let mail = mailbox.latest_to("[email protected]").unwrap();
let code = mail.subject.rsplit(' ').next().unwrap();
let sign_in = auth
	.verify_email_code("[email protected]", code, &auth::RequestInfo::default())
	.await?
	.signed_in()
	.expect("no second factor yet");
esc