Keelv0.86.0
Docs / Authentication

Session-based auth built on the pieces you already have: sessions hold the login, hashing checks passwords. auth() ties them together.

Requires sessionMiddleware() in your HTTP kernel — every auth() call reaches through the session, so without the middleware the first call throws Session is not available….

Tell Keel how to load a user

Register a user provider once (in a service provider) — a function that returns a user for an id. Keel stays database-agnostic, so this is wherever your users live:

import { setUserProvider } from "@shaferllc/keel/core";

setUserProvider((id) => db.users.find(id));

The id handed to your provider is always a stringlogin() normalizes whatever you pass (number or string) with String(id) before stashing it. If your ids are numeric, coerce inside the provider (db.users.find(Number(id))).

Logging in

Verify the password yourself with hash, then login() the user's id:

import { auth, hash, response } from "@shaferllc/keel/core";

async login() {
  const { email, password } = await request.only(["email", "password"]);
  const user = await db.users.findByEmail(email);

  if (!user || !(await hash.verify(user.password, password))) {
    return response.abort("Invalid credentials", 401);
  }

  auth().login(user.id);
  return response.redirect("/dashboard");
}

login() only writes the id to the session — it does no lookup and no password check. Verifying credentials is your job (above); login() is the "trust this id from now on" step.

Verify in constant time. The snippet above skips the password check when no user is found, so a missing account answers faster than a wrong password — a timing signal that leaks which emails are registered. Compare against hash.dummy (a valid hash that never matches) so both paths cost the same:

const user = await db.users.findByEmail(email);
const ok = await hash.verify(user?.password ?? hash.dummy, password);
if (ok && user) auth().login(user.id);   // `user &&` so the dummy never logs anyone in

Reading the current user

auth().check();        // is someone logged in?
auth().guest();        // …or not?
auth().id();           // the user id (string), or null
await auth().user();   // the full user (via your provider), or null

user() reads the id from the session and runs it back through your provider on every call — there's no request-level cache, so if you need it twice in one handler, hold onto the result. Type the row it returns with the generic:

type User = { id: number; email: string };

const user = await auth().user<User>(); // User | null

user() returns null when nobody is logged in. But if someone is logged in and you never called setUserProvider, it throws — Keel has no way to turn the id back into a user:

Error: No user provider. Call setUserProvider((id) => findUser(id)).

Logging out

auth().logout();
return response.redirect("/");

logout() forgets the id from the session; it doesn't destroy the whole session, so anything else you flashed or stored survives. Clear the session yourself if you want a clean slate on sign-out.

Protecting routes

authGuard() rejects unauthenticated requests. Register it as named middleware and apply it wherever you need:

import { authGuard } from "@shaferllc/keel/core";

router.named({ auth: authGuard({ redirectTo: "/login" }) });

router.get("/dashboard", [DashboardController, "index"]).use("auth");
router.group(() => { /* … */ }).use("auth");

Without redirectTo, the guard returns 401 Unauthenticated (ideal for APIs):

router.named({ auth: authGuard() }); // 401 JSON on failure, no redirect

The guard only checks that someone is logged in — it runs no provider lookup and loads no user. It gates on guest(), so it's cheap; load the user inside the handler with auth().user() when you actually need it.

Token (API) authentication

Sessions ride on a cookie — great for a server-rendered app, awkward for an SPA, a mobile client, or another service. For those, issue a stateless bearer token: an HS256 JWT signed with config('app.key'), built on the Web Crypto API so it works the same on Node and the edge (no jsonwebtoken, no native bindings). This is the Cloudflare-Workers-friendly path — nothing to store server-side.

Issue a token in your login handler instead of (or alongside) auth().login():

import { jwt, hash, response } from "@shaferllc/keel/core";

async login() {
  const { email, password } = await request.only(["email", "password"]);
  const user = await db.users.findByEmail(email);
  if (!user || !(await hash.verify(user.password, password))) {
    return response.abort("Invalid credentials", 401);
  }

  const token = await jwt.sign({ sub: String(user.id) }, { expiresIn: "1h" });
  return response.json({ token });
}

Protect API routes with bearerAuth(). It reads Authorization: Bearer <token>, verifies it, and makes the token's sub the authenticated id — so auth() works downstream exactly as it does for sessions, provider lookup and all:

import { bearerAuth, auth } from "@shaferllc/keel/core";

router.get("/api/me", async () => response.json(await auth().user())).use(bearerAuth());

A missing or invalid token gets 401 Unauthenticated. Pass { optional: true } to let the request through unauthenticated (auth().check() is then false). A token verified this way takes precedence over any session cookie on the same request, and — unlike sessions — needs no session store, so bearerAuth() works without sessionMiddleware().

jwt is a standalone primitive if you need tokens outside the guard:

const token = await jwt.sign({ sub: "42", role: "admin" }, { expiresIn: "7d" });
const payload = await jwt.verify(token); // { sub, role, iat, exp } | null

verify() returns null — never throws — for a token that's malformed, tampered, expired, not-yet-valid, or fails an issuer/audience check. Only HS256 is accepted: alg: none and asymmetric algorithms are refused, closing the classic JWT algorithm-confusion hole. sign() accepts expiresIn (seconds, or a duration string like "30s", "15m", "1h", "7d"), plus subject, issuer, audience, and a secret override.

Opaque access tokens

A JWT is stateless — you can't revoke one without extra machinery. When you need revocable, scoped API tokens (a "personal access tokens" screen, per-token abilities, "log out this device"), use the database-backed token store instead. A token is a row you can delete, so revocation is instant.

Store them in a personal_access_tokens table (all timestamps epoch-ms):

selector TEXT UNIQUE, hash TEXT, tokenable_id TEXT, name TEXT,
abilities TEXT, last_used_at INTEGER, expires_at INTEGER, created_at INTEGER

Mint a token after verifying credentials — the plaintext is shown once:

import { createToken } from "@shaferllc/keel/core";

const { token } = await createToken(user.id, {
  abilities: ["posts:read", "posts:write"], // or ["*"] for everything
  expiresIn: "30d",                          // omit for no expiry
  name: "CLI token",
});
return response.json({ token }); // "keel_<selector>.<verifier>"

Protect routes with tokenAuth() — it verifies the Bearer token, makes its owner the authenticated user, and can require abilities:

import { tokenAuth, auth, token, tokenCan } from "@shaferllc/keel/core";

router.get("/api/posts", async () => response.json(await auth().user()))
  .use(tokenAuth({ abilities: ["posts:read"] }));

// inside a handler, inspect the verified token:
token();               // { tokenableId, abilities, expiresAt, … } | null
tokenCan("posts:write"); // boolean

The token splits into a public selector (indexed, for lookup) and a secret verifier (stored only as a SHA-256 hash), so a leaked database can't mint working tokens — and verification needs no RETURNING, so it's portable across every driver. Manage tokens with listTokens(userId), revokeToken(selector), and revokeTokens(userId) (log out everywhere). Verifying an expired token deletes it in passing, so the table self-prunes.

JWT vs. opaque: reach for jwt when you want zero-lookup, stateless tokens (and don't need revocation); reach for createToken/tokenAuth when you need revocation, per-token scopes, or last-used tracking.

Basic authentication

For internal tools and quick gates, basicAuth() implements HTTP Basic auth — the browser's native username / password prompt. Always behind HTTPS, since the credentials ride on every request:

import { basicAuth, auth, hash } from "@shaferllc/keel/core";

router.get("/admin", () => response.json(auth().id())).use(
  basicAuth(async (username, password) => {
    const user = await db.users.findByEmail(username);
    const ok = await hash.verify(user?.password ?? hash.dummy, password);
    return ok && user ? user.id : false; // return the id to log them in, or false
  }, { realm: "Admin" }),
);

The verifier returns the user's id (logs them in for the request), true (allow without an identity), or a falsy value (reject). On rejection basicAuth answers 401 with a WWW-Authenticate challenge so the browser re-prompts.

Social sign-in

"Sign in with GitHub/Google/Discord" lives in its own guide — Social authentication.

Registration

Registration is the same flow in reverse — hash the password on the way in:

const user = await db.users.create({
  email,
  password: await hash.make(password),
});
auth().login(user.id);

Working with Auth directly

auth() is a thin accessor — it returns a fresh, stateless Auth bound to the current request's session. You can construct one yourself if you prefer; it reads the same session, so the two are interchangeable:

import { Auth } from "@shaferllc/keel/core";

if (new Auth().check()) { /* … */ }

There's nothing to share between instances — all state lives in the session — so auth() and new Auth() behave identically.


API reference

auth()

auth(): Auth

Returns an Auth accessor bound to the current request's session.

import { auth } from "@shaferllc/keel/core";

auth().login(userId);
await auth().user();

Notes: constructs a fresh Auth each call — it's stateless, so there's no cost to calling it repeatedly. Every method underneath reaches through session(), which throws if sessionMiddleware() isn't installed.

setUserProvider(fn)

setUserProvider(fn: UserProvider): void

Registers the function Keel uses to turn a stored id back into a user.

import { setUserProvider } from "@shaferllc/keel/core";

setUserProvider((id) => db.users.find(id));

Notes: global — the last call wins. Register it once in a service provider. Until it's set, auth().user() throws for a logged-in request (but still returns null for a guest).

authGuard(options?)

authGuard(options?: { redirectTo?: string }): MiddlewareHandler

Builds a middleware that blocks unauthenticated requests.

import { authGuard } from "@shaferllc/keel/core";

router.named({
  auth: authGuard({ redirectTo: "/login" }),
  api: authGuard(), // 401 instead
});

Notes: with redirectTo, guests get a redirect; without it, a 401 { error: "Unauthenticated", status: 401 } JSON response. Authenticated requests pass straight through to the next handler. The check is guest() only — no user is loaded.

Auth

The accessor returned by auth(). Stateless — all its state lives in the session — so you rarely construct it directly, though new Auth() works and is equivalent to auth().

login(id)

login(id: string | number): void

Marks the given id as the authenticated user by storing it in the session.

auth().login(user.id);

Notes: does no lookup or password check — verify credentials before calling. The id is coerced with String(id), so id() and your provider always receive a string.

logout()

logout(): void

Forgets the authenticated id from the session.

auth().logout();

Notes: only removes the auth key — other session data (flashes, cart, etc.) survives. Call session().clear() yourself for a full reset.

id()

id(): string | null

The authenticated user's id, or null if nobody is logged in.

const uid = auth().id(); // "42" | null

Notes: always a string (see login). Returns null, not undefined, for a guest.

check()

check(): boolean

true when a user is authenticated.

if (auth().check()) { /* logged in */ }

Notes: a pure id() != null test — reads the session, runs no provider.

guest()

guest(): boolean

true when the request is unauthenticated — the inverse of check().

if (auth().guest()) return response.redirect("/login");

user(...)

user<User = unknown>(): Promise<User | null>

Loads the full authenticated user by running the session id through the registered provider.

type User = { id: number; email: string };
const user = await auth().user<User>(); // User | null

Notes: returns null when nobody is logged in. Throws No user provider… if a user is logged in but setUserProvider was never called. No caching — each call re-invokes the provider. The generic only types the result; it does not validate the row's shape at runtime.

Interfaces & types

UserProvider

type UserProvider = (id: string) => unknown | Promise<unknown>;

The seam between Keel and your user store. Implement it once and register it with setUserProvider — Keel calls it with the string id from the session whenever auth().user() runs, and treats the return value as the authenticated user.

import { setUserProvider, type UserProvider } from "@shaferllc/keel/core";

const provider: UserProvider = async (id) => {
  // `id` is always a string; coerce if your keys are numeric
  return db.users.find(Number(id));
};

setUserProvider(provider);

Notes: may be sync or async — user() awaits it either way. Return the user object (any shape) when found, or a nullish value when not; that value flows back out of auth().user().