Packages
Authentication
ajo-kit-auth: sessions, CSRF, guards, tokens, teams, passkeys and more.
ajo-kit-auth adds authentication and authorization to an Ajo Kit application: cookie sessions, CSRF protection, route guards, bearer tokens with abilities, teams, invitations, passkeys, password reset and email verification. It keeps users, sessions, tokens and the rest in the application’s SQLite database through Kysely, and ships its own migrations.
Each piece is a plain function you call from handlers and wares.ts files. Nothing runs until you register the middleware or call an API, so an application can adopt sessions first and add tokens, teams or passkeys later.
Install
pnpm add ajo-kit-auth@0.6.1The package needs ajo-kit ^0.3.2 as a peer dependency and Node 22.18 or newer for development. Password hashing depends on argon2, which has a native build step. The official starter allows it next to the two builds from the quick start:
allowBuilds:
argon2: true
better-sqlite3: true
esbuild: trueThe package is marked server-only, so Kit’s Vite plugin blocks importing it from client code. Browser code that needs ability checks imports them from ajo-kit-auth/ability instead.
Set up
Include the auth tables in your database type.
Authdescribes every table the package owns; extend it with your own. The rest of this module follows the Database guide.src/database.ts import { connect, db as database, type Generated } from 'ajo-kit/database' import { env } from 'ajo-kit/platform' import type { Auth } from 'ajo-kit-auth' interface Notes { id: Generated<number> user: number text: string created: Generated<string> } export interface Database extends Auth { notes: Notes } connect(env('DATABASE_PATH') ?? './database.sqlite') export const db = () => database<Database>()Give the package its database accessor with
configure()and register the two root middlewares.wares.session()resolvesreq.user;wares.csrfrejects unsafe cookie-authenticated requests without proof.src/wares.ts import type { Middleware } from 'ajo-kit' import { configure, wares } from 'ajo-kit-auth' import { db } from './database' configure(() => db()) export default [wares.session(), wares.csrf] satisfies Middleware[]Run the migrations. The package declares
kit.migrations, sokit migratediscovers them next to your own and creates the auth, passkey, team and invitation tables.Terminal kit migrate upSeed the roles your application uses. The package reads the
rolestable (a name and a JSON array of abilities) and thememberstable that assigns them, but never creates roles itself.db/seeds/roles.ts import type { Kysely } from 'ajo-kit/database' export async function seed(db: Kysely<any>) { await db.insertInto('roles') .values([ { name: 'admin', abilities: JSON.stringify(['*']) }, { name: 'editor', abilities: JSON.stringify(['notes:*']) }, ]) .onConflict(conflict => conflict.column('name').doNothing()) .execute() }Set
APP_SECRET. It signs CSRF tokens and verification links. Development runs without it on a built-in placeholder; in production (NODE_ENV=production) every operation that needs the secret throws when it is missing, shorter than 32 characters, or a sample placeholder..env APP_URL=http://localhost:5173 APP_SECRET=<32+ random characters from your secret manager>
For non-local production, set APP_URL to the public origin used in generated links. When the host supplies a managed origins manifest, APP_URL must be an exact HTTPS origin listed there, and form checks use the current request origin through requestOrigin(req) from ajo-kit.
Main exports
Everything is exported from the package root. Most exports are namespaces of related functions.
| Export | Contents |
|---|---|
configure | Sets the Kysely accessor. Call it once at boot. |
wares | session(lookup?) and csrf middleware. |
session | create, validate, touch, remove, prune |
cookie | read, write, clear |
csrf | set, verify |
| Guards | auth, authorize, admit, ability, protect, guest, confirmed, verified, when, redirect; also under the guard namespace. |
password | Argon2id hash and verify. |
token | create, validate, list, revoke, purge, prune |
account | grants, abilities, scoped |
team | Teams, memberships and subject claims. |
invite | create, get, accept, revoke, list |
passkey | WebAuthn registration and authentication. |
limit | In-memory rate limiting. |
confirm | In-memory recent password confirmation. |
reset | Password reset tokens. |
verify | Signed email verification links. |
can, all, merge, compact, intersect | Ability helpers, also exported by ajo-kit-auth/ability for browser code. |
Sessions and cookies
A sign-in action checks the password, creates a session and writes its cookie. session.create() returns the plaintext credential for the cookie; the database stores only its SHA-256 hash.
import { Denied, Failure, ip, type Request, type Response } from 'ajo-kit'
import { cookie, limit, password, session } from 'ajo-kit-auth'
import { email, maxLength, object, parse, pipe, string, toLowerCase, trim } from 'ajo-kit/validate'
import { db } from '../database'
const Login = object({
email: pipe(string(), trim(), toLowerCase(), email(), maxLength(254)),
password: pipe(string(), maxLength(128)),
})
// Verifying against a real hash when no account matches keeps the timing uniform.
const dummy = await password.hash('not-a-real-password')
export const actions = {
default: async (req: Request, res: Response) => {
const input = parse(Login, req.body)
const key = `login:${ip(req)}:${input.email}`
if (!limit.check(key)) throw new Failure(429, 'Too many attempts. Try again in a minute.')
limit.hit(key)
const user = await db().selectFrom('users').select(['id', 'password'])
.where('email', '=', input.email).executeTakeFirst()
const valid = await password.verify(input.password, user?.password ?? dummy)
if (!user?.password || !valid) throw new Denied('Email or password is incorrect')
limit.clear(key)
const id = await session.create(user.id, false, ip(req), req.headers['user-agent'])
cookie.write(res, id)
return { redirect: '/notes' }
},
}To sign out, pass the value from cookie.read(req) to session.remove() and call cookie.clear(res).
| Rule | Value |
|---|---|
| Absolute lifetime | 30 days, or 365 days with remember = true (the second argument of create). |
| Idle timeout | 30 minutes for every session. Remembering changes only the absolute limit. |
| Activity writes | last is updated at most once every 5 minutes. |
| Cookie name | __Host-session when APP_URL starts with https:; session otherwise. |
| Cookie attributes | HttpOnly; SameSite=Lax; Path=/, plus Secure for HTTPS, with a matching Max-Age. |
The __Host- prefix stops sibling subdomains from shadowing the session. Local HTTP development uses the plain name because browsers require Secure on __Host- cookies. cookie.read() rejects a header that carries the session cookie twice.
session.validate(id, activity = true) removes expired sessions and returns the row or null. The middleware passes activity = false for text/event-stream requests, so a live connection does not keep a session awake. When the cookie no longer resolves, the middleware clears it. session.prune() deletes expired rows; expiry does not depend on it.
After wares.session(), req.user holds the account’s id, name, email and verified fields with its role names and merged abilities, and req.session holds the stored (hashed) session id. Pass your own resolver, wares.session(lookup), to load a different user shape; it receives the user id and returns the user or null.
CSRF protection
wares.csrf decides per request:
- Requests authenticated by a bearer token pass. So do
GET,HEADandOPTIONS. - Unauthenticated requests under
/api/*pass. - Every other request, including route actions such as sign-in and registration forms, needs proof or fails with 403
Invalid CSRF token.
csrf.verify(req) accepts either proof. The first is a same-origin check: the Origin or Referer header must match the current request origin. The second is a signed double-submit token bound to the session: csrf.set(req, res) writes a readable XSRF-TOKEN cookie and returns the token, and the client sends it back in an X-XSRF-TOKEN header. csrf.set requires a session.
On a managed App with several origins, proof must match the origin of the current request. A form on one alias does not authorize a request to another alias of the same App.
Guards
Guards are middleware for wares.ts files, or functions you call inside loaders, actions and API handlers. Failures throw Kit errors: Denied is a 401 and Forbidden a 403.
| Guard | Kind | Behavior |
|---|---|---|
auth() | middleware | 401 when there is no req.user. |
protect(to = '/login') | middleware | Redirects guests. |
guest(to = '/dashboard') | middleware | Redirects signed-in users away from guest-only pages. |
authorize(req, ...abilities) | function | Global check. 401 without a user. With abilities, 403 for any subject-scoped token, then 403 Missing ability: … when the account or the token lacks one. Without abilities it checks authentication only. |
ability(...abilities) | middleware | The middleware form of authorize(). |
admit(req, subject, ...abilities) | async function | Subject check. 401 without a user; 403 when a scoped token names another subject. Global abilities count, plus the user’s team grants for that subject; a token must also carry each ability. |
confirmed(window?) | middleware | 401 without a user or credential. Redirects to /confirm?redirect=… unless the credential was confirmed within window ms (default 180,000). |
verified() | middleware | 401 without a user. Unverified accounts get 403 Email verification required for AJAX requests, otherwise a redirect to /verify. |
when(condition, middleware, otherwise?) | middleware | Runs middleware when condition(req, res) is true, else otherwise or next(). |
redirect(target) | middleware | 302 with Location; AJAX requests get 200 JSON { redirect } with Cache-Control: no-store. target may be a function of the request. |
A branch-level wares.ts protects every route beneath it, and loaders still call authorize(req) so their data never depends on the redirect alone. confirmed() and verified() redirect to /confirm and /verify; your application provides those pages.
import { protect } from 'ajo-kit-auth'
export default [protect('/login')]import type { Request } from 'ajo-kit'
import { authorize } from 'ajo-kit-auth'
import { db } from '../database'
export async function page(req: Request) {
authorize(req)
const notes = await db().selectFrom('notes').select(['id', 'text'])
.where('user', '=', req.user!.id).execute()
return { notes }
}Bearer tokens
API tokens let scripts and other programs call /api/* routes with an Authorization: Bearer header. On those routes an explicit bearer token takes precedence over a session cookie; when the token is invalid, the request stays unauthenticated and the cookie is not consulted. Route actions always use cookie sessions.
import type { Request } from 'ajo-kit'
import { authorize, token } from 'ajo-kit-auth'
export const actions = {
token: async (req: Request) => {
authorize(req)
const plain = await token.create(req.user!.id, 'Import script', ['notes:write'], {
subject: 'notebook:42',
ttl: 30 * 24 * 60 * 60 * 1000,
})
return { token: plain } // Shown once; only its hash is stored.
},
}import type { Request, Response } from 'ajo-kit'
import { send } from 'ajo-kit/server'
import { admit } from 'ajo-kit-auth'
// POST /api/notebooks/:id
export default {
async post(req: Request, res: Response) {
await admit(req, `notebook:${req.params.id}`, 'notes:write')
// Store the imported note…
send(res, 201, { ok: true })
},
}- Issuance.
token.create(user, name, abilities, options?)returns the plaintext token once. A global token (nosubject) is checked against the account’s current global abilities; a scoped token against those plus the user’s team grants for its subject. Requests beyond that authority throw. - Abilities.
*grants everything,notes:*every ability of thenotesresource, and anything else matches exactly. Prefer narrow grants such asnotes:write. - Subjects. Exact, nonblank, opaque strings with no wildcard, prefix or environment matching. A scoped token can never satisfy
authorize()with abilities; useadmit(). - Lifetimes.
ttlis in milliseconds and defaults to 90 days. Scoped tokens need a finite positive TTL of at most 90 days; global tokens also acceptttl: nullfor no expiry. - Every request checks the account’s current authority and the token’s abilities, so a removed role, membership or claim takes effect on the next request.
req.tokenholds{ id, abilities, subject }, withsubject: nullfor global tokens. - Management.
token.list(user)returns full ids, names, subjects, abilities and usage and expiry metadata, never secrets.token.revoke(user, id)deletes one of the user’s tokens by its full id and returns whether it did.token.purge(user)deletes all of them, andtoken.prune()deletes expired tokens.token.validate(plain)resolves an identity; it is not an authorization check.
Roles and teams
Global authority comes from roles assigned in members. account.grants(user) returns each role with its abilities, account.abilities(user) merges them, and account.scoped(user, subject) returns only what team membership grants over one subject.
Teams are subject-scoped authorization groups, not tenants: they have no settings, request context, resource ownership or data isolation. A teammate holds a role from the same roles catalog. A claim records that a team holds a subject, an opaque string your application defines. Global grants apply everywhere; on top, for one subject, a user gains the abilities of every role they hold in every team that claims it.
import { admit, team } from 'ajo-kit-auth'
const id = await team.create('Field notes')
await team.join(id, userId, editorRoleId) // a roles.id; joining again changes the role
await team.claim(id, 'notebook:42') // idempotent
// Later, in a handler for that notebook:
await admit(req, 'notebook:42', 'notes:write')The team namespace also has rename, remove, get, list (with member and claim counts), leave, members, release, claims, holders(subject), of(user) and subjects(user), which lists every subject a user reaches and suits scoping list views. Removing a team revokes its pending invitations.
Invitations
import { invite, password } from 'ajo-kit-auth'
const plain = await invite.create({ role: 'editor', team: teamId, email: 'ana@example.com', inviter: userId })
// Send a link that carries plain; it is returned only once.
const pending = await invite.get(plain) // { role, name, email, team, user } or null
const account = await invite.accept(plain, { passwordHash: await password.hash(newPassword) })create()returns anajoinv_token and stores its SHA-256 hash. Invitations expire after seven days unless you passttl. Creating fails when the team does not exist.- An
emailbinds acceptance to that normalized address and revokes earlier pending invitations for it. Without one, the person accepting supplies the address and invitations are not deduplicated. accept()creates the account atomically and returns its id, ornullwhen the invitation is not valid or the email is already registered. The role is looked up by name and fails closed when unknown. A team invitation adds a teammate; a global one adds a member.- The account is marked verified only when the invitation was email-bound and a password hash was supplied. An account without credentials can revisit the invitation to finish a passkey ceremony until it gains a password or a passkey.
list()returns pending invitations with stored ids;revoke(id)takes one of those ids.
Passkeys
The package implements WebAuthn itself for attestation: 'none', the format mainstream passkey providers emit. It accepts ES256, EdDSA and RS256. A passkey replaces exactly one step, proving the person is who they claim; the caller then creates a session as it would after password.verify.
import { Denied, ip, type Request, type Response } from 'ajo-kit'
import { authorize, cookie, passkey, session } from 'ajo-kit-auth'
// Once, at startup. Never derived from request headers.
passkey.configure({ rpId: 'example.com', origins: ['https://example.com'] })
export const actions = {
// Registration: a signed-in person adds a passkey in two requests.
options: async (req: Request) => {
authorize(req)
return passkey.registration({ id: req.user!.id, name: req.user!.email })
},
register: async (req: Request) => {
authorize(req)
return { id: await passkey.register(req.user!.id, req.body) }
},
// Authentication: ask for a challenge, then answer it.
challenge: async () => passkey.authentication(),
signin: async (req: Request, res: Response) => {
const user = await passkey.authenticate(req.body).catch(() => null)
if (user === null) throw new Denied('Passkey sign-in failed')
cookie.write(res, await session.create(user, false, ip(req), req.headers['user-agent']))
return { redirect: '/notes' }
},
}The options carry base64url strings. Browser code converts them for navigator.credentials.create() or get() and posts back base64url fields: id, clientDataJSON, attestationObject and optional transports for registration; id, clientDataJSON, authenticatorData, signature and userHandle for authentication. Registration requires a discoverable credential, so authentication sends an empty allowCredentials list and the credential names its own account.
- Challenges are single-use database rows that expire after five minutes, enforced when they are redeemed.
passkey.prune()only reclaims rows. - Client data must come from one of the configured
originsexactly, and not from a cross-origin frame. - Backup eligibility cannot change in either direction, and a credential registered with user verification cannot later sign in on presence alone. The signature counter is recorded but not enforced, because synced passkeys report zero.
- Failed ceremonies throw
passkey.Malformed. Treat every failure the same way, as the example does. passkey.list(user)andpasskey.remove(user, id)serve a management screen.
Rate limits and confirmation
limit counts attempts per key in fixed windows: check(key, max = 5), hit(key, window = 60_000), remaining(key, max = 5) and clear(key). confirm records that the current credential re-entered its password: call confirm.stamp(req) after checking it, and confirm.check(req, window = 180_000) or the confirmed() guard before a sensitive operation. Stamps are scoped to the current session or bearer token; clear, clearSession, clearToken and clearUser remove them.
Password reset and email verification
reset.create(user) returns a plaintext token, replaces the user’s earlier reset tokens and expires in one hour. reset.validate(token) is a read-only preview for the page that shows the form. reset.consume(token, passwordHash) is the atomic boundary: it changes the password, revokes the user’s sessions, API tokens and other reset tokens, clears confirmation stamps, and returns the user id or null.
import { Missing, type Request } from 'ajo-kit'
import { password, reset } from 'ajo-kit-auth'
import { minLength, object, parse, pipe, string } from 'ajo-kit/validate'
const Change = object({ password: pipe(string(), minLength(12)) })
export async function page(req: Request) {
return { valid: (await reset.validate(req.params.token)) !== null }
}
export const actions = {
default: async (req: Request) => {
const input = parse(Change, req.body)
const user = await reset.consume(req.params.token, await password.hash(input.password))
if (user === null) throw new Missing('This link has expired')
return { redirect: '/login' }
},
}verify.url(user, email, base) builds {base}/verify/{signature}: an HMAC-SHA256 signature bound to the normalized email, valid for 24 hours. verify.validate(signature) sets users.verified and returns the user id, or null. A link can be replayed until it expires but only ever affirms the address it was minted for; an already verified account succeeds without another write.
import { origin } from 'ajo-kit'
import { verify } from 'ajo-kit-auth'
import { send } from 'ajo-kit-mail'
// Inside an action, after authorize(req):
const { id, email } = req.user!
await send({
to: email,
kind: 'verify',
subject: 'Verify your email',
text: `Confirm this address: ${verify.url(id, email, origin(req))}`,
})Types
import type { Ability, Auth, Invite, New, Session, Team, Token, User } from 'ajo-kit-auth'Auth is the schema fragment the migrations create. User, Session, Token, Team and Invite are selected rows, New is an insertable user, and Ability is a string. To type the extra fields on req.user, augment Kit’s User interface:
import type { User as Account } from 'ajo-kit-auth'
declare module 'ajo-kit' {
interface User extends Pick<Account, 'name' | 'email' | 'verified'> {}
}- MailDeliver verification and reset links with validated messages.
- API routes and middlewareWhere wares.ts files and /api handlers fit.
- DatabaseMigrations, seeds and the SQLite file on disk.
Source of truth: ajo-kit-auth README