ProjectsmahiAuthentication

Mahi

Framework

A TypeScript application framework for building APIs and services on Node.js — container, ORM, router, queues, mail and more.

Authentication

@mahiframework/auth answers "who is making this request". It has two built-in guards (bearer tokens, cookie sessions), one built-in user provider (database-backed), and a per-request identity scope built on AsyncLocalStorage rather than a request-scoped container.

import { Auth } from "@mahiframework/auth";

const user = Auth.user<UserTable>();       // throws for guests
const maybe = Auth.userOrNull<UserTable>();// null for guests
if (Auth.check()) { /* ... */ }

Authentication is the identity half. The permission half, "may this user do this to this thing", lives in Authorization.

Configuration

config/auth.ts returns an AuthConfig:

export interface AuthConfig {
  default: string;
  guards: Record<string, unknown>;
  providers: Record<string, unknown>;
  passwords?: PasswordBrokerConfig & { provider?: string };
  verification?: EmailVerificationConfig & { provider?: string; model?: unknown };
  /** Read by the SCAFFOLDED controllers, not by the framework. */
  notifications?: { resetPassword?: boolean; verifyEmail?: boolean };
}

The base app ships this:

// config/auth.ts
import type { AuthConfig } from "@mahiframework/auth";
import { User } from "../src/models/user.model.js";
import type { Env } from "./env.js";

export function authConfig(env: Env): AuthConfig {
  return {
    default: "token",

    guards: {
      token: {
        provider: "users",
        expiresInMinutes: null,   // null = never expires, matching Sanctum
      },
      session: {
        provider: "users",
        store: "database",
        cookie: "session",
        lifetimeMinutes: 120,
        sameSite: "Lax",
        secure: env.NODE_ENV === "production",
        path: "/",
      },
    },

    providers: {
      users: {
        driver: "database",
        model: User,
        identifierColumn: "email",
        passwordColumn: "password",
      },
    },
  };
}

guards and providers are typed as Record<string, unknown> because each driver reads its own config shape: TokenGuardConfig, SessionGuardConfig, DatabaseUserProviderConfig. The manager hands the raw object to the factory, which casts it.

Which guard to use

This is topology-dependent, not a preference:

token, bearer tokens in an Authorization header. Correct for a detached frontend (a SPA on another origin) and for any third-party API consumer. Needs no CSRF protection, because browsers never attach an Authorization header automatically.

session, a signed cookie plus a server-side session. Correct when the frontend is served from the same origin as the API. Cross-origin cookies require sameSite: "None" and secure: true, and secure means they will not work over plain HTTP, so a cross-origin SPA in local development silently gets no session at all. That is a browser rule, not a framework limitation. Pair this guard with the csrf() middleware.

If you're building an API that a mobile app or a separately-deployed SPA consumes, use token and stop reading this paragraph. If you're serving HTML and JSON from one origin, session gives you revocable server-side sessions and httpOnly cookies.

Provider registration

AuthServiceProvider must come after DatabaseServiceProvider (user lookups and its own tables), after EncryptionServiceProvider (HASHER_TOKEN for passwords, SIGNER_TOKEN for session cookies), after CacheServiceProvider if you use the cache session store, and before HttpServiceProvider so AUTH_TOKEN is bound and the global auth-scope pipe is collected before routes and middleware are. See Service providers.

The two driver axes

Manager<T> models one swappable-driver axis. Auth genuinely has two, and they're orthogonal:

AxisWhat it decidesRegistered withResolved with
GuardHow a request is authenticatedmanager.extend(name, factory)manager.guard(name) / driver(name)
User providerWhere users come frommanager.extendUserProvider(driver, factory)manager.userProvider(key)

Guards go through the inherited Manager machinery. User providers get a small parallel registry on AuthManager. That split is deliberate: widening Manager<T> to support two axes would complicate every other manager in the framework for one caller's benefit.

The two axes compose. Both built-in guards share one user provider, so swapping SQLite for an external identity service means writing one UserProvider and changing one config line, neither guard changes.

AuthServiceProvider registers the built-ins through exactly the same public methods a plugin would use:

manager.extendUserProvider("database", (app, config) =>
  new DatabaseUserProvider(config as DatabaseUserProviderConfig, hasher));

manager.extend("token", () => {
  const guardConfig = manager.guardConfig("token") as TokenGuardConfig;
  return new TokenGuard(manager.userProvider(guardConfig.provider), guardConfig);
});

Note the asymmetry in how they're named: extendUserProvider() registers a driver ("database"), but userProvider() resolves by config key ("users"), matching how config/auth.ts names them. Passing a driver name to userProvider() throws UnknownUserProviderError; naming a config entry whose driver was never registered throws UserProviderNotRegisteredError.

Writing a custom guard

A Guard is one method:

export interface Guard<TUser = unknown> {
  user(request: Request): Promise<TUser | null>;
}

Deliberately one method. Laravel's guard also carries check()/guest()/id(), but those are pure derivations of user(). They live on AuthManager and the Auth facade instead.

Guards are stateless by contract. A guard is a long-lived singleton shared across every concurrent request (one Application, resolved once by AuthManager), so it must never memoize per-request state on itself. Everything it needs comes from the Request argument; the resolved user goes into the ambient scope.

export class ApiKeyServiceProvider extends ServiceProvider {
  boot(): void {
    const auth = this.app.make<AuthManager>(AUTH_TOKEN);

    auth.extend("api-key", (app) => new ApiKeyGuard(auth.userProvider("users")));
  }
}

Writing a custom user provider

export interface UserProvider<TUser = unknown> {
  retrieveById(id: string): Promise<TUser | null>;
  retrieveByCredentials(credentials: Credentials): Promise<TUser | null>;
  validateCredentials(user: TUser, credentials: Credentials): Promise<boolean>;
  updatePassword?(user: TUser, hashedPassword: string): Promise<void>;
}

The split between retrieveByCredentials() (look up, don't check the secret) and validateCredentials() (check the secret) is intentional. Do not "simplify" it into one findByCredentials that checks the password too, keeping lookup and verification separate is what lets attempt() perform constant work when no user was found, so response timing doesn't leak whether an account exists.

updatePassword() is optional because not every user source is writable. PasswordBroker.reset() requires it and throws loudly if the configured provider doesn't implement it, rather than silently no-op'ing a password change.

AuthManager

Bound as a singleton at AUTH_TOKEN.

MethodReturnsPurpose
guard<TUser>(name?)Guard<TUser>Resolve by config name. Defaults to config.default.
statefulGuard<TUser>(name?)StatefulGuard<TUser>Same, but typed with login()/logout(); throws if the guard has none.
guardConfig(name?)Record<string, unknown>The raw config object for a guard. {} if absent.
guardDriver(name?)stringThe driver a named guard uses.
login(request, userId, opts?)Promise<string>Log a user in through a stateful guard.
attemptLogin<TUser>(request, credentials, opts?)Promise<TUser | null>Verify and log in, Laravel's Auth::attempt().
logout(request, guardName?)Promise<void>End the current session.
collectableGuards()Array<[string, { gc() }]>Guards that can sweep their own expired rows. Drives auth:gc.
extend(driver, factory)thisRegister a guard driver.
extendUserProvider(driver, factory)thisRegister a user-provider driver.
userProvider(name?)UserProviderResolve a configured provider by config key. Cached.
passwordBroker()PasswordBrokerThe single reset broker. Cached.
resolve(request, guardName?)Promise<unknown | null>Authenticate the request and write the result into the ambient scope.
user<TUser>()TUserAmbient user. Throws for guests.
userOrNull<TUser>()TUser | nullAmbient user, or null for guests.
check()booleanWhether anyone is authenticated in this scope.
currentGuard()string | nullThe guard that resolved the current user.
id()stringString(user.id). Throws for guests.
runAs(user, fn)Promise<T>Run fn in an explicit auth scope.
attempt<TUser>(credentials, providerName?)Promise<TUser | null>Verify credentials. Does not log anyone in.

resolve() mutates the state opened by AuthServiceProvider's global pipe rather than nesting a new scope, so one request has exactly one identity for its whole lifetime. authenticate() calls it and 401s on null.

id() assumes an id property, which is the convention every model in this framework already follows (a model's primaryKey config defaults to "id").

Guards are named, and resolved by driver

Guards are the one place AuthManager diverges from Manager's name-equals-driver assumption, because auth's config genuinely has two layers:

guards: {
  web:   { driver: "session", provider: "users" },
  admin: { driver: "session", provider: "users", cookie: "admin_session" },
  api:   { driver: "token",   provider: "users" },
}

Factories are registered by driver (extend("session", ...)), while resolved instances are cached by name, so guard("web") and guard("admin") are two independently configured session guards, and each factory sees its own settings when it calls guardConfig() with no argument.

The framework's own shorthand still works: when a guard block has no driver key, the name is the driver, so { session: { ... } } resolves the session driver as before.

The Auth facade

A hand-written class with real static methods proxying one token. See the facades note for why this is a narrow exception rather than a reversal of "no dynamic facades".

StaticForwards to
Auth.user<TUser>()manager.user()
Auth.userOrNull<TUser>()manager.userOrNull()
Auth.check()manager.check()
Auth.id()manager.id()
Auth.currentGuard()manager.currentGuard()
Auth.attempt<TUser>(credentials, providerName?)manager.attempt()
Auth.attemptLogin<TUser>(request, credentials, opts?)manager.attemptLogin()
Auth.login(request, userId, opts?)manager.login()
Auth.logout(request, guardName?)manager.logout()
Auth.guard<TUser>(name?)manager.guard()
Auth.statefulGuard<TUser>(name?)manager.statefulGuard()
Auth.passwordBroker()manager.passwordBroker()
Auth.runAs(user, fn)manager.runAs()

Logging in and out needs no cast:

await Auth.login(request, user.id, { remember: true });
await Auth.logout(request);

Auth.guard() returns the Guard interface (one method). To reach a concrete guard's own API you cast:

const guard = Auth.guard("token") as TokenGuard<UserTable>;
const { token } = await guard.createToken(user.id, "login");

For the session-establishing half, prefer statefulGuard() over a cast. It is checked, so a guard that can't log anyone in fails with a clear NotStatefulGuardError rather than a TypeError deep inside a handler:

const guard = Auth.statefulGuard("web");   // typed with login()/logout()
await guard.login(request, user.id);

The auth context

Laravel's guard is request-scoped and stateful. Auth::user() works because PHP rebuilds the container per request. Mahi boots one long-lived Application and serves every request from it, so a singleton holding "the current user" would leak one request's user into another. That is a critical security bug, not a stylistic difference.

AsyncLocalStorage gives per-request isolation without per-request container rebuilds, and propagates across await boundaries. The stored value is:

export interface AuthState {
  user: unknown | null;
  guard: string | null;
}

Mutable, because resolve() writes into the state object opened by the global pipe rather than nesting a second scope.

Three cases, deliberately not collapsed into two

Situationuser()userOrNull()
No scope: queue job, CLI command, forgot the providerMissingAuthContextErrorMissingAuthContextError
In scope, nobody authenticatedUnauthenticatedErrornull
In scope, authenticatedthe userthe user

userOrNull() throwing on a missing scope is the important row, and the easiest thing to "helpfully" soften into returning null. Don't: a route that forgot authenticate() must fail loudly rather than silently behaving as an anonymous request, because "silently anonymous" is exactly how authorization checks get bypassed. Only "we're in a request and nobody is logged in" is a legitimate null.

Every request gets a scope

AuthServiceProvider.middleware() contributes one global pipe that opens an empty scope for every request:

middleware(): HttpPipe[] {
  return [(request, next) => runWithAuth({ user: null, guard: null }, () => next(request))];
}

So on a public route with no authenticate() middleware at all, Auth.userOrNull() returns null rather than throwing, and MissingAuthContextError stays reserved for genuinely non-HTTP callers.

Outside a request

Queue jobs, CLI commands, and scheduled tasks have no scope. Any Auth.user() / Auth.userOrNull() / Auth.check() call from one throws MissingAuthContextError with a message pointing at the fix:

await Auth.runAs(user, async () => {
  // Auth.user() works here; Gate checks see this user too.
  await sendDigest();
});

runAs() sets guard: null. There was no guard involved, so Auth.currentGuard() reports null even though Auth.check() is true.

Request.user<TUser>() is a thin delegate to Auth.userOrNull() that returns undefined when @mahiframework/auth isn't bound at all. It exists so framework code (a rate-limiter key callback, for instance) can ask without a hard dependency on the auth package, prefer Auth.user() / Auth.id() in your own controllers. See Requests.

Low-level exports

For code that needs the primitives rather than the facade, @mahiframework/auth exports them directly: runWithAuth, currentAuthState, requireAuthState, user, userOrNull, check, currentGuard, MissingAuthContextError, UnauthenticatedError.

currentAuthState() returns AuthState | undefined (no throw); requireAuthState() throws MissingAuthContextError. Both are for guards and middleware that need to write to the state.

Middleware

authenticate(guardName?)

Resolves the user with the named guard (or the default) and throws HttpError.unauthorized(), a 401, if there is none.

posts.post("/", CreatePostController).middleware(authenticate());
admin.get("/", DashboardController).middleware(authenticate("session"));

Per-route, not a global pipe: auth is opt-in per route.

authenticateOptional(guardName?)

Resolves the user if credentials are present, never rejects. For routes that serve guests and authenticated users differently:

router.group("/posts", (posts) => {
  posts.middleware(authenticateOptional());

  posts.get("/", ListPostsController).name("posts.index");
  posts.get("/{post}", GetPostController).name("posts.show");
  posts.post("/", CreatePostController)
    .middleware(authenticate(), throttle("create-post"))
    .name("posts.store");
});

Pair it with Auth.userOrNull(). Auth.user() still throws for guests, by design.

ensureEmailVerified(column?)

Requires the authenticated user's email_verified_at to be set, else 403. Place it after authenticate(): it reads the user that authenticate() resolved into the ambient scope, and does not resolve anyone itself:

protectedRoutes.get("/", handler).middleware(authenticate(), ensureEmailVerified());

A guest (no user in scope) gets a 401, not a 403, authenticating differently could fix a 401; different credentials won't fix a 403.

There is no redirect branch (unlike Laravel's dual API/web EnsureEmailIsVerified): this is an API-only framework, so an unverified user is a flat 403.

csrf(options?)

Signed double-submit-cookie CSRF protection.

import { csrf } from "@mahiframework/auth";

router.group("/app", (routes) => {
  routes.middleware(csrf(), authenticate("session"));
  // ...
});
OptionDefaultMeaning
cookie"XSRF-TOKEN"Cookie holding the token
header"X-XSRF-TOKEN"Header the client echoes it back in
field"_token"Form field checked when the header is absent; null to disable
signtrue when a Signer is boundHMAC the cookie so forged tokens are rejected
prefix:"host" for a __Host- cookie a sibling subdomain can't write
securetrueAdds Secure to the cookie
sameSite"Lax"Cookie SameSite
path"/"Cookie Path
safeMethods["GET","HEAD","OPTIONS"]Methods that skip the check

GET, HEAD, and OPTIONS are treated as safe and pass through unchecked (they still get the cookie issued). Every other method must present the token in the header or the form field, compared with timingSafeEqual. A mismatch is HttpError.forbidden("CSRF token mismatch.").

The form-field fallback is what makes this usable from a plain HTML form: a client with no JavaScript cannot set a request header at all, so a header-only check silently restricts the app to fetch/XHR callers.

The cookie value is <token>.<hmac>, produced with the app's Signer. A cookie whose signature doesn't verify is discarded and re-issued rather than trusted.

Plain double-submit accepts any value that appears in both the cookie and the header. So an attacker who can write a cookie, an XSS on a sibling subdomain, or a MITM on plain HTTP, which can set cookies for the HTTPS origin, simply picks both halves and forges at will. Signing means only tokens this server minted count.

This is not Laravel/Sanctum's synchronizer token. Laravel binds the token to the session, so a token is useless in anyone else's. This is per-cookie, which is strictly weaker against an attacker who can write cookies to the victim's browser. To close that gap, serve over HTTPS and set prefix: "host", a __Host- cookie cannot be set or overwritten by a sibling subdomain, and treat SameSite=Lax (the default) as the primary defense.

The cookie is deliberately not httpOnly. The whole mechanism depends on the client's JavaScript being able to read the cookie and copy it into a request header. An attacker's page on another origin can cause the browser to send the cookie but cannot read it (same-origin policy), so it cannot construct the matching header. Making the cookie httpOnly would break the scheme entirely, not harden it.

The cookie is queued on the Request (request.queueCookie(...)) and written by the HTTP boundary, not set through Hono. See The cookie is queued, not set through Hono.

The token guard needs no CSRF middleware. CSRF exists because browsers attach cookies to cross-origin requests automatically. They never attach an Authorization header automatically, so there is nothing for an attacker's page to ride on. Adding csrf() to a token-guarded API buys nothing and breaks non-browser clients.

Verifying credentials

Auth.attempt() verifies credentials and returns the user or null. It does not log anyone in, the caller decides what to issue (a token, a session):

const user = await Auth.attempt<UserTable>({ email: body.email, password: body.password });

if (user === null) {
  throw HttpError.unauthorized("Invalid credentials.");
}

The dummy hash

When no user matches, attempt() still hashes the submitted password and discards the result:

if (user === null) {
  await this.hasher.make(credentials["password"] ?? "");
  return null;
}

argon2 takes on the order of 50–100ms. Without this line, "no such account" returns almost instantly while "wrong password" takes ~100ms, and an attacker can enumerate which email addresses have accounts by timing the response alone. Doing the work anyway makes both paths cost roughly the same.

The response body has to match too. The base app's login controller returns one message for both cases:

// One message for both "no such account" and "wrong password" — the
// pairing to Auth.attempt()'s constant-time behaviour. Distinguishing
// them here would leak account existence through the response body,
// undoing the timing work entirely.
throw HttpError.unauthorized("Invalid credentials.");

And so does validation. The base app's LoginRequest deliberately has no .min(8) on the password, unlike registration, rejecting a short password at validation time tells an attacker their guess was too short to be this account's password. Login validates shape only; correctness is decided uniformly by attempt().

Rehash on login

Login is the one moment the framework legitimately holds the plaintext, so it's the only place a stored hash can be transparently upgraded:

if (Hash.needsRehash(user.password)) {
  await User.update(user.id, { password: await Hash.make(body.password) });
}

See Encryption & hashing.

TokenGuard

Opaque, database-backed bearer tokens, modeled on Sanctum's API-token half. Tokens are revocable server-side, which is the decisive advantage over JWT for a single-database application: a JWT can't be revoked without a revocation list, which reintroduces the very database lookup JWTs exist to avoid.

export interface TokenGuardConfig {
  provider?: string;
  expiresInMinutes?: number | null;   // null = never expires (default)
}

Token format

<uuid-id>|<base64url-secret>
9c7b531f-3ac1-4d51-9d6a-6b0c0a2b5f77|lu8aN1IBZLiVzEi27XDpn_Pks9JYebFTWEWGOWrlMiQ

The id is randomUUID(); the secret is randomBytes(32) encoded base64url. The plaintext is returned exactly once from createToken() and is never recoverable afterwards, only the digest is stored.

The id prefix exists for lookup. The stored column is a digest, so it can't be looked up by equality. Without an id, verifying a token would mean loading every token row and comparing each, O(n) work per request, trivially DoS-able. The id turns it into one indexed primary-key lookup plus exactly one digest comparison.

splitToken(plaintext) returns [id, secret] or null. It splits on the first |, and rejects an empty id or empty secret.

Why SHA-256, not argon2

hashToken() is createHash("sha256").update(secret).digest("hex"). This is deliberate and is not a performance shortcut taken at the cost of security.

argon2 is intentionally slow to make brute-forcing human-chosen passwords infeasible, passwords occupy a tiny, heavily biased corner of the keyspace, so the only defense is making each guess expensive. A personal access token is 32 bytes of randomBytes: there is no low-entropy space to brute-force. The slowness buys nothing while costing an argon2 verification on every authenticated API request. Sanctum makes the same call for the same reason.

It's fixed rather than configurable: the only alternative setting is strictly slower for zero security gain, and changing the algorithm would invalidate every already-issued token, so a config knob would be a trap rather than a feature.

verifyTokenHash(secret, storedDigest) compares with timingSafeEqual, not ===. Same class of bug Signer.verify() guards against, and just as easy to "simplify" back into a vulnerability during review.

The user() ordering

const record = await PersonalAccessToken.find(id);
if (record === undefined) return null;

// Verify the secret BEFORE checking expiry so a valid-but-expired
// token and a bogus one take the same path; and reject before
// touching last_used_at so a failed guess never writes.
if (!verifyTokenHash(secret, record.token)) return null;
if (this.isExpired(record)) return null;

await PersonalAccessToken.update(id, { last_used_at: DateTime.now("UTC").toISOString() });

return this.users.retrieveById(record.user_id);

Three security-critical orderings in five lines:

  1. Digest before expiry. If expiry were checked first, an attacker holding a known-expired token id could distinguish "this id exists but expired" from "this id doesn't exist" by response timing or by which branch runs. Verifying the digest first means a bogus secret and a valid-but-expired token take the same path out.
  2. last_used_at only after a successful verify. A failed guess must never write to the database, otherwise brute-force attempts show up as touched rows, and every wrong guess costs a write.
  3. User lookup last. No user is loaded for a request that failed verification.

API

MethodSignatureNotes
user(request)Promise<TUser | null>The Guard contract. Reads Authorization: Bearer.
createToken(userId, name)Promise<NewAccessToken>Returns { token, record }. token is the plaintext, shown once.
revokeToken(id)Promise<void>Delete one token by id.
revokeAllTokens(userId)Promise<void>"Log out everywhere."
currentTokenId(request)string | nullThe id half of this request's token, without verifying it.

The credential itself comes from Request.bearerToken(), which parses Authorization: Bearer <token>. A malformed header yields undefined rather than throwing. An unparseable header is an unauthenticated request, not a server error.

Issuing and revoking

// POST /auth/login
const guard = Auth.guard("token") as TokenGuard<UserTable>;
const { token } = await guard.createToken(user.id, "login");

return HttpResponse.json({ user: new UserResource(user).toJson(), token });
// POST /auth/logout — revokes only the token that made this request, so
// logging out on a phone doesn't sign you out on a laptop.
const guard = Auth.guard("token") as TokenGuard<UserTable>;

const tokenId = guard.currentTokenId(request);
if (tokenId !== null) {
  await guard.revokeToken(tokenId);
}

currentTokenId() deliberately doesn't verify the secret, the request already passed authenticate(), so the token is known good by the time a controller reads its id.

SessionGuard

The cookie carries a signed session id and nothing else; the session itself lives server-side in a SessionStore.

Signing (via Signer, which already supports key rotation) means a forged or edited cookie is rejected before it ever reaches the store, so an attacker can't enumerate session ids by tampering. And because only the id travels, deleting the stored row revokes the session immediately.

export interface SessionGuardConfig {
  provider?: string;
  store?: string;               // "database" (default) | "cache" | "array"
  cookie?: string;              // default "session"
  lifetimeMinutes?: number;     // default 120
  rememberMinutes?: number;     // default 400 * 24 * 60 (~400 days)
  sameSite?: "Strict" | "Lax" | "None";
  secure?: boolean;
  domain?: string;
  path?: string;
  prefix?: "secure" | "host";
  slidingCookie?: boolean;   // default true
  name?: string;             // set by the provider; reported by Auth.currentGuard()
}
AttributeValue
Nameconfig.cookie ?? "session", plus __Host-/__Secure- if prefix is set
Valuesigner.sign(sessionId): <uuid>.<hmac>
HttpOnlyhardcoded true, not configurable, limits XSS session theft
Secureconfig.secure ?? true: defaults to on
SameSiteconfig.sameSite ?? "Lax"
Pathconfig.path ?? "/"
Max-Ageminutes * 60, where minutes is the lifetime or remember window
Domainonly set if config.domain is provided

Setting prefix: "host" yields a __Host-session cookie, which the browser refuses to let a sibling subdomain set or overwrite, the strongest available defense against session fixation from a compromised other.example.com. It requires secure: true, path: "/" and no domain, so it is opt-in: those constraints break plain-HTTP local development.

login(), logout() and the sliding re-issue all call request.queueCookie(...); the HTTP boundary writes the queued cookies onto whatever response the handler returns.

This is a correctness requirement. Mahi handlers return platform Response objects, and Hono only merges its context-queued headers (c.header(), and therefore hono/cookie's setCookie()) into a response it built via c.json()/c.body()/c.newResponse(). Setting the cookie through Hono therefore wrote a session row the browser never learned the id of: login appeared to succeed, and every subsequent request was anonymous.

The same mechanism is available to application code, see Request cookies, and is what csrf() uses too.

secure defaulting to true means the cookie will not be sent over plain HTTP unless you explicitly opt out. The base app sets secure: env.NODE_ENV === "production" so local development over HTTP works; in production it's on.

sameSite: "Lax" is right for same-origin deployments. A cross-origin SPA needs "None", which browsers only honour alongside secure: true, meaning cookie sessions do not work over plain HTTP across origins in local development. Use the token guard for cross-origin clients.

Sliding expiry

On every successful user() call, the session's expiry is renewed to the later of the normal sliding window and the session's own current expiry:

const slid = this.expiresAt(this.lifetimeMinutes);
const renewed = new Date(session.expiresAt).getTime() > new Date(slid).getTime()
  ? session.expiresAt
  : slid;
await this.sessions.touch(sessionId, renewed);

Taking the later of the two is what stops a "remember me" session, whose expiry is already far in the future, from being shrunk back to the short lifetime on the next request. An ordinary session still slides forward normally: active sessions keep renewing, abandoned ones lapse.

The cookie slides with it. When the renewal actually moves the expiry forward, the guard re-issues the cookie with a fresh Max-Age. Without that, only the server side slid: the browser still deleted its cookie lifetimeMinutes after login, so an actively-used session died mid-use, precisely what sliding expiry exists to prevent.

A remembered session's cookie is not re-sent on every request (its expiry is already far in the future, so there is nothing to refresh and it would be pure header weight). Set slidingCookie: false for an absolute lifetime that no amount of activity extends.

login() and session fixation

const sessionId = await guard.login(request, user.id);
const sessionId = await guard.login(request, user.id, { remember: true });

login() always mints a fresh session id, and destroys any pre-existing session first. That is the defense against session fixation, an attacker who plants a known session id in a victim's browser before login must not still know it afterwards. It's the one session-specific attack a naive implementation reliably gets wrong; the behaviour is covered by a dedicated test. Don't "optimise" it into reusing an existing id.

Remember me

{ remember: true } is deliberately not Laravel's recaller-cookie mechanism. Laravel keeps a second, long-lived credential (an id|token|hmac cookie plus a remember_token column) specifically to avoid holding a session row alive for months. A concern that doesn't apply here, because these sessions are already fully server-side and revocable by deleting the row.

So "remember me" here simply means one long-lived session: expiresAt and the cookie's Max-Age use rememberMinutes instead of lifetimeMinutes. One optional param, one branch. No separate recaller cookie, no extra table, no password-HMAC binding.

rememberMinutes defaults to ~400 days, matching browsers' modern cap on cookie Max-Age.

API

MethodSignatureNotes
user(request)Promise<TUser | null>Reads and verifies the cookie, slides expiry (and re-issues the cookie).
login(request, userId, { remember? })Promise<string>Fresh id, destroys any prior session, queues the cookie, populates the ambient auth scope. Returns the id.
logout(request)Promise<void>Destroys this session, queues the cookie's deletion, clears the ambient scope.
logoutEverywhere(userId)Promise<void>Destroys every session for the user, including this one. Database/array store only.
logoutOtherDevices(request, password)Promise<boolean>Revokes every other session, keeps this one. Re-validates the password first.
gc()Promise<number>Deletes expired sessions. Driven by auth:gc.

login() writes the user into the ambient auth scope, so Auth.user() works for the rest of the same request rather than only from the next one. Before that, a controller that logged a user in and then tried to render them hit UnauthenticatedError in the handler that had just authenticated someone.

logoutOtherDevices() re-validates the password before mass-revoking, the standard guard on a security-settings page: confirm it's really the account owner. It returns false without touching anything if the password doesn't check out, if there's no current session, or if the user row is gone. It needs a store that can be queried by user.

Session stores

export interface SessionStore {
  read(id: string): Promise<SessionRecord | null>;
  write(id: string, userId: string, expiresAt: string): Promise<void>;
  touch(id: string, expiresAt: string): Promise<void>;
  destroy(id: string): Promise<void>;
  destroyForUser(userId: string): Promise<void>;
  destroyForUserExcept(userId: string, exceptId: string): Promise<void>;
  gc(): Promise<number>;
}

SessionRecord is { id, userId, expiresAt }. That's all a session holds. There is no arbitrary session-data bag.

Storestore: valueSurvives restartMulti-processdestroyForUser
DatabaseSessionStore"database" (default)YesYesYes
CacheSessionStore"cache"Depends on cache driverDepends on cache driverThrows
ArraySessionStore"array"NoNoYes

All three enforce expiry on read

Every store checks expiresAt in read() and returns null for a stale record, rather than trusting that gc() has run:

// Expiry is enforced on read rather than relying on gc() having run —
// gc() is a cleanup job, not a correctness guarantee. Treating a
// stale row as valid because the cron hasn't fired would be a real
// vulnerability.
if (new Date(row.expires_at).getTime() <= Date.now()) return null;

CacheSessionStore does it too, even though the cache TTL should already have evicted the entry. It keeps all three stores behaviourally identical.

DatabaseSessionStore

The default. Sessions in a sessions table: survives process restarts, works across multiple processes, and is queryable, so "log this user out everywhere" is one statement. It has no automatic expiry mechanism, so gc() must be run periodically. See auth:gc.

CacheSessionStore

Sessions in the cache, with TTL-based expiry handled by the store. Faster than the database store, with two caveats:

  • With the array cache driver, sessions vanish on restart.
  • destroyForUser() and destroyForUserExcept() throw:
CacheSessionStore cannot revoke sessions by user — a cache can't be
queried by value. Use the 'database' session store if you need to log a
user out everywhere.

This is not an implementation gap. A cache is a key–value map: you can ask "what is at key session:abc", but there is no way to ask "which keys hold a value whose userId is x" without scanning the entire keyspace, which most cache drivers don't expose at all, and which is a production hazard on the ones that do (Redis KEYS). The alternative would be maintaining a parallel user:<id> → [session ids] index, which is a second source of truth that can drift out of sync with the sessions themselves. Throwing loudly is better than silently revoking nothing on a "sign out everywhere" button.

The store is resolved through CACHE_TOKEN at runtime rather than by importing @mahiframework/cache, so @mahiframework/auth doesn't take a package dependency for one optional store. It only needs get/put/forget.

ArraySessionStore

In-memory sessions in a plain Map. Zero setup, no I/O, for tests, where a SessionGuard can be exercised end to end without a database round-trip or a cache backend. Not for production.

Unlike CacheSessionStore, this one can back destroyForUser(): it holds the records directly, so it can scan them by value.

DatabaseUserProvider

Retrieves users from any model class, verifying passwords with the app's Hasher (argon2).

export interface DatabaseUserProviderConfig {
  model: AnyModelClass;
  identifierColumn?: string;   // default "email"
  passwordColumn?: string;     // default "password"
}

AnyModelClass is the value-side "any model class" type, typeof Model now names the Model() factory function rather than a class, so a config that accepts a model class spells it this way.

Global scopes apply

Lookups go through Model.query(), not queryWithoutScopes():

async retrieveByCredentials(credentials: Credentials): Promise<TUser | null> {
  const value = credentials[this.identifierColumn];
  if (value === undefined || value === "") return null;

  const row = await this.config.model.query().where(this.identifierColumn, value).first();
  return (row as TUser | undefined) ?? null;
}

That single decision means a soft-deleting user model stops authenticating deleted users with no extra code on either side. The soft-delete global scope adds WHERE deleted_at IS NULL to every query(), so a soft-deleted user simply isn't found, by the login lookup, and by retrieveById() on every subsequent authenticated request, so existing tokens and sessions stop working too.

The base app's User model is configured with softDeletes: true and keyType: snowflake(), so this is the default behaviour out of the box. Any global scope you add to the user model participates: a tenant scope, an active scope, a banned_at IS NULL scope. See Models and Queries.

updatePassword() writes back keyed by the model's primaryKey. It's what PasswordBroker.reset() calls.

The users table is app-owned

@mahiframework/auth ships no users migration and no User model. Every real app wants its own columns there (tenant, avatar, role), and a framework-owned users table would mean either a publish-and-edit step or apps fighting the framework's schema forever. The package only ships the tables internal to its own guards.

Passwords

PasswordBroker handles the reset flow. One broker over one UserProvider, deliberately narrower than Laravel's multi-broker PasswordBrokerManager, since this framework has no multi-user-table goal. Resolve it with Auth.passwordBroker(); it's cached after first resolution.

export interface PasswordBrokerConfig {
  expiresInMinutes?: number;   // default 60
}

Configured under auth.passwords, with an optional provider key to point it at a user provider other than the default guard's.

sendResetLink(email)

type SendResetLinkResult =
  | { status: "sent"; email: string; token?: string }
  | { status: "throttled" };
StatusWhentoken present
"sent"A user matched. A token was minted and a row writtenYes
"sent"No user matched: nothing minted, nothing writtenNo
"throttled"Only ever returned when a real user was foundNo

The no-enumeration behavior is the point. When no user matches, the broker returns { status: "sent", email } with no token and writes no row. That's the same shape as success, so a caller that relays the status directly to the client can't be used to enumerate which email addresses have accounts.

Note what this means for the caller: you must branch on token, not on status, to decide whether to actually send mail:

const result = await Auth.passwordBroker().sendResetLink(body.email);

if (result.status === "sent" && result.token !== undefined) {
  const url = URL.signedRoute("password.reset", { token: result.token, email: result.email });
  await Mail.send(new ResetPasswordMail(url).to(result.email));
}

// Same response either way.
return HttpResponse.json({ status: "sent" });

On success the raw token is handed back to the caller, which decides how to deliver it (email, SMS). The framework owns the mechanism, the app owns the UX.

One live reset per email: sendResetLink() upserts, rather than accumulating rows. email is the primary key. (It used to delete-then-insert, which two concurrent requests, a double-clicked form, could interleave into a primary-key violation and a 500.)

"throttled"

throttled is returned when a token was minted for this address less than throttleSeconds ago (default 60, Laravel's value; set 0 to disable). It is only ever reachable for a real account, an unknown address returns "sent" above without ever consulting the table, so it leaks nothing an attacker couldn't already determine.

This is deliberately in addition to the throttle() HTTP middleware on the route, because the two answer different questions: middleware limits how often one client may ask, this limits how often one mailbox may be written to. An attacker rotating IPs to flood a victim's inbox defeats the first and not the second.

reset(email, token, newPassword)

type ResetResult =
  | { status: "reset" }
  | { status: "invalid-token" }
  | { status: "expired-token" }
  | { status: "invalid-user" };
StatusMeaningSide effect
"reset"Password updatedToken row deleted (single use); all sessions and access tokens revoked; listeners fired
"invalid-token"No row for this email, or the token doesn't match the stored hashRow left in place
"expired-token"Row found but older than expiresInMinutesStale row deleted
"invalid-user"Token verified but the user no longer existsRow left in place

The order matters: expiry is checked before the hash comparison, so an expired row is swept even if the presented token is wrong.

The no-row path still performs a hash before returning invalid-token. Otherwise "no pending reset" would return instantly while "wrong token" paid for an argon2 verify (~50–100 ms at 64 MiB), a timing oracle for which accounts have a reset pending, and an unthrottled way to make the server burn CPU. attempt() does the same on its miss path.

A successful reset revokes everything else

Password reset is the account-recovery path: the thing a user does because they believe they were compromised. So on success the broker destroys every session and revokes every personal access token for that user.

Without it, the attacker's existing session simply survived the recovery that was meant to end it, and these sessions are server-side and long-lived, with a "remember me" session running to ~400 days.

AuthManager wires the revokers automatically from the configured guards. Revocation is best-effort per store: CacheSessionStore cannot revoke by user at all (it throws by design), and that must not turn a successful reset into a 500. The password has already changed by then.

To react to a reset (notify the user, write an audit record):

Auth.passwordBroker().onPasswordReset(({ user, email }) => {
  // ...
});

Reset tokens are argon2-hashed

Unlike personal access tokens (SHA-256), reset tokens go through the shared Hasher:

const token = randomBytes(32).toString("base64url");
const hashed = await this.hasher.make(token);

A reset token is a short-lived credential a human may paste around, and hashing it means a leaked password_reset_tokens dump yields nothing usable. Verification is a single PK lookup by email plus one Hasher.check(), once per reset, not once per request, so argon2's cost is irrelevant here in a way it isn't for API tokens.

Rate limiting: use both layers

The broker throttles per email (see "throttled" above). Add the throttle() HTTP middleware per client on the route as well. They cover different attacks:

auth.post("/password/forgot", ForgotPasswordController)
  .middleware(throttle("password-reset"));

See Cache and Routing.

Constant-time response is the caller's job too, the "no such account" path returns much faster than "account exists, hash a token, write a row, send mail". Wrap the call in timebox():

import { timebox } from "@mahiframework/encryption";

const result = await timebox(() => broker.sendResetLink(body.email), 250);

gc()

Deletes reset tokens older than expiresInMinutes and returns how many. Driven by auth:gc.

Email verification

Plain composable functions, not a trait or mixin, Model rows are plain objects, so there's no class to mix into. There is deliberately no MustVerifyEmail interface to implement and no base class: opting a model in is just adding the nullable email_verified_at column in its migration.

FunctionSignatureNotes
hasVerifiedEmail(user, column?)booleancolumn defaults to "email_verified_at". undefined counts as unverified.
markEmailAsVerified(model, userId, column?)Promise<string>Stamps now via model.update(), returns the timestamp written.

markEmailAsVerified() is idempotent at the storage layer, calling it twice rewrites the timestamp. Callers that must not "re-verify" should guard with hasVerifiedEmail() first.

EmailVerificationBroker

The two helpers above are the state mechanics. EmailVerificationBroker is the flow around them, the counterpart to PasswordBroker, resolved the same way:

const broker = Auth.verificationBroker();
MethodReturnsNotes
sendVerificationLink(userId, signerOptions?){ status: "sent"; url } | already-verified | invalid-userMints the signed link for the caller to deliver.
verify(userId, hash)verified | already-verified | invalid-user | invalid-hashChecks the hash, then stamps the column.
verificationUrl(userId, email, signerOptions?)stringThe URL on its own, if you have the address already.
hasVerified(user)booleanhasVerifiedEmail() against the configured column.

Configure it in config/auth.ts:

verification: {
  model: User,                   // required — see below
  expiresInMinutes: 60,
  path: "/auth/verify-email",    // must match the registered route
},

model is required and passwords has no equivalent, because the two brokers write differently: PasswordBroker delegates to UserProvider.updatePassword(), while this one stamps an arbitrary column, which UserProvider has no method for. Widening that interface for a single caller wasn't worth it, so the model is configured instead.

No token table

Unlike password reset, this stores nothing. The link is an HMAC-signed URL, so there is no table, no migration and no GC sweep.

That trade is right here and wrong for password reset, because the two differ decisively. A reset token is a credential that grants the ability to change a password, so it must be revocable, single-use, and hashed at rest. A verification link only ever asserts "whoever received mail at this address asked for this", grants no capability beyond flipping one boolean, and is naturally idempotent, clicking twice is a no-op.

The cost, stated plainly: a verification link cannot be revoked before it expires, and re-sending mints a second link without invalidating the first. If your app needs revocation, model it on the reset flow with a token table instead.

The email hash

The signed payload carries a hash of the address being verified, and verify() recomputes it against the user's current address.

Without it the flow has a real hole: request a link for a@example.com, change the account's address to victim@example.com before clicking, then click, and the account is now "verified" at an address that never received anything. The signature does not catch this, because the URL was legitimately signed. Only the hash does.

It's a fast SHA-256, not argon2, and deliberately: this is not a secret. It's a tamper-evident binding between link and address, the link is already HMAC-signed, and an attacker can compute the hash of any address they know regardless. Making it slow would only make every click slow.

The full flow

// Route — the signature IS the credential, so no authenticate() here.
auth.get("/verify-email", VerifyEmailController)
  .middleware(validateSignature())
  .name("auth.verification.verify");
// Handler — validateSignature() has already rejected tampered and expired
// links, so only the two things a signature cannot prove are left.
const result = await Auth.verificationBroker().verify(
  request.query("id")!,
  request.query("hash")!,
);
// Sending — the broker returns the URL; delivery is yours.
const result = await Auth.verificationBroker().sendVerificationLink(user.id);

if (result.status === "sent") {
  await Mail.send(new VerifyEmailMail(user.email, result.url, 60));
}

Then gate the routes that require it:

posts.post("/", CreatePostController)
  .middleware(authenticate(), ensureEmailVerified());

No throttle here, unlike password reset

PasswordBroker has throttleSeconds; this has nothing equivalent, and that asymmetry is intentional. "Forgot password" is unauthenticated, so anyone can point it at a stranger's mailbox. The per-mailbox throttle is the only thing that stops an inbox flood, because an attacker rotating IPs defeats the middleware. A resend endpoint is authenticated and can only ever mail the caller's own address, so ordinary throttle() middleware on the route is the correct and sufficient control.

What the framework sends, and what your app sends

@mahiframework/auth sends no email and does not depend on @mahiframework/mail. Both brokers hand back a token or URL and stop there:

const { token } = await Auth.passwordBroker().sendResetLink(email);
const { url }   = await Auth.verificationBroker().sendVerificationLink(id);

The mailables and the controllers that send them are scaffolded into your app (src/mail/, src/http/controllers/), where you can edit the copy, swap the theme, or delete them. This is the same split as register/login/logout: the framework owns the mechanism, the app owns the UX.

To keep the scaffolded flow but take delivery over yourself, an event listener, SMS, an ESP's API, turn the send off:

AUTH_SEND_RESET_EMAIL=false
AUTH_SEND_VERIFY_EMAIL=false
// config/auth.ts — the generated controllers read these; the framework does not.
notifications: {
  resetPassword: env.AUTH_SEND_RESET_EMAIL,
  verifyEmail: env.AUTH_SEND_VERIFY_EMAIL,
},

The broker still mints the token or link and the endpoint still responds normally; only the send stops.

Auth email sends synchronously: don't queue it

The scaffolded controllers call Mail.send(), not a queued job, and that is a security decision rather than a simplification.

sendResetLink() returns the raw token once, only its argon2 hash is stored, so the plaintext is unrecoverable afterwards. Queueing the send therefore writes a live credential into the jobs table, and into failed_jobs indefinitely if the send fails. Keeping the send inline keeps the token in memory only.

The two failure paths are handled differently, and the difference is what a retry costs:

  • Forgot password deletes the token row and rethrows. The row is written before the email goes out, so a failed send would otherwise leave a token the user never received and start the per-mailbox throttle, locking them out for a minute over our failure.
  • Registration logs and swallows. The account already exists by then, so a 500 would tell the user to retry, and the retry would fail unique(email) validation and strand them. The resend endpoint is the recovery path.

Tables

@mahiframework/auth contributes three migrations via its migrations() hook. None of them has a foreign key to users. That table is app-owned and the framework can't assume its name.

personal_access_tokens

table.string("id").primary();
table.string("user_id").index();
table.string("name");
table.string("token");
table.timestamp("last_used_at").nullable();
table.timestamp("expires_at").nullable();
table.timestamp("created_at");

token stores a SHA-256 digest, never the plaintext secret. The primary key is the id clients send as the "<id>|<secret>" prefix, so authenticating is one indexed PK lookup. user_id is indexed for revokeAllTokens().

sessions

table.string("id").primary();
table.string("user_id").index();
table.timestamp("expires_at").index();
table.timestamp("created_at");
table.timestamp("last_active_at");

expires_at is indexed for gc(), user_id for "log this user out everywhere".

password_reset_tokens

table.string("email").primary();
table.string("token");
table.timestamp("created_at");

email is the primary key, not a surrogate id: a user has at most one outstanding reset, so re-requesting overwrites rather than accumulating, and verification is a single indexed PK read. token is an argon2 hash.

The models

PersonalAccessToken, Session, and PasswordResetToken are exported and are ordinary models you can query. All three leave keyType at its default and supply the key themselves (the PKs are client-generated strings), and set timestamps: false (no updated_at column, last_used_at / last_active_at already mean "when did this last change", more precisely than an auto updated_at would).

They're framework-owned rather than app-owned because they're internal implementation details of the built-in guards, the same ownership rationale as @mahiframework/queue owning jobs.

auth:gc

./artisan auth:gc

Deletes expired sessions, expired personal access tokens, and expired password-reset tokens, logging a count for each.

Every one of those stores enforces expiry on read, so a stale row is never honoured, but nothing deletes them either, so the tables grow unboundedly without this. It's a cleanup job, not a correctness guarantee.

Guards are swept by capability, not by name: any configured guard exposing gc() is collected. That matters because guards are app-named, an app following Laravel's web/api convention has no guard called "session" at all, and the previous hardcoded lookup silently swept nothing while the tables grew.

Tokens with a null expires_at never expire (the Sanctum default) and are left alone.

Schedule it daily:

schedule(schedule: Schedule): void {
  schedule
    .call(async (app) => {
      await new AuthGcCommand(app).handle();
    })
    .daily()
    .name("auth-gc")
    .withoutOverlapping();
}

See Scheduling.

Testing

Auth.runAs() is the supported way to establish an identity without a request:

const user = await User.factory().createOne();

await Auth.runAs(user, async () => {
  expect(Auth.check()).toBe(true);
  expect(await Gate.allows("delete", Post, post)).toBe(true);
});

For HTTP-level tests, issue a real token and send it. The guard path is then exercised end to end:

const guard = Auth.guard("token") as TokenGuard<UserTable>;
const { token } = await guard.createToken(user.id, "test");

const response = await client.getJson("/auth/me", {
  headers: { Authorization: `Bearer ${token}` },
});

For session-guard tests, point auth.guards.session.store at "array" so no database round-trip is needed. See Testing.