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

Terminal
pnpm add ajo-kit-auth@0.6.1

The 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:

pnpm-workspace.yaml
allowBuilds:
  argon2: true
  better-sqlite3: true
  esbuild: true

The 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

  1. Include the auth tables in your database type. Auth describes 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>()
  2. Give the package its database accessor with configure() and register the two root middlewares. wares.session() resolves req.user; wares.csrf rejects 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[]
  3. Run the migrations. The package declares kit.migrations, so kit migrate discovers them next to your own and creates the auth, passkey, team and invitation tables.

    Terminal
    kit migrate up
  4. Seed the roles your application uses. The package reads the roles table (a name and a JSON array of abilities) and the members table 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()
    }
  5. 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.

ExportContents
configureSets the Kysely accessor. Call it once at boot.
waressession(lookup?) and csrf middleware.
sessioncreate, validate, touch, remove, prune
cookieread, write, clear
csrfset, verify
Guardsauth, authorize, admit, ability, protect, guest, confirmed, verified, when, redirect; also under the guard namespace.
passwordArgon2id hash and verify.
tokencreate, validate, list, revoke, purge, prune
accountgrants, abilities, scoped
teamTeams, memberships and subject claims.
invitecreate, get, accept, revoke, list
passkeyWebAuthn registration and authentication.
limitIn-memory rate limiting.
confirmIn-memory recent password confirmation.
resetPassword reset tokens.
verifySigned email verification links.
can, all, merge, compact, intersectAbility 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.

src/login/handler.ts
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).

RuleValue
Absolute lifetime30 days, or 365 days with remember = true (the second argument of create).
Idle timeout30 minutes for every session. Remembering changes only the absolute limit.
Activity writeslast is updated at most once every 5 minutes.
Cookie name__Host-session when APP_URL starts with https:; session otherwise.
Cookie attributesHttpOnly; 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, HEAD and OPTIONS.
  • 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.

GuardKindBehavior
auth()middleware401 when there is no req.user.
protect(to = '/login')middlewareRedirects guests.
guest(to = '/dashboard')middlewareRedirects signed-in users away from guest-only pages.
authorize(req, ...abilities)functionGlobal 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)middlewareThe middleware form of authorize().
admit(req, subject, ...abilities)async functionSubject 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?)middleware401 without a user or credential. Redirects to /confirm?redirect=… unless the credential was confirmed within window ms (default 180,000).
verified()middleware401 without a user. Unverified accounts get 403 Email verification required for AJAX requests, otherwise a redirect to /verify.
when(condition, middleware, otherwise?)middlewareRuns middleware when condition(req, res) is true, else otherwise or next().
redirect(target)middleware302 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.

src/notes/wares.ts
import { protect } from 'ajo-kit-auth'

export default [protect('/login')]
src/notes/handler.ts
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.

src/settings/handler.ts
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.
  },
}
src/notebooks/[id]/handler.ts
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 (no subject) 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 the notes resource, and anything else matches exactly. Prefer narrow grants such as notes:write.
  • Subjects. Exact, nonblank, opaque strings with no wildcard, prefix or environment matching. A scoped token can never satisfy authorize() with abilities; use admit().
  • Lifetimes. ttl is in milliseconds and defaults to 90 days. Scoped tokens need a finite positive TTL of at most 90 days; global tokens also accept ttl: null for 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.token holds { id, abilities, subject }, with subject: null for 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, and token.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.

TypeScript
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

TypeScript
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 an ajoinv_ token and stores its SHA-256 hash. Invitations expire after seven days unless you pass ttl. Creating fails when the team does not exist.
  • An email binds 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, or null when 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.

src/passkeys/handler.ts
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 origins exactly, 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) and passkey.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.

src/reset/[token]/handler.ts
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.

TypeScript
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

TypeScript
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:

src/user.d.ts
import type { User as Account } from 'ajo-kit-auth'

declare module 'ajo-kit' {
  interface User extends Pick<Account, 'name' | 'email' | 'verified'> {}
}

Source of truth: ajo-kit-auth README