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 |
Static facade - Auth::user(), Auth::attempt(), Auth::login(), Auth::logout(), Auth::guard("name") |
Authenticatable |
Trait your User model implements; surfaces get_auth_identifier() -> String and the password hash |
UserProvider |
Trait that fetches users from storage; EloquentUserProvider<M> and DatabaseUserProvider ship built in |
AuthManager |
Holds the [AuthConfig] + registered providers; resolves named guards on demand |
SessionGuard / TokenGuard |
Session-backed (stateful) and bearer-token (stateless) guards |
AuthMiddleware / GuestMiddleware / BasicAuthMiddleware |
Route guards |
Credentials |
JSON-shaped credential map, typically { "email", "password" } |
The trail in source is short: framework/src/auth/{guard,manager,contract, authenticatable,middleware,session_guard,token_guard,eloquent_provider, database_provider}.rs. Higher-level flows - email verification, password
reset, brute-force throttling, TOTP 2FA - live alongside in
framework/src/auth_flows/ and have their own chapter:
Auth Flows.
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;
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.
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.
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(...).
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.
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(...).
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. The framework's
FormRequest and Validate derives handle per-field validation; the
Inertia client surfaces a 422 with { message, errors } automatically
on the originating page:
use Deserialize;
use ;
pub async
pub async
pub async
Registration follows the same shape: validate the form, create the
user, then Auth::login(Arc::new(user), false).await? logs the freshly
created user into the session and fires the Login event.
The scaffolded User model
The generated User is a #[suprnova::model] that also implements
Authenticatable. Password handling lives in two helpers backed by
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.
Remember-me
Auth::attempt(credentials, remember) with remember = true issues a
remember-me token alongside the session login. The token lives in the
remember_tokens table (bcrypt-hashed, single-use rotating) and a
matching encrypted cookie. On a future request where the session is
gone, SessionMiddleware verifies the cookie against the hashed row,
rotates the token, and hydrates the session - the user is logged back
in transparently.
Apps that have already established a session and want to issue the
remember-me half separately (the 2FA challenge flow does this) reach
for Auth::issue_remember_cookie(&user_id, ttl_minutes).await?.
Auth::revoke_remember_tokens() invalidates every remember-me token
for the current user - the right hook for a "log me out everywhere"
account-security button.
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).
- 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. - 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) that the session-backed guards inherit.
Next
- Auth Flows - email verification, password reset,
brute-force throttling with
LoginThrottleMiddleware, TOTP 2FA, theauth_flowsevent suite - Authorization -
Gate, policies,Authorizablefor "what is this user allowed to do" - Session - the cookie + storage that backs
web-style guards - CSRF Protection - how state-changing requests are gated
- Hashing - bcrypt + argon2 helpers behind
verify_password
