Suprnova ships a Laravel-shaped authentication system: a static Auth
facade, named guards resolved through an AuthManager, pluggable user
providers, an Authenticatable trait on your User model, and middleware
to gate routes. A scaffolded project boots with a session guard (web)
and a token guard (api) already wired against your typed User, so
login, registration, and protected routes work the day you run
suprnova new.
The pieces
| Type | Role |
|---|---|
Auth |
Framework facade for guards plus Magnetar-backed password, magic-link, passkey, and OAuth operations |
MagnetarConfig / init_magnetar |
Compose and atomically install the default password, session, lockout, passkey, and factor engines |
Authenticatable |
Trait your application model implements; surfaces get_auth_identifier() -> String and the password hash |
UserProvider |
Trait that fetches application users; EloquentUserProvider<M> and DatabaseUserProvider ship built in |
AuthManager |
Holds the AuthConfig and registered providers; resolves named guards on demand |
SessionGuard / TokenGuard |
Framework stateful and stateless guard contracts |
BearerTokenMiddleware |
Resolves Magnetar bearer sessions into framework request authentication state |
AuthMiddleware / GuestMiddleware / BasicAuthMiddleware |
Route guards |
Credentials |
JSON-shaped credential map, typically { "email", "password" } |
Framework guard/provider code lives in framework/src/auth/. The Magnetar host
adapters and facades live in framework/src/magnetar_integration/; the engine
crate lives in crates/suprnova-magnetar/. An application never depends on
that crate: every Magnetar type a public signature of the framework names is
re-exported under suprnova::magnetar_integration. Higher-level email
verification, password reset, lockout, and TOTP flows live in
framework/src/auth_flows/ and are covered by Auth flows.
OAuth, Apple, and magic-link login are covered by
OAuth and passwordless login.
Identifier model
The authenticated user's id flows through Suprnova as a String
end-to-end - session storage, [UserProvider::retrieve_by_id], the
remember-me table, every auth event. The canonical surface is
Authenticatable::get_auth_identifier() -> String (Laravel's
getAuthIdentifier). Numeric primary keys stringify trivially; UUIDs,
ULIDs, and opaque OAuth provider ids flow through unchanged.
use Any;
use Authenticatable;
get_auth_password is what the built-in providers verify a plaintext
password against via hashing::verify_async. Return None for users
that authenticate by other means (OAuth, passkey, magic link). The
auth_identifier_name() -> &'static str method (default "id") names
the column the id lives in. The convenience auth_identifier() -> i64
default-parses the string id and falls back to 0 for non-numeric ids -
Suprnova itself never calls it; override only for integer-keyed models
that want to skip the parse.
Why Suprnova diverges
Laravel's getAuthIdentifier() returns mixed. PHP doesn't care
whether the id is an int, a UUID string, or a stringly-typed primary
key from a legacy table. Rust needs a single concrete type the session,
the provider, and the events all agree on. String is the only choice
that accommodates every id shape without forcing the framework to know
which one your app uses. The auth_identifier() integer convenience
exists for the common case where your column is a BIGINT, but the
framework never depends on it - switch your User to a ULID tomorrow
and nothing in the auth stack notices.
Wiring auth at boot
The Rust analogue of config/auth.php is an AuthConfig registered as
an AuthManager singleton on the container, plus a UserProvider
registered under a name. bootstrap.rs typically does both in two
lines:
use Arc;
use ;
use crateUser;
pub async
AuthConfig::from_env() reads the default guard from AUTH_GUARD
(default "web") and ships with two named guards out of the box: a
web session guard and an api token guard, both backed by the
"users" provider. Apps that need more guards (separate admins
provider, distinct stateful and stateless guards) build the config
explicitly:
use ;
let config = new
.guard
.guard
.guard;
Initialize the Magnetar engine
The API starter initializes Magnetar after the database and APP_KEY are
ready:
use ;
pub async
The default engine shares the application SeaORM connection and creates its
schema unless .apply_migrations(false) is selected. It installs the
password/session and passkey adapters atomically. Reinitialization returns an
error rather than replacing one adapter while another request still uses the
old store.
MagnetarConfig also accepts session, lockout, and two-factor policy values:
let magnetar = from_sea_orm
.session_config
.lockout_config
.two_factor_config
.passkey_config;
The default host binding uses the canonical app_users table with i64
application IDs. Magnetar's public UserId remains opaque at the facade
boundary; the default binding parses the stored identifier only where it
crosses into the application table.
Magnetar-backed facade methods
The installed engine powers these framework-owned methods:
Auth::password().register(...).Auth::password().authenticate(...).Auth::magic_link().send(...)and.consume(...).Auth::passkey().begin_registration(...)and.finish_registration(...).Auth::passkey().begin_authentication(...)and.finish_authentication(...).Auth::oauth(provider)when an OAuth delegate is installed.- Remember-me issuance, rotation, and revocation.
- Bearer-session lookup through
BearerTokenMiddleware. list_sessions,revoke_session, andrevoke_all_sessionsinsuprnova::magnetar_integration.
Successful sign-in rotates the framework session ID and CSRF token, stores the application user ID, and records an opaque Magnetar web binding. The framework continues to own HTTP middleware, cookies, mail, events, and its guard/provider contracts.
Framework logins under the engine
The framework's own login paths check credentials outside Magnetar:
Auth::login_id, Auth::attempt, Auth::login, Auth::login_using_id, and
TwoFactor::complete_challenge. With the engine installed, every web login
needs a Magnetar session, so revocation and auth epochs reach it. For these
paths, SessionMiddleware issues the user's Magnetar session at the end of the
request and records its binding before it stores the session. This issuance is
host-trusted, like Laravel's Auth::loginUsingId: the engine takes the
framework's word for who signed in, and no Magnetar plugin can reach it.
- Same user ids. The default guard's
UserProvidermust resolve Magnetar's user ids, for example a provider over Magnetar'sapp_userstable. The framework cannot check this at boot, because a provider is opaque until it resolves a user. A login whose id Magnetar does not know fails with a500that names the cause. - Second factors. An account with a confirmed Magnetar second factor is
refused with
409, because the framework login did not prove it. The session guards, including the once-only paths (Auth::once,Auth::once_using_idandBasicAuthMiddleware::once()),Auth::login_rememberandTwoFactorask before they log in or read a code, so a refusal fires noLoginevent, consumes no code, and issues no remember-me credential.Auth::login_idcannot await the engine, so its refusal arrives at the end of the request, which then stores nothing and retires a remember-me credential issued during it. - Framework TOTP. The reverse holds too. Magnetar's own sign-ins -
Auth::password(),Auth::magic_link(),Auth::passkey()andAuth::oauth()- never read the framework'sTwoFactortable, so each of them refuses an account with confirmed framework TOTP with409before a session or challenge exists. That account signs in through the application's login andTwoFactor::complete_challenge. A remembered sign-in still passes, because a remember-me credential is issued only after a full sign-in. Enrolling either factor returns409while the account has the other, confirmed or pending, so an account holds one. An account that has both anyway, from before that check or from an import, is refused by every path until an administrator disables one of them; see One second factor per account. - Auth epochs. Every framework sign-in carries the user's auth epoch, as
Auth epochs describes. Under the engine, the default guard's
epoch is Magnetar's own, and the session middleware checks it on every
request by validating the Magnetar session, so the framework compares no
second value for that guard.
Auth::attemptissues the session at the auth epoch read with the password, and carries that epoch intoTwoFactor::start_challengerather than reading it again. A password reset or "sign out everywhere" that commits after the password was read therefore cancels the sign-in with401, and one betweenstart_challengeandcomplete_challengecancels the challenge with401before its code is read. The epoch comes from the same row read as the password hash when the user model returns it fromAuthenticatable::auth_epoch; otherwise the session guard reads it right after looking the user up and before checking the password. They never see a password elsewhere:Auth::login_idreads the epoch when the request commits, and astart_challengeafter the application's own password check reads it when it runs. - Remember-me. The factor check covers remember-me credentials issued
during these logins. A credential issued later with
Auth::issue_remember_cookieis not checked: Magnetar treats a remembered sign-in as having proved every factor, so issue one only for a user who did. - Replaced logins. When a login replaces a bound identity, or a logout ends one, the old Magnetar session is revoked.
- Custom engines. A custom engine signs these logins in only if it
implements
admit_host_sign_inandissue_host_session. Their default bodies refuse, so under an engine without them every framework login fails closed with503instead of storing an identity the next request would drop.
When the engine refuses or fails, the request answers with that error and stores nothing. The browser is never told it signed in when the next request would sign it out.
Password authentication
Use the Magnetar password facade when the application wants the integrated credential, lockout, factor-gate, and session path:
use ;
// Both outcomes get the same answer, so the endpoint does not reveal which
// addresses already have an account.
let _registration: Registration = password
.register
.await?;
let response = json
.status;
// Later, on the sign-in endpoint:
let = password
.authenticate
.await?;
register returns a Registration. Registration::Created(user) carries the
new account. An address that already has an account returns
Registration::Accepted, which carries nothing: that account is neither
changed nor returned, so registration can never sign the requester in as its
owner. Signing a Created account in at once is safe, because its password is
the one just submitted, but that answer differs from the one Accepted can
give, so it shows that the address was free. Answer both variants the same way
when registration must not reveal which addresses have accounts.
The two outcomes cost nearly, not exactly, the same. Both hash the submitted password, the expensive step, before the address is looked up, and both read an account back. Only a new address writes a row, so over many requests the response time can still tell a free address from a taken one where a write is slow. Registration goes through the auth abuse limiter, which keeps that kind of probing slow; don't rely on the timing alone to hide an address.
Why Suprnova diverges
Laravel's starter kits validate registration with a unique:users rule, which
answers "The email has already been taken" and so tells any visitor which
addresses have accounts. register instead reports an existing address as
Registration::Accepted and returns nothing about its account, so the
application can give one answer to every registration.
authenticate returns HTTP 401 errors for invalid credentials, lockout, or a
required second factor. Storage and engine failures remain server errors. The
method never returns password material.
Passkeys
Passkey begin and finish calls require SessionMiddleware because the
single-use ceremony selector is stored in the framework session:
let challenge = passkey
.begin_authentication
.await?;
let = passkey
.finish_authentication
.await?;
Registration follows the corresponding begin_registration and
finish_registration pair. Existing-account enrollment requires a verified
request actor and recent reauthentication through the plugin path; a bare user
ID in a legacy session is not promoted into a credential actor.
First email proof and auth epochs
Magnetar treats the first successful mailbox proof on an unverified account as an atomic credential boundary. Password reset, magic-link consumption, and OAuth verified-email completion can win this boundary.
The transaction advances the account's authentication epoch, revokes old sessions and remember credentials, and removes provisional credentials that a squatter could have registered before the mailbox owner arrived. Password, passkey, linked-account, and two-factor writes carry an actor snapshot and fail if the account epoch changed while the operation was in flight.
For an already verified account, password reset preserves legitimate passkeys, linked accounts, and two-factor enrollment while still rotating the password and invalidating sessions. OAuth never auto-links an unverified existing account by email alone; it requires verified-email completion or explicit linking according to host policy.
Direct Magnetar crate surface
Most applications stay on the framework facades. Applications building a
custom identity host can depend directly on suprnova-magnetar for:
- Framework-neutral plugin routes and effect handlers.
- Password and password-management plugins.
- Passkey and two-factor engines.
- OAuth authorization, grants, provider plugins, device authorization, and token-broker services.
- Opaque, JWT, remember, and grant session engines.
- Custom storage bindings and the default SeaORM schema.
- Shape-aware auth-data migration.
Direct use does not transfer HTTP or application-user ownership to Magnetar. The host still maps wire requests, mail effects, application IDs, rate-limit drivers, and session bindings into its own framework.
The Auth facade
The static Auth facade is the Laravel-shaped surface you call from
controllers and middleware. The credential- and user-based methods
delegate to the default guard (whatever AuthConfig::default_guard
points at, default "web"); the synchronous check/guest/id reads
are the session-backed fast path and need no manager.
use ;
// Validate credentials and log the user in. Fires Attempting → (Login +
// Authenticated), honours remember-me. Returns the resolved user, or
// None on bad credentials.
if let Some = attempt.await?
// Log a known user in directly.
login.await?;
// Log in by id without re-checking credentials (e.g. just-finished registration).
login_using_id.await?;
// Validate credentials without persisting a session (password-confirmation dialogs).
let ok: bool = validate.await?;
// Authenticate for this request only - no session write. Laravel's `once`.
let ok: bool = once.await?;
once_using_id.await?;
// Session-backed fast path (no AuthManager required).
if check
if guest
if let Some = id
// Whether the current user was authenticated by remember-me cookie this
// request. Laravel's `viaRemember()`.
if via_remember
// Resolve the current user (via the registered provider).
if let Some = user.await?
if let Some = .await?
// Tear down auth + revoke remember-me + rotate CSRF + fire Logout.
logout.await?;
// Full session destruction (regenerate id + flush + revoke remember-me + fire Logout).
logout_and_invalidate.await?;
Auth::attempt returns the resolved user on success rather than a bare
bool - richer than Laravel's API, and saves the follow-up Auth::user()
call. Ok(None) means the credentials did not resolve a user; Err
means a database / hashing / configuration failure that needs to bubble.
If you have already verified a user's identity yourself and only want to establish the session - say after an OAuth callback completes - reach for the synchronous primitive:
// Sync, no provider, no AuthManager, no events. Returns Err when called
// outside a request scope (no SessionMiddleware installed) so a
// silently-dropped login can never look like success.
login_id?;
login_id regenerates the session id (preventing session fixation) and
rotates the CSRF token, then writes the id into the session. It's
deliberately failure-loud: previous versions silently no-op'd outside a
session scope, and the audit fixed that - a "successful login" that
never landed is the kind of bug nothing else catches.
You call Auth::logout_other_devices(password).await? from a session-backed
request to sign out your other devices. You verify the password against the
current user through the default guard's provider. With the correct password,
you revoke the user's other sessions and remember credentials while keeping
the current session ID, CSRF token, session data, and remember selector. With
an incorrect password, you receive FrameworkError::Validation on password
and keep every credential unchanged.
When the provider rewrites the password hash after the password check, as
EloquentUserProvider and DatabaseUserProvider do for a database shared
with Laravel, the hash is a new auth epoch. The current
session is bound to the new epoch, and without Magnetar the current
remember-me cookie is replaced with one bound to it, so this browser stays
signed in.
With Magnetar installed, you also revoke its other sessions and remember
credentials while keeping the current web binding. You keep the current
browser signed in without advancing the user's auth epoch. You propagate
storage errors. With a custom session store, you implement
SessionStore::destroy_other_guard_sessions; with a custom Magnetar remember
store or engine, you implement revoke_other_remember to preserve the given
selector.
Auth::user() and user_as<T>
Auth::user() returns the user behind the trait:
if let Some = user.await?
That trait object covers anyone who implements Authenticatable. To get
your concrete User back, downcast through user_as::<T>():
use Auth;
use crateUser;
if let Some = .await?
user_as returns Ok(None) both when no user is authenticated and
when the resolved user isn't a T (e.g. an Auth::set_user(...) of
a different type elsewhere in the stack). Inside a request the user is
cached per-request, so calling Auth::user() repeatedly only hits the
provider once.
Named guards
The bare Auth::* methods talk to the default guard. To act against a
specific guard, resolve it by name:
use Auth;
// Read-only operations work on every driver.
if guard?.check.await?
// Login/logout/attempt need a stateful guard. Token guards fail loud here.
let user = stateful_guard?
.attempt
.await?;
Auth::guard("name") returns Arc<dyn Guard> (the read contract) and
Auth::stateful_guard("name") returns Arc<dyn StatefulGuard> (adds
attempt/login/logout). Asking for the stateful contract on a token
guard returns an error with a remediation message rather than silently
limiting the API.
Guards of your application
The framework ships two guard drivers, session and token. For a
guard it does not ship (an API key, a client certificate, a single
sign-on cookie), you register a driver of your own and declare a guard
that uses it. GuardDriver has three variants: Session, Token, and
Custom(String). It is not Copy, so borrow a driver or call
.clone() where you copied it, and give a match that names every
variant a GuardDriver::Custom(_) arm.
The name of a guard of your application cannot be empty or contain :,
and neither can the name of any other guard except the default session or
token guard. The manager returns an error when you resolve such a guard,
and via_request refuses to register a resolver for it, before either
changes anything. The rule keeps two guards from attesting the same principal (see
Guards and Live).
Auth::extend
Auth::extend(driver, factory) registers the factory of a driver.
GuardConfig::custom(driver, provider) declares a guard of that driver:
use Arc;
use ;
let config = from_env
.guard;
// The factory gets the name of the guard and its provider. It returns
// any `Guard`; `TokenGuard` stands in for your own type here.
extend?;
The factory has the type
Fn(&str, Arc<dyn UserProvider>) -> Result<Arc<dyn Guard>, FrameworkError>.
Inside a request the manager builds one instance per guard name and keeps
it for that request, so the factory runs once per request and a guard may
keep its user on the instance; outside a request each Auth::guard("partner")
builds a fresh one. State that must outlive the request still belongs in
the session or your own store, not in the guard value.
If you resolve a guard whose driver has no factory, you get an error
that names the guard and the driver and tells you to call
Auth::extend. It never falls back to a built-in driver. Registering a
driver twice keeps the last factory, and Auth::extend never replaces
session or token. It refuses a driver name that starts with
via_request:, because that prefix belongs to Auth::via_request.
Auth::via_request
Most custom guards read one thing from the request and look up a user.
Auth::via_request(name, resolver) is the short form for that. The
resolver gets the &Request and answers with the user (Ok(Some(user))),
with no user (Ok(None)), or with an error. You declare the guard with
the driver that AuthManager::via_request_driver(name) derives from its
name.
This example authenticates a partner from the X-Api-Key header, from
the config to the route:
use Arc;
use text;
use ;
pub async
pub async
GuestMiddleware::for_guard("partner") runs the resolver the same way.
These rules hold for a guard of Auth::via_request:
- The resolver runs once for a request, inside the middleware for that
guard.
AuthMiddleware::for_guardandGuestMiddleware::for_guardrun it, and the guard reads the answer. - Outside that middleware,
Auth::guard("partner")reports no user, as the token guard does withoutBearerTokenMiddleware. A handler that no such middleware protects sees a guest. - An error of a factory or of a resolver fails the request with
500. It is never a guest, andAuthMiddleware::optional()does not change that. - The guard is read-only through the manager.
Auth::stateful_guardreturns an error for it, and so doesBasicAuthMiddleware::for_guard. - Each guard sees its own user. A guard of your application never reads
the user of the default guard or of another guard, and
AuthMiddleware::for_guardtakes the principal of the request from the user of that guard.
AuthManager::via_request(name, resolver) is the method of the manager
behind Auth::via_request. It returns a Result, so end the call with
?.
The same rules hold for a guard that Auth::extend registers, except
that no resolver runs. A middleware earlier in the chain validates the
credential and records what it proved in request-scoped state, and your
guard reads that state, as the token guard reads the bearer id.
Auth::via_request_with_provider
A resolver that looks the user up through the guard's own provider takes
that provider as a second argument. Auth::via_request_with_provider(name, resolver) hands it the Arc<dyn UserProvider> that the guard's
configuration names, as Laravel's viaRequest callback receives $request
and $provider:
use Auth;
// `partner` is declared with `GuardConfig::custom(driver, "partners")`,
// so `provider` is the provider registered under `partners`.
via_request_with_provider?;
The resolver doesn't capture a provider of its own, so a callback you port
from Laravel keeps its shape. The manager looks the provider up each time
the resolver runs. A provider you register after the resolver, or register
again under the same name, is the one the resolver receives, and the same
one the guard's validate uses. If no provider is registered under the
name the configuration gives, the request fails with 500 before the
resolver runs.
Auth::via_request is built on this function: its resolver takes the
request alone, and the rules above hold for both. Registering a guard name
twice keeps the last resolver, whichever function registered it.
AuthManager::via_request_with_provider is the method of the manager
behind it.
A guard of your application as the default guard
A guard of your application is read-only through the manager. When you
declare one as the default guard, the functions of Auth that take no
guard name act as follows:
Auth::userand the other functions that load the user ask that guard.Auth::logout,Auth::login_idandAuth::login_rememberreturn an error and change nothing. So do the stateful functions that go throughAuth::stateful_guard, such asAuth::loginandAuth::attempt.Auth::set_userbehaves as it does with the token guard:Auth::idandAuth::checksee the user, andAuth::userstill answers from the guard itself.AuthMiddleware::new(), with nofor_guard, asks that guard, runs its resolver, and attests the user it returns.
A logout forgets the users that the guards of your application resolved in the request, so the request does not sign itself back in through a later middleware.
Guards and Live
The principal that AuthMiddleware attests for a Live component is the
user of the guard it checked. For the default session or token guard, it is
the bare id, such as 7. For any other guard - a second session guard such
as admin, a second token guard, or a guard of your application - it is
<guard>:<id>.
The same id under two guards is two principals: web user 7 differs from
admin 7 and from partner 7, even when both guards read one table. A user
of another guard in the same session never stands in for the guard's own
user.
A default-guard id that holds a : gets a leading :. Web user admin:9
attests :admin:9, so it never reads as admin 9, whose principal is
admin:9. No guard can attest a principal that starts with :, because a
guard name cannot be empty. An id without a : is its own principal, so an
application whose ids are numbers, UUIDs or ULIDs sees the value it always
saw.
The render cache keys a page by the default guard's principal. An identity
read through any other guard is recorded as <guard>:<id>, so a page built
from it is never stored under the default guard's key and never served to a
visitor who lacks that guard's sign-in. See Render
cache.
Live's gated actions, uploads, subscriptions and memberships, and the
Pusher endpoints, use the same principal: the route's user, the bare id for
the default guard (with the leading : above) and <guard>:<id> for any
other. See Live and
Broadcasting.
Why Suprnova diverges
In Laravel, the closure of Auth::viaRequest runs when the guard
resolves its user, and it gets the current request. A Rust guard cannot
reach the request from user(). So the middleware for the guard runs
the resolver with the request, and the guard reads the answer. That is
why the guard reports no user outside that middleware.
Laravel's request guard also answers validate through its closure, which
checks no password. A Suprnova request guard validates credentials through
its provider, as the token guard does.
User providers
A UserProvider tells the auth stack how to fetch and validate users.
Two providers ship built in, so the common case needs no custom
implementation:
EloquentUserProvider<M>- resolves through a typed#[suprnova::model]Userthat is alsoAuthenticatable. Looks up by primary key for ids, byemail(default) for credentials.DatabaseUserProvider- resolves a raw table by name into aGenericUser(id + attribute map). Use it when you don't have or want a typed model.
Both filter credential lookups against an allowlist (default
["email"]) - a hostile credential map cannot inject extra WHERE
predicates. Customise the allowlist with .credential_columns([...]),
the lookup column with .identifier_column("uuid"), or the id-binding
strategy with .with_id_parser(...).
By default a numeric id binds as an integer and any other id as text. An
id above i64::MAX binds as a u64, so a user whose key is Laravel's
BIGINT UNSIGNED users.id signs in at any value on MySQL. Postgres and
SQLite hold no integer key that large, so there such an id finds no user.
A text identifier column that holds the id's digits finds the user on
every database: DatabaseUserProvider reads the column's type for such
an id and compares the digits as text. The type comes from the table the
lookup reads: on Postgres the first schema of the search path that holds
it, or the schema the table name names, so a table of the same name in
another schema never decides the bind.
To plug in a custom source (LDAP, an external API), implement
UserProvider directly. retrieve_by_id takes the identifier as
a &str:
use async_trait;
use Arc;
use ;
;
Register it on the manager:
register_provider?;
Protecting routes
AuthMiddleware
Gate authenticated-only routes. Unauthenticated requests are redirected
to a login page or receive 401:
use ;
AuthMiddleware::new() returns 401 Unauthorized instead - best for
JSON APIs. AuthMiddleware::redirect_to("/login") issues a 302 for
regular requests and a 409 X-Inertia-Location for Inertia requests
(which the Inertia client turns into a full-page visit). To gate on a
specific guard, chain for_guard:
// 401 unless the api guard is authenticated.
.middleware
A token guard (for_guard("api")) relies on whatever bearer-token
middleware runs earlier in the chain to populate the request's auth id;
without it the guard always reports unauthenticated.
A guard of your application (see Guards of your
application) decides alone. The user it
resolves is the principal of the request, and the middleware attests it as
<guard>:<id>. With no for_guard, AuthMiddleware::new() does the same
when the default guard is a guard of your application.
GuestMiddleware
The inverse - for login and registration pages that authenticated users shouldn't see:
use ;
GuestMiddleware::for_guard("name") works the same way as
AuthMiddleware::for_guard.
BasicAuthMiddleware
HTTP Basic auth from the Authorization: Basic header against a
guard's provider:
use BasicAuthMiddleware;
// Stateful - logs the user into the session on success (Laravel's `basic`).
.middleware
// Stateless - authenticates for this request only (Laravel's `onceBasic`).
.middleware
The decoded username is matched against the field credential (default
"email"); a missing, malformed, or invalid header returns 401 with
a WWW-Authenticate: Basic realm="..." challenge. Configure with
.field(...), .realm(...), and .for_guard(...).
With BasicAuthMiddleware::new(), a request whose session is already signed
in to the guard passes without a header. The guard first resolves that user
and compares its auth epoch, as AuthMiddleware does, so a
session signed in before a password change is signed out and must present
valid credentials.
Lifecycle events
The guards dispatch five lifecycle events. Listen for them via the
EventFacade:
| Event | When |
|---|---|
Attempting |
a credential attempt begins (attempt/once) |
Authenticated |
a user is actively authenticated this request (login/once/once_using_id) |
Login |
a user is persisted to the session (login/successful attempt) |
Logout |
a user is logged out |
Failed |
a credential attempt fails (bad password or unknown id) |
Every event carries the guard name and a string user id - never the
plaintext password and never the raw credential map. Authenticated
fires only when a user is actively established, not on a passive
Auth::user() resolution off an existing session, so listeners don't
get a stream of duplicates on every authenticated request.
The scaffolded login flow
suprnova new generates an authentication controller that uses
Auth::attempt against the registered provider. FormRequest and Validate
produce the { message, errors } validation envelope. For an Inertia request,
the installed validation-redirect middleware turns that failure into an HTTP
303 See Other redirect back and flashes the errors for the originating page.
A non-Inertia client receives the HTTP 422 Unprocessable Entity JSON
envelope:
use Deserialize;
use ;
// No `errors` prop: the framework seeds `errors` on every Inertia page
// from the session-flashed validation bag, and an explicit prop of the
// same name would replace it.
pub async
pub async
pub async
Registration follows the same shape: validate the form, create the
user, mail a verification link with EmailVerification::send_link, then
Auth::login(Arc::new(user), false).await? logs the freshly created user
into the session, fires the Login event, and continues to
/verify-email. The generated email_verification and password_reset
controllers complete the account flows; see Auth flows.
The scaffolded User model
The generated User is a #[suprnova::model] that implements
Authenticatable. It also contains
email_verified_at: Option<DateTime<Utc>> and implements MustVerifyEmail and
CanResetPassword. Those bridges let EloquentUserProvider<User> mark email
verification and supply password-reset identity data. The excerpt below shows
only the guard-login fields and helpers; use the generated model template for
the complete auth-flow implementation. Its password helpers use the
hashing module:
use ;
use ;
The hidden = ["password", "remember_token"] attribute makes the model
skip those columns when serialising to JSON for the wire - they exist
on the struct but never leak through an Inertia response.
The generated users migration creates email_verified_at, created_at and
updated_at with .date_time(): DATETIME on MySQL, timestamp on
Postgres. The casts name the native casts for those columns; the default
AsDateTime stores text, which those columns refuse (see
native date-time casts).
An application generated before this change has .timestamp() columns
instead, TIMESTAMP on MySQL and MariaDB and timestamp on Postgres, and a
User without casts, which fails to register or sign in a user on those
engines. Give its three fields the casts for its columns:
AsNativeDateTime and AsOptionalNativeDateTime on MySQL and MariaDB,
AsNaiveDateTime and AsOptionalNaiveDateTime on Postgres. On SQLite
either pair reads the rows the text cast wrote.
Remember-me
When a Magnetar engine is installed, Auth::attempt(credentials, true) and
Auth::issue_remember_cookie issue purpose-bound Magnetar remember
credentials. The browser still receives the framework's encrypted
remember_me cookie, while Magnetar owns verifier storage, auth-epoch checks,
single-use rotation, anomaly handling, and revocation.
On a request without an active framework login, SessionMiddleware consumes
the cookie through the installed engine, rotates the remember credential,
issues a fresh Magnetar session, and binds both session layers. A stale auth
epoch, revoked account session, malformed credential, or replay does not
authenticate the request.
Auth::revoke_remember_tokens() invalidates every remember credential for the
current user. The clear cookie is queued before backend revocation, so the
browser drops its credential even when the storage operation fails.
When no Magnetar engine is installed, the framework retains the legacy
remember_tokens fallback for compatibility. New applications should
initialize Magnetar rather than relying on that fallback. Each row of the
fallback is bound to the user's auth epoch at issue: its
hash covers the verifier and the epoch together, so a cookie issued before
a password change fails verification after it, its row is deleted, and its
cookie is cleared.
Auth epochs
Every sign-in that the framework issues through a user provider records the user's auth epoch, and every request that resolves the user compares it with the user's current one. A password change therefore ends every older sign-in on its next request, even when the password reset's revocation of the user's other sessions failed.
The epoch of a user is the value Authenticatable::auth_epoch returns. When
the model returns None, the epoch is a digest of the password hash that
Authenticatable::get_auth_password returns. Every password write stores a
new hash, with a new salt even for the same password, so the digest changes
with every reset, change, and rehash, and no table needs a new column.
The framework records and compares the epoch in these places:
- Session sign-ins.
Auth::attempt,Auth::login,Auth::login_using_id,Auth::login_remember, and the same methods of a named session guard read the epoch through the guard's provider and record it beside the guard's identity in the session.Auth::login_id,session::set_auth_user, andTwoFactor::complete_challengedon't read it themselves. The request that signs in reads it through the default guard's provider beforeSessionMiddlewarestores the session. If the provider fails that read, the sign-in isn't stored, and the framework logs a warning with the guard and the user id. - Remember-me credentials. Without the Magnetar engine, each
remember_tokensrow is bound to the epoch that the guard's provider read for the sign-in. The cookie verifies only against the user's current epoch, read through the provider of the guard that the cookie names. ASessionGuardthat you build yourself under a name theAuthManagerdoesn't know can't issue one: its login completes without a remembered session and logs a warning that names the guard. To use remember-me with such a guard, register the guard with theAuthManager. - Requests.
Auth::user, a guard'susermethod,AuthMiddleware, andBasicAuthMiddleware::new()resolve the user and compare.BasicAuthMiddleware::new()lets a session that is already signed in pass without anAuthorizationheader only after this comparison.Auth::idandAuth::checkread the session without resolving the user, so they don't compare.
When the epochs differ, the framework ends that sign-in before the request
reaches your handler. The guard's identity leaves the session, the
remember-me credential this session holds for the guard is deleted and its
cookie cleared, and the request continues as a guest's: AuthMiddleware
answers 401 or redirects to your login page, and BasicAuthMiddleware::new()
answers its 401 challenge unless the request carries valid credentials,
which sign the user in again. The framework logs the user id at info
level. It dispatches no Logout event, because the user didn't sign out,
and it leaves the other guards of the session signed in.
A user whose model returns None from both auth_epoch and
get_auth_password, such as an OAuth-only account, records no epoch and
isn't checked. A session signed in before 4.0.0 carries no epoch either; it
is checked from the user's next sign-in.
If your table keeps a counter that also advances without a password change,
for example on a "sign out everywhere" action, return it from auth_epoch:
use Any;
use Arc;
use Authenticatable;
The first time a model returns a counter, the sessions it recorded under the digest end once.
With the Magnetar engine installed, the default guard's sign-ins carry Magnetar's own epoch instead; see Framework logins under the engine.
Why Suprnova diverges
Laravel compares a session with the user's password hash only on routes
that run the AuthenticateSession middleware, and flushes the whole session
on a mismatch. Suprnova compares on every request that resolves a user, for
every session guard and for remember-me cookies, so a stolen credential
stops working after a password change whatever middleware the route runs. A
mismatch ends only that guard's sign-in, so another guard signed in on the
same session keeps its sign-in.
Security guarantees
A short list of invariants the auth stack establishes:
Auth::login_idfails loud outside a request scope. Previous versions silently dropped the session write; a "successful login" that never landed is the kind of bug nothing else catches.- Session id and CSRF token regenerate on every login. Both
login_idand the guard-backedlogin/attemptrotate them to prevent session fixation. - Logout clears auth state before revoking remember-me. If the DB revoke fails, the session is already in a logged-out state, so a stale auth slot cannot survive a partial logout. The remember-me clear cookie is queued before the DB delete, so the browser drops the cookie even when the row delete fails (the prune sweep cleans up later).
- A password change ends older sign-ins. Every session sign-in and remember-me credential records the user's auth epoch, and a request that resolves the user refuses one recorded under an earlier password, so a session that a failed revocation left behind stops working on its next request.
- Credential allowlists block injection. Both built-in providers
filter
retrieve_by_credentialsagainstcredential_columns, so extra keys in an attacker-influenced credential map cannot become extraWHEREpredicates. - Credential writes are actor-fenced. Password, passkey, linked-account, two-factor, session, and remember mutations carry the user ID and auth epoch established by verified authentication. Revocation or a first-proof epoch change makes an in-flight stale write fail.
- The first mailbox proof is atomic. On an unverified account, password reset, magic-link consumption, or OAuth verified-email completion advances the auth epoch and removes provisional credentials in the same transaction. A concurrent squatter write cannot restore access after commit.
- Email verification is actor-bound. The framework verification facade requires an authenticated user whose ID matches the token owner. A token for another account is rejected without being consumed.
- OAuth email is not account ownership. An unverified existing account is never auto-linked from a provider email alone. Verified accounts require explicit linking; unverified accounts require the first-email-proof completion path.
- Auth events never carry plaintext. Guard name + string user id,
nothing else. Failed-attempt tracking (email-keyed lockouts) belongs
to
BruteForcein Auth Flows, not the lifecycle events.
The Session chapter covers the cookie configuration
(SESSION_LIFETIME, SESSION_COOKIE, SESSION_SECURE,
SESSION_SAME_SITE, and SESSION_COOKIE_PREFIX) that session-backed guards
inherit.
Next
- Auth flows - email verification, password reset, Magnetar-backed account lockout, framework TOTP 2FA, and auth-flow events
- OAuth and passwordless login - Magnetar OAuth, Apple, magic links, provider policy, and auth-data migration
- Authorization -
Gate, policies, andAuthorizable - Session - the browser session and cookie layer
- CSRF protection - state-changing request protection
- Hashing - bcrypt and Argon2 helpers
