Authentication
Authentication
The starter always ships a local email/password sign-in flow through the
first-party auth plugin (jsails/auth), which backs Better Auth over
MariaDB. It provides email/password sign-in plus RFC 8628 device
authorization, exposes an AuthSessionService, and mounts a set of trusted hook
routes. It is deliberately login-only — a starting point you build production
auth on, not a finished authorization system.
The auth plugin
authPlugin({ publicOrigin }) lazily builds a Better Auth instance and mounts
its routes. Better Auth is constructed lazily and memoized, so nothing connects
at import or setup time — the database is only touched when a route actually
runs.
The plugin provides an AuthSessionService under the authSessionToken service
token. The admin panel consumes it by declaring requires: [authSessionToken],
so the auth plugin must be declared earlier in the extension list than the
admin plugin, or assembly fails with a value-free error.
// jsails.app.js (app-owned, compiled ESM; plain default export)
import { authPlugin } from 'jsails/auth';
import { serverComponentsPlugin } from 'jsails/server-components';
import { taskList } from './dist/components/task-list.js';
export default {
rootDir: '.',
plugins: {
enabled: ['auth', 'cache', 'filesystem', 'mail', 'server-components'],
},
extensions: [
authPlugin({ publicOrigin: process.env.BETTER_AUTH_URL }),
serverComponentsPlugin({ components: { 'task-list': taskList } }),
],
};
resolveSessionFromRequest(createAuth(), request) and createAuth are exported
from the same subpath. The starter's own config sets a global resolveSession
this way for the /api/me filesystem API and the /dashboard page — that
resolves a memoized instance distinct from the plugin's own, but both read
the same environment and database.
// jsails.app.js (app-owned)
import { createAuth, resolveSessionFromRequest } from 'jsails/auth';
const auth = createAuth();
export default {
resolveSession: (request) => resolveSessionFromRequest(auth, request),
// ...extensions, including authPlugin
};
Mounted routes
authPlugin mounts trusted hook routes — native Hono routes added through
the extension seam, never filesystem API routes. That means they bypass the
default-deny authorize gate and the cookie-session CSRF middleware, because the
plugin owns its own origin/session checks.
Core sign-in and device flow:
/api/auth/*— the Better Auth handler (email/password, session endpoints)./api/loginand/api/logout./api/device/approveand/api/device/deny— RFC 8628 device authorization.
The account lifecycle handlers are mounted too, alongside the standalone handlers the barrel exports:
/api/register/api/password-resetand/api/password-reset/confirm/api/verification/send
The standalone functions handleRegister,
handleRequestPasswordReset / handleResetPassword, and
handleSendVerificationEmail let you wire these into your own surfaces.
buildResetPasswordSender and buildVerificationSender adapt a SendMailFn to
the jsails/mail transport.
This is login-only. Registration UI, password reset, email verification, and OAuth are deferred in the starter — the routes and handlers are present, but the starter's pages do not exercise them.
Roles
Role checks read a session's role field. sessionRole(context),
requireRole(...roles), and adminOnly each return a strict RoleCheck: an
exact true allows, anything else denies. They are suitable for a page or API
authorize callback, which follows the same exact-true default-deny rule as
the rest of the runtime.
// pages/admin.tsx (app-owned)
import { adminOnly } from 'jsails/auth';
import type { RequestContext } from 'jsails';
export function authorize(context: RequestContext) {
return adminOnly(context); // exact true allows, everything else denies
}
API tokens
The same subpath mints long-lived, revocable API tokens behind the
apiTokens option, disabled by default. When apiTokens.enabled is set, the
plugin provides an ApiTokenService under the apiTokensToken service token and
mounts three trusted routes:
POST /api/tokensGET /api/tokensDELETE /api/tokens/:id
Token management requires a signed-in browser session plus same-origin and CSRF checks.
A token is a Better Auth session tagged with a jsails/api-token: userAgent
sentinel, so it rides the existing session table with no new schema.
createApiToken returns the raw secret exactly once — it is never listed or
stored again.
AuthSessionService.resolveSession resolves the cookie session first, then falls
back to the Bearer <token> credential in the configured header — and only when
tokens are enabled. The header defaults to authorization, overridable via
apiTokens.headerName. apiTokens.expiresInDays defaults to 30 (bounded
1..3650).
// jsails.app.js (app-owned)
import { authPlugin } from 'jsails/auth';
export default {
extensions: [
authPlugin({
publicOrigin: process.env.BETTER_AUTH_URL,
apiTokens: { enabled: true, expiresInDays: 90 },
}),
],
};
curl -H "Authorization: Bearer $TOKEN" https://app.example.com/api/me
Environment and setup
Auth requires a database connection. The recommended default is MariaDB, started
locally with jamal up (which also starts Valkey). JSails reads the connection
from individual variables — there is no DATABASE_URL:
DATABASE_HOSTDATABASE_PORT(default3306)DATABASE_USERDATABASE_PASSWORDDATABASE_NAME
Better Auth reads:
BETTER_AUTH_SECRET— export it before serving, at least 32 bytes.BETTER_AUTH_URL— the public origin, used behind a TLS-terminating proxy (defaulthttp://localhost:3000); in the starter it is also passed aspublicOrigin.
Create Better Auth's tables and the first account with the starter's scripts:
npm run auth:migrate # create Better Auth's tables
npm run user:create -- <email> <password> [name] # create the first account
Next steps
- Plugins & Extending — the extension seam,
service tokens, and how the
authplugin consumes them.