Authentication Guide
Add sign-in, registration and password reset to an app with one command, then protect routes and read the signed-in user in controllers. The first half of this page covers that generated setup; the second half covers sessions, guards and user providers, for when you change how it works.
Authentication Guide
Add sign-in, registration and password reset to an app with one command, then protect routes and read the signed-in user in controllers. The first half of this page covers that generated setup; the second half covers sessions, guards and user providers, for when you change how it works.
Quickstart
Run the scaffolder with --install (bunx guren add auth does the same):
bunx guren make:auth --install
The command writes:
- login, registration, password reset and profile controllers, their Inertia pages and a layout, and
routes/auth.ts; AuthProvider, theUsermodel, a demo seeder, andconfig/mail.tsfor the reset emails;- the
userscolumns and asessionstable indb/schema.ts,config/session.ts, and one migration for both.
--install then wires it into the app:
- Registers
AuthProviderincreateApp()and adds the mail definition to itsconfigarray. - Adds
auth: {}tocreateApp(), which mounts the session and CSRF middleware. - Calls
registerAuthRoutes(router)fromroutes/web.ts.
Then run:
bun run db:migrate
bun run db:seed
bun run dev
Open /login on the URL bun run dev prints (http://localhost:3333 by default) and sign in with demo@example.com / secret, or open /register to create a new account.
Flags change what is generated:
| Flag | What it changes | Notes |
|---|---|---|
--minimal |
Login only: no registration or password reset | |
--verify |
Adds email verification (see Email verification) | Not with --minimal |
--oauth github,google |
Adds provider sign-in buttons (see OAuth login buttons) | Works with --minimal |
--oauth-only |
Provider sign-in only, no passwords (see OAuth as the only sign-in method) | Needs --oauth; implies --minimal |
--no-session |
Keeps sessions in process memory instead of a database table | Lost on restart and across instances |
Without --install
If you prefer to wire things yourself, or already have part of the setup, omit the flag:
bunx guren make:auth
The command prints the steps it skipped. The main ones:
- Register
AuthProviderin yourcreateApp()providers array. - Add
auth: {}to thecreateApp()options to enable sessions and CSRF. - Import
registerAuthRoutesfromroutes/auth.tsand call it from yourroutes/web.tsregistrar. - Add the
config/mail.tsdefinition to theconfigarray, and setAPP_URLin.env(emailed links are built from it).
--install is idempotent: it does not duplicate configuration that is already there.
An app that already sets up mail, through guren add mail or a provider of its own, keeps that setup: make:auth writes no config/mail.ts, and the reset mail goes through the existing one. --no-session skips the session table and config and leaves sessions in process memory.
Generated Flows
Throttling
POST /login, POST /register and POST /forgot-password go through the throttles in the generated app/Http/Middleware/AuthThrottle.ts. Each counts requests per client IP and submitted email address: five logins or registrations a minute, three reset requests every 15 minutes. Past the limit, an Inertia form gets the message as a validation error under errors.message, and any other client gets a 429 with a Retry-After header. The message comes from auth.throttle (logins) or auth.too_many_requests in lang/en/auth.json, which the command writes unless the app already has one.
The file is yours to edit: change a limit, or pass store: new RedisRateLimitStore(redis) from @guren/core/redis so every instance shares one count, since the default store lives in process memory. Behind a proxy or CDN every client arrives from the proxy's address, so read the header your proxy sets in clientIp(). See the rate limiting guide for the options.
Password reset
"Forgot your password?" on the login page leads through ForgotPasswordController and ResetPasswordController, built on the framework's createPasswordResetToken / completePasswordReset (see Password Reset).
- The token is kept by the generated
app/Auth/PasswordResetStore.ts, an in-memory store. Swap it for a Redis-backed store in production or any multi-instance deployment. - The link is mailed through the generated
config/mail.ts, which defaults to thelogdriver: links print to the console, so the flow works with no setup in development. SetMAIL_MAILER=smtpand theSMTP_*variables to send real email.
Email verification
Pass --verify to also scaffold an email verification flow:
bunx guren make:auth --install --verify
This adds an emailVerifiedAt column to the users table, a VerifyEmailController (shows a "check your email" notice, resends the verification link, and confirms the token), and a VerifyEmail page.
Registering then sends a verification email and redirects to /verify-email instead of /dashboard. The generated /dashboard route is guarded with requireVerifiedEmail, so unverified users are sent back to /verify-email until they confirm. Verification links use the same in-memory store and log-driver mail as password reset, so this works with no setup in development.
OAuth / Social login
There are two ways to add provider sign-in:
| Command | What you get |
|---|---|
make:auth --oauth github,google |
Buttons on the generated login and registration pages, with account creation and linking already written |
guren add oauth |
The OAuth manager, a controller with a stub callback, and routes, for a sign-in flow you write yourself |
Don't run both against the same app: they write the same files, so the second run aborts (or, with --force, overwrites the first).
OAuth login buttons
Pass --oauth with a comma-separated provider list to also scaffold "Continue with GitHub / Google / Discord" buttons on the login (and, unless --minimal is set, registration) pages:
bunx guren make:auth --install --oauth github,google
This adds:
- a
githubId/googleIdcolumn per provider on theuserstable; - a
config/oauth.tsdefinition that registers each provider with theOAuthManageronce itsOAUTH_<PROVIDER>_CLIENT_ID,_CLIENT_SECRETand_REDIRECT_URIare all set (the command declares them inconfig/env.ts; see OAuth / Social login); - an
OAuthControllerwithredirectToProviderandcallbackactions.
The callback signs in the user linked to that provider ID. Otherwise:
- If an existing account has the same email, it refuses to link them: that account signs in with the method it was created with.
- If the provider reports the address as unverified (Google's
email_verified, Discord'sverified), it refuses to create an account, since an unverified address would let an account claim one it does not own. Existing links are unaffected if a provider's status changes later. - Otherwise it creates a passwordless account and signs the user in. Nothing is hashed at signup, and the scaffolded
users.passwordHashcolumn is nullable.
--oauth without --verify scaffolds the profile email read-only: ProfileUpdateSchema omits the field and ProfileController.update() never reads one, so neither the form nor a hand-crafted request can move an account off the address its provider vouched for. With --verify the field stays editable, because a replacement address resets emailVerifiedAt and has to be confirmed through a link sent to it. Note that in every mode an address is only claimed, never reserved: registration accepts any well-formed email and users.email is unique, so an account already holding an address blocks its real owner's first OAuth sign-in. Add your own ownership checks if that matters for your app.
The OAuth state that ties the callback to its authorize redirect is kept in the database. --oauth adds an oauth_states table to db/schema.ts, covered by the same migration as users and sessions, and config/oauth.ts passes DatabaseOAuthStateStore as its stateStore. --install lists the definition in createApp({ config }). The redirect and the callback can then reach different processes, which on Workers, Lambda and Vercel is the normal case (see State Storage). An app with no db/schema.ts gets the definition without a stateStore, so state stays in process memory.
The definition is what the command writes once config/env.ts exists and no provider binds oauth. Otherwise it writes app/Providers/OAuthProvider.ts with the same registrations (plus CoreOAuthServiceProvider when there is no db/schema.ts) and wires that provider instead. Apps that configure OAuth in a service provider keep working; see Apps with service providers.
OAuth as the only sign-in method
--oauth still scaffolds password login alongside the buttons. Add --oauth-only to drop it entirely:
bunx guren make:auth --install --oauth github --oauth-only
/login becomes a provider-buttons page with no credential form and no POST /login route; LoginController keeps only show() and destroy() (logout). Registration, password reset, the login and profile password fields, LoginValidator, and the demo UsersSeeder are all skipped: a seeded password could never be used to sign in. --oauth-only requires --oauth with at least one provider (otherwise the app would have no way in at all) and subsumes --minimal; --verify is skipped under it, since provider-supplied emails arrive already vouched for.
Like plain --oauth without --verify, the profile email is read-only in this mode (see above).
make:auth only writes the files it scaffolds; it never deletes. Converting an existing password app with --oauth-only --force therefore leaves the old registration and reset files behind, and the scaffold prints the list. Delete them: the stale db/seeders/UsersSeeder.ts in particular is picked up by db:seed rather than by the route table, so a dead routes/auth.ts does not neutralize it.
This is the recommended shape for CPU-metered runtimes such as the Cloudflare Workers free tier, where a single password hash exceeds the per-request CPU budget no matter which hashing algorithm you pick.
Scaffold OAuth in an app
Guren ships OAuth primitives plus provider presets for GitHub, Google and Discord. guren add oauth sets them up without the make:auth pages:
bunx guren add oauth
This creates:
config/oauth.tsapp/Http/Controllers/Auth/OAuthController.tsroutes/oauth.ts
and lists the config/oauth.ts definition in createApp({ config }). The definition binds the oauth manager; its contents are shown in the OAuth guide.
It also adds an oauth_states table to db/schema.ts and generates its migration (run bun run db:make yourself if drizzle-kit is not installed yet), and registers the oauth-states:prune command. config/oauth.ts passes DatabaseOAuthStateStore as its stateStore, so the callback does not need to reach the process that issued the authorize redirect, which on Workers, Lambda and Vercel it usually does not (see State Storage). CoreOAuthServiceProvider is not registered, because its manager keeps state in process memory. The command refuses to run in an app with no db/schema.ts, and writes nothing.
As with --oauth, an app without config/env.ts, or with a provider already binding oauth, gets app/Providers/OAuthProvider.ts with the same registrations and store instead.
Configure provider credentials
The command declares three keys per provider in config/env.ts and adds them, blank, to .env:
OAUTH_GITHUB_CLIENT_ID=...
OAUTH_GITHUB_CLIENT_SECRET=...
OAUTH_GITHUB_REDIRECT_URI=https://your-app.test/auth/github/callback
Equivalent keys exist for GOOGLE and DISCORD. A provider is registered only when all three of its keys are set.
Route flow
router.get('/auth/:provider', [OAuthController, 'redirectToProvider'])
router.get('/auth/:provider/callback', [OAuthController, 'callback'])
redirectToProvider creates a signed state and redirects to the provider consent screen.callback validates state, exchanges code for token, then fetches the remote profile.
Post-login redirect (redirectTo)
Pass redirectTo when starting the flow and read it back, sanitized, after the callback. In the scaffolded OAuthController (which resolves the manager with this.oauth()):
// /auth/github?redirectTo=/settings
async redirectToProvider(): Promise<Response> {
const { url } = await this.oauth().authorize('github', {
redirectTo: this.request.query('redirectTo'),
session: this.auth.session(),
})
return this.redirect(url)
}
async callback(): Promise<Response> {
const { profile, redirectTo } = await this.oauth().handleCallback('github', {
code,
state,
session: this.auth.session(),
})
// ...log the user in...
return this.redirect(redirectTo ?? '/')
}
redirectTo is guarded against open redirects on both ends of the flow: only app-relative paths (/settings) survive by default. Protocol-relative URLs (//evil.com), backslash variants, non-http schemes, and unlisted hosts are dropped: redirectTo comes back as undefined and your fallback applies.
To allow specific external hosts (wildcards supported), list them in stateConfig in config/oauth.ts. Redirect After Login in the OAuth guide shows the definition.
Note:
createRedirectSafetyMiddleware(opt-in) validatesLocationheaders with its own separateallowedHostsoption. If you mount it, keep both allowlists in agreement, or the middleware rewrites an approved external redirect to/.
Controllers & Routes
Controllers read the signed-in user through this.auth:
import { pages } from '@/.guren/pages.gen'
import type { UserRecord } from '@/app/Models/User'
export default class DashboardController extends Controller {
async index() {
const user = await this.auth.user() // returns user or null
return this.inertia(pages.dashboard.Index, { user })
}
async store() {
const user = await this.auth.userOrFail<UserRecord>() // throws 401 if not authenticated
// user is guaranteed non-null here
await Post.create(data, { set: { authorId: user.id } })
return this.redirect('/posts')
}
}
Use this.validateBody() / this.validateQuery() / this.validateParams() with Zod schemas for typed validation. Reserve FormRequest for compatibility code.
Surfacing the logged-in user on every Inertia page is already wired for you. The app/Providers/AuthProvider.ts that bunx guren add auth (equivalently bunx guren make:auth --install) generates registers it in boot(), so auth.user is readable from every page's props right after scaffolding. It is what the generated layout reads to toggle between Sign in and Log out.
// app/Providers/AuthProvider.ts (generated; register()'s useModel setup elided)
import { ServiceProvider, shareInertiaProps, AUTH_CONTEXT_KEY } from '@guren/core'
import type { AuthContext } from '@guren/core'
export default class AuthProvider extends ServiceProvider {
boot(): void {
shareInertiaProps(async (ctx) => {
const auth = ctx.get(AUTH_CONTEXT_KEY) as AuthContext | undefined
return { auth: { user: await auth?.user() } }
}, this.container)
}
}
If you assembled authentication by hand, make the same call from your own service provider's boot().
Sharing auth.user() this way is safe by default: the record is sanitized before it leaves the auth layer, so the password hash never reaches the browser (see Sanitized User Records).
Augment InertiaSharedProps (see the Controllers guide) to type this auth payload for React pages.
shareInertiaPropsmerges its props over whatever was registered before, so several parts of your app (auth, i18n, flash, …) can each contribute without clobbering one another:shareInertiaProps((ctx) => ({ i18n: { locale: detectLocale(ctx) } }), this.container)Pass
this.containerto scope the props to one application; without it they are process-wide and leak into a second application booted alongside.
setInertiaSharedPropsreplaces the process-wide resolver rather than merging, dropping whatever is registered at the moment it runs. Reach for it only when you mean to replace the whole thing.
Route middleware makes protecting endpoints straightforward:
import { Router, requireAuthenticated, requireGuest } from '@guren/core'
import LoginController from '@/app/Http/Controllers/Auth/LoginController'
import DashboardController from '@/app/Http/Controllers/DashboardController'
export function registerWebRoutes(baseRouter: Router): void {
const router = baseRouter
.aliasMiddleware('auth', requireAuthenticated({ redirectTo: '/login' }))
.aliasMiddleware('guest', requireGuest({ redirectTo: '/dashboard' }))
router.middleware('guest').group((guest) => {
guest.get('/login', [LoginController, 'show'])
guest.post('/login', [LoginController, 'store'])
})
router.middleware('auth').group((auth) => {
auth.post('/logout', [LoginController, 'destroy'])
auth.get('/dashboard', [DashboardController, 'index'])
})
}
Session Guard Helpers
auth.check()– resolves totruewhen a user is authenticated.auth.user()– returns the current user record (ornull), sanitized: the password hash, remember token, and the model'shiddenfields are stripped.auth.userOrFail()– returns the current user or throwsAuthenticationException(401). Useful when you know the route is protected and want to avoid null checks.auth.login(user, remember?)– logs in the given user and optionally issues a remember token.auth.attempt(credentials, remember?)– validates credentials using the active guard and logs in on success.auth.logout()– clears the session and remember token.
Sanitized User Records
auth.user() (and the cached user available right after login() or attempt()) never exposes credential material. ModelUserProvider strips the password column, the remember-token column, and any fields the model marks as hidden before the record leaves the auth layer:
export class User extends defineModel(users, {
base: AuthenticatableModel,
optionalOnCreate: ['passwordHash'],
requireOnCreate: ['password'],
fillable: ['name', 'email', 'password'],
hidden: ['passwordHash', 'rememberToken'],
}) {}
The make:auth scaffolder generates the user model with this hidden configuration out of the box. See Hiding Fields for the option and the still-supported static hidden = [...] form.
Credential validation still runs on the raw database record internally, so login and remember-me tokens are unaffected: sanitization only changes what auth.user() exposes to application code.
Custom user providers can opt in by implementing the optional sanitize(user) method on the UserProvider interface. SessionGuard calls it before caching or returning the user:
sanitize(user: AuthUser): AuthUser {
const { passwordHash, ...safe } = user
return safe as AuthUser
}
Typing Sanitized Users
Sanitization happens at runtime, so a plain auth.user<UserRecord>() would still type the record as if the credential fields were present. Use the Sanitized<T> helper to strip the conventional credential keys from the type:
import type { Sanitized } from '@guren/core'
// Strips password/passwordHash/rememberToken-style keys from the type
const user = await this.auth.userOrFail<Sanitized<UserRecord>>()
user.email // ✅ string
user.passwordHash // ❌ compile error — stripped at runtime
If your model hides additional fields via hidden, or your credential columns use names outside the conventions (password, passwordHash, password_hash, rememberToken, remember_token), list them in the second type parameter:
type SafeUser = Sanitized<UserRecord, 'twoFactorSecret' | 'credentialDigest'>
The runtime strips exactly the columns your provider is configured with plus the model's hidden fields. A static type cannot see that configuration, so Sanitized<T> reflects the conventional names and relies on you to pass anything else. guren audit warns about sensitive columns missing from hidden, which keeps the runtime side honest.
How Authentication Fits Together
The generated setup is built from these parts, which the rest of the page configures:
- AuthManager – central registry for guards and user providers. Resolved from the service container via
this.container.make<AuthManager>('auth'). - Guards – runtime objects responsible for authenticating a request. The default
SessionGuardpersists the logged-in user's identifier inside the session and supports optional "remember me" tokens. - User Providers – data access adapters used by guards to load and validate users.
ModelUserProviderintegrates with Guren'sModelabstraction so you can back authentication with Drizzle ORM tables. - Auth Context – per-request facade that surfaces guard helpers (
auth.check(),auth.user(),auth.login(), etc.). The context is attached automatically byAuthServiceProvider; it is available in controllers via thethis.authhelper and in middleware throughattachAuthContext. - OAuthManager – social login helper that manages provider configs, CSRF-safe OAuth state, code exchange, and user profile fetch.
Enabling Sessions
Guards need access to the session. When createApp() receives an auth option, AuthServiceProvider attaches the session middleware (and CSRF) for you. Pass options there to customize it:
import { createApp } from '@guren/core'
const app = createApp({
auth: {
autoSession: true, // set false to opt out
sessionOptions: {
cookieSecure: process.env.NODE_ENV === 'production',
},
},
})
Without the auth option you can mount the session middleware yourself, but nothing then attaches CSRF protection or the provider's session handling:
import { createApp, createSessionMiddleware } from '@guren/core'
const app = createApp()
app.use('*', createSessionMiddleware())
cookieSecure controls whether the session cookie is marked Secure (only sent over HTTPS). In production you should keep it true; in local development it is set to false by default so cookies work over http://localhost.
Application auth options
autoSession(defaulttrue): automatically attachescreateSessionMiddleware.sessionOptions(forwarded tocreateSessionMiddleware):cookieName(defaultguren.session)cookieSecure(defaulttruein production,falsein dev/local)cookieSameSite(defaultLax)cookieHttpOnly(defaulttrue)cookieMaxAgeSeconds(optional; falls back tottlSeconds)ttlSeconds(default 2 hours)store(default in-memory; swap for your own implementation for multi-instance deployments). Accepts a store or a function returning one; the function is called on every request (aSessionManagermemoizes), never at boot.
Selecting a store with SessionManager
bunx guren add session writes the sessions table and its migration, config/session.ts (declaring the database and cookie stores), the SESSION_DRIVER key (declared in config/env.ts with a database default, and added to .env and .env.example), and the sessions:prune command, and lists the definition in createApp({ config }). The redis store below is the one addition you make by hand: importing @guren/core/redis pulls ioredis into every bundle, so the scaffold leaves it out until you want it. guren add auth runs it for you, so a scaffolded app is database-backed from the start. The rest of this section is what it produces, for an app wiring it by hand.
As with --oauth, an app without config/env.ts, or with a provider already binding session, gets config/session.ts as a plain SessionConfig and an app/Providers/SessionProvider.ts that binds it instead.
For more than one candidate store, declare them once and pick by environment. defineSessionConfig binds a SessionManager under the session key, and AuthServiceProvider builds the session middleware around it at boot, resolving the store itself on the first request:
// config/session.ts
import { defineSessionConfig } from '@guren/core'
import { createRedisClient } from '@guren/core/redis'
import { sessions } from '../db/schema'
export default defineSessionConfig((env) => ({
default: env.SESSION_DRIVER,
ttlSeconds: 60 * 60 * 2,
stores: {
// Survives restarts, isolates and cold starts, over the connection
// `configureOrm()` already established — Postgres, MySQL, SQLite or D1.
database: { driver: 'database', table: sessions },
cookie: { driver: 'cookie' },
// `client` may be a function: it runs when this store is first used, so a
// store that is declared but not selected opens no connection.
redis: { driver: 'redis', client: () => createRedisClient({ url: env.REDIS_URL }) },
},
}))
Declare REDIS_URL in config/env.ts, and import @guren/core/redis only in the config that uses it, since it pulls in ioredis. List the definition beside the database one:
// src/app.ts
import { createApp } from '@guren/core'
import database from '../config/database.js'
import env from '../config/env.js'
import session from '../config/session.js'
const app = createApp({
env,
config: [database, session],
})
defineSessionConfig() builds its manager with createSessionManager(), which is new SessionManager() plus the database driver. Only @guren/core can supply that driver: the store wraps your table in an ORM model, and the HTTP layer underneath does not depend on the ORM. Use it whenever a database store is declared; registerDatabaseSessionDriver(manager) adds the driver to a manager you built some other way.
The cookie store
{ driver: 'cookie' } keeps the whole session inside the cookie, encrypted under your APP_KEY (AES-256-GCM, with APP_PREVIOUS_KEYS accepted so a key rotation does not log everyone out). It is the one store that needs no server-side resource at all (no table, no migration, no Redis, no Workers binding):
stores: {
cookie: { driver: 'cookie' },
}
Three things it cannot do, and you should decide against them deliberately:
- Everything in the session travels in the cookie, so it is capped. The middleware measures the whole
Set-Cookieit is about to send — name and attributes included — againstmaxCookieBytes(4096 by default, what browsers keep) and throws rather than emitting one the browser silently drops. That leaves roughly 2.9 KB for the session itself. Keep records in the database and only their ids in the session. - A logout cannot revoke a cookie the client already copied.
invalidate()clears it on that client; the copy stays valid until it expires. Anything that must be revocable belongs in the database. - No "log out everywhere", and no session listing: there is nothing server-side to enumerate.
Set ttlSeconds deliberately: nothing server-side can expire the cookie early, so the encrypted payload's own expiry is the only limit.
The database store and boot checks
The database driver needs a sessions table in db/schema.ts and a migration. It has three columns: id (text primary key), data, and expiresAt. The dialect-specific shape is in the Cloudflare guide. Sweep expired rows on a schedule with manager.pruneExpired(); read() already treats them as missing.
Cookie and TTL settings on the manager are the base, and auth.sessionOptions overrides them field by field. These mistakes fail the boot, so a typo in SESSION_DRIVER stops the app before the first login:
- setting
auth.sessionOptions.storeand binding a manager (nothing picks one silently) - a
defaultstore whose driver nobody registered - a
defaultname thatstoresdoes not declare, caught whenAuthServiceProviderbuilds the manager
memory is always declared, so SESSION_DRIVER=memory works without an entry. The manager is bound before any provider boots; see Config definitions.
A plugin adds a driver by augmenting the SessionDrivers interface and calling manager.registerDriver(name, factory). Resolution is lazy, so the plugin's register() may run after the config was declared.
rule
On Cloudflare Workers, AWS Lambda, or Vercel, requests share no memory, so the default MemorySessionStore loses a login on the very next request. The middleware warns once per process when it finds itself in that position; guren check and the deploy builds warn ahead of time.
Configuring Providers & Guards
Using the auth.useModel() Shorthand (Recommended)
The simplest way to configure authentication is using the auth.useModel() helper, which registers both a ModelUserProvider and SessionGuard in one call:
import { ServiceProvider } from '@guren/core'
import type { AuthManager } from '@guren/core'
import { User } from '@/app/Models/User'
export default class AuthProvider extends ServiceProvider {
register(): void {
const auth = this.container.make<AuthManager>('auth')
auth.useModel(User, {
usernameColumn: 'email',
passwordColumn: 'passwordHash',
rememberTokenColumn: 'rememberToken',
credentialsPasswordField: 'password',
})
}
}
This single method call:
- Registers a
ModelUserProviderwith the specified columns - Creates a
SessionGuardwith proper session handling - Sets up the default guard as 'web'
- Uses the hasher
createApp({ auth: { hasher } })selected, scrypt by default (see Password hasher)
Password hasher
Every password the app writes goes through one hasher, whether AuthenticatableModel hashes it on create() or the session guard rehashes it at login. It is selected once, in createApp():
const app = createApp({
auth: {
hasher: 'scrypt', // the default
},
})
'scrypt'(the default) writes$scrypt$hashes throughnode:crypto, which every runtime reads back: Bun, Node, Lambda, Workers.'argon2'writes Argon2id throughBun.password. Pick it only for a deployment that stays on Bun;createApp()throws on a runtime withoutBun.password.- A
PasswordHasherobject replaces the built-in one entirely.
Verification is routed by the stored hash's format rather than by this setting, so a column holding both formats keeps working. A row in the other format, such as an Argon2id hash written under Bun before scrypt became the default, is rehashed on that user's next successful login. A row that never logs in again keeps its format.
On a runtime without Bun.password an Argon2id row cannot be verified at all. Migrate such a column while the app still runs on Bun, by having those users log in or by resetting their passwords, before moving to Node, Lambda or Workers.
Manual Configuration (Advanced)
For advanced use cases requiring custom providers or guards, you can still configure them manually:
import { ServiceProvider } from '@guren/core'
import { ModelUserProvider, SessionGuard } from '@guren/core'
import type { AuthManager } from '@guren/core'
import { User } from '@/app/Models/User'
export default class AuthProvider extends ServiceProvider {
register(): void {
const auth = this.container.make<AuthManager>('auth')
// Register the provider
auth.registerProvider('users', () => new ModelUserProvider(User, {
usernameColumn: 'email',
passwordColumn: 'passwordHash',
rememberTokenColumn: 'rememberToken',
credentialsPasswordField: 'password',
}))
// Register a custom guard
auth.registerGuard('web', ({ session, manager }) => {
const provider = manager.getProvider('users')
return new SessionGuard({ provider, session })
})
auth.setDefaultGuard('web')
}
}
Pair this with the AuthenticatableModel base class (see below) to get automatic password hashing and validation helpers.
Authenticatable Models
Models that extend AuthenticatableModel receive first-class password handling. Providing a plain password property when calling create or update automatically hashes and stores it in the passwordHash column (configurable via static properties). The framework never persists the plain text password, and authentication continues to rely on the same hashing algorithm as the providers.
import { AuthenticatableModel, defineModel } from '@guren/core'
import { users } from '@/db/schema.js'
export type UserRecord = typeof users.$inferSelect
export class User extends defineModel(users, {
base: AuthenticatableModel,
optionalOnCreate: ['passwordHash'],
requireOnCreate: ['password'],
}) {
// Optional:
// static override passwordField = 'plainPassword'
// static override passwordHashField = 'password_digest'
}
Pass AuthenticatableModel as the base and reshape the create payload in the same call. The type defineModel() infers from the table requires every non-defaulted column, which is the wrong shape here: callers pass a plain password the model hashes for them, not a passwordHash. optionalOnCreate makes the column optional and requireOnCreate makes the virtual field required, both at the type level with no cast and no redeclared markers.
Optional means optional: a caller may still pass passwordHash and it will type-check. At runtime, AuthenticatableModel denies the hash column (and the remember token) from mass assignment entirely: a request body carrying it throws a MassAssignmentException, whatever the model's fillable says. Use forceCreate() / forceUpdate() for trusted server-side values such as passwordHash: 'oauth:...'.
Leave requireOnCreate off when accounts can also arrive without a password — an OAuth-only sign-up, for instance — so password stays optional.
A credential column holding something that is not a password hash means the account cannot authenticate with one. ModelUserProvider treats a null column, an empty string, and a sentinel such as 'oauth:...' alike: the login is denied, and the same hashing work is spent as on a real verification so the response is not distinguishable by time. A value that claims a hash format and fails to satisfy it still throws. That is a corrupt or truncated column, and denying it in silence would leave nothing to notice it by. A nullable column is the clearer choice for a passwordless account; make:auth --oauth scaffolds one.
createApp() registers a default web guard that uses the users provider. If you need additional guards (e.g. token-based APIs), call auth.registerGuard('api', factory) inside the provider and set it as default via auth.setDefaultGuard('api') when appropriate.
Remember Tokens
SessionGuard manages remember tokens automatically when your user provider implements setRememberToken / getRememberToken. ModelUserProvider handles this when the rememberTokenColumn option is supplied.
Custom One-Time Token Stores
Password reset and email verification store one-time tokens. The memory and Redis stores need nothing from you; this section is for writing your own store, or upgrading an older scaffold.
The helpers rely on two atomic store operations. replace() revokes the email's previous tokens and stores the new one in a single operation. consume(tokenId, email) compares the stored email and deletes the token atomically, returning true to exactly one caller. The memory and Redis stores implement both methods. Issuance normalizes email addresses to lowercase before replacement.
Custom PasswordResetTokenStore and EmailVerificationTokenStore implementations must add these methods before using the issuance and completion helpers. Password reset stores accept replace(tokenId, email, expiresAt); email verification stores accept replace(token). The methods are optional in the interfaces for source compatibility, but the helpers reject stores that lack the required operation. A process-local lock around separate reads and deletes does not protect a store shared by several workers; use a transaction or an atomic database command.
completePasswordReset() and completeEmailVerification() consume the token before calling application code. If the update fails, request a new token; the consumed token is not restored. verifyPasswordResetToken() and verifyEmailToken() only inspect validity and do not reserve or consume a token. Use the completion helpers for updates. Existing scaffolded password reset controllers should migrate their separate verify/update/delete sequence to completePasswordReset(). Upgrade @guren/core and @guren/cli together, and restart all instances that issue or complete tokens; older instances still run the non-atomic sequence.
Example Application
The blog example includes the full authentication stack:
AuthProviderfor guard/provider setup, withconfig/session.tsandconfig/oauth.tsdefinitions- Login, registration, password reset, and email verification controllers, plus
DashboardController - Inertia pages under
resources/js/pages/auth/(Login,Register,ForgotPassword,ResetPassword,VerifyEmail) andresources/js/pages/dashboard/Index.tsx - OAuth login buttons for GitHub and Google
- Database schema, migration, and seeder for
users
Run the demo with:
bun run dev
Visit http://localhost:3333/login and sign in using the seeded credentials demo@guren.dev / secret, or visit /register to create a new account.