Skip to content

Writing a Sign-in Method

A sign-in method is a way a person proves who they are and gets a session, such as a passkey, a magic link or OAuth. Core’s methods and a plugin’s are declared the same way, so a plugin can add one of its own, such as SAML, a wallet or an enterprise identity provider. It then shows up on authClient.signIn next to core’s, with types.

A method has two halves:

  • The server half, defineAuthMethod from plumix/plugin, goes in the plugin’s authMethods. Its routes answer under /_plumix/auth/<name>/, and each route’s handler works through a kernel.
  • The browser half, defineAuthMethodClient from plumix/auth/client, is a module the server half names in client. It appears as authClient.signIn.<camelCase name>, with any extra actions under authClient.<camelCase name>.

A method only proves an identity. It ends in the kernel’s proveIdentity, and core decides everything after that: whether the address may have an account, whether the account is disabled, the session, and where the visitor lands.

This page builds a complete method, email-code, which mails a six-digit code and trades it for a session. Every sample is a file of the playground that Plumix’s own tests sign in through. Sign-in in Your Theme builds the theme pages that call it.

The plugin declares the method, its mail and its tables:

auth/email-code/index.ts
import type { JsonObject, JsonValue } from "plumix";
import { defineAuthMethod, defineMail, definePlugin } from "plumix/plugin";
import type emailCodeClient from "./client.js";
import { checkCode, CODE_TTL_MINUTES, issueCode } from "./codes.js";
import * as schema from "./schema.js";
interface EmailCodeMail {
readonly code: string;
}
declare module "plumix" {
interface AuthMethodRegistry {
"email-code": typeof emailCodeClient;
}
interface MailRegistry {
emailCode: EmailCodeMail;
}
}
const emailCodeMail = defineMail<EmailCodeMail>("emailCode", {
subject: () => "Your sign-in code",
text: ({ code }) =>
`Your sign-in code is ${code}. It expires in ${CODE_TTL_MINUTES} minutes.`,
preview: { code: "123456" },
});
function isObject(input: JsonValue): input is JsonObject {
return typeof input === "object" && input !== null && !Array.isArray(input);
}
function readStringField(input: JsonValue, name: string): string {
const value = isObject(input) ? input[name] : undefined;
return typeof value === "string" ? value.trim() : "";
}
const EMAIL = /^[^\s@]+@[^\s@]+$/;
/**
* Signs a person in with a six-digit code mailed to them. It proves the
* inbox; core decides whether that address may have an account.
*/
export const emailCode = definePlugin("email-code", {
schema,
// Inside the site, so the tables are the site's.
schemaModule: "./auth/email-code/schema.ts",
mails: [emailCodeMail],
setup: () => undefined,
authMethods: [
defineAuthMethod({
name: "email-code",
label: "Email code",
// The admin's login screen calls a listed method's `signIn()` with no
// arguments, and this one needs an address and a code.
listed: false,
errorCodes: [
"invalid_input",
"too_many_codes",
"code_expired",
"wrong_code",
"too_many_attempts",
],
client: `${import.meta.dirname}/client.ts`,
routes: {
request: {
method: "POST",
formPost: true,
handler: async (auth) => {
const email = readStringField(
await auth.readInput(),
"email",
).toLowerCase();
if (!EMAIL.test(email)) return auth.fail("invalid_input");
const code = await issueCode(auth.ctx, email);
if (code === null)
return auth.fail("too_many_codes", { status: 429 });
await auth.ctx.mail.send("emailCode", { code }, { to: email });
return auth.notice("sent");
},
},
verify: {
method: "POST",
formPost: true,
handler: async (auth) => {
const input = await auth.readInput();
const email = readStringField(input, "email").toLowerCase();
const code = readStringField(input, "code");
if (!EMAIL.test(email) || code === "") {
return auth.fail("invalid_input");
}
const checked = await checkCode(auth.ctx, email, code);
if (checked === "too_many_attempts") {
return auth.fail(checked, { status: 429 });
}
if (checked !== "ok") return auth.fail(checked);
return auth.proveIdentity({ email, emailVerified: true });
},
},
},
}),
],
});
  • name is a kebab-case slug, and the method’s routes mount under /_plumix/auth/email-code/. Core’s own device, signout and verify-email routes are names no method may take, and so is any name whose camelCase form is one of authClient’s own keys: account, sessions, can, useAccount, useCan, signIn, signOut, useSession, useAuthMethods and url. Either fails the boot, naming the clash, and so do two plugins declaring one name, naming both.
  • label is what authClient.useAuthMethods() lists. listed: false keeps the method out of that list. This one is unlisted because the admin’s login screen offers every listed plugin method as a “Continue with” button that calls signIn() with no arguments, and this method needs an address and a code.
  • errorCodes are the codes the handlers pass to fail, besides the identity refusals core adds. Passing any other code is a type error.
  • routes are keyed by path under the mount. A :param segment matches any one segment, and the handler reads it from params.
  • client names the browser half: an absolute path, as here, or a package subpath resolved from the site root, such as "@acme/plumix-otp/client".

Install the plugin in plumix.config.ts like any other, and authClient.signIn.emailCode type-checks in the theme.

Each handler receives the kernel, bound to the request:

Member What it is
request The request, with the base path stripped.
ctx The request’s AppContext: db, mail, logger and the rest.
params The route’s :param segments.
loginPath The site’s loginPath, with the base path.
readInput() A fetch’s JSON body, or a form post’s string fields. null when the body doesn’t parse.
readRedirectTo(value?) Checks value, or the request’s ?redirectTo=. undefined when absent or empty, the site root when unsafe.
proveIdentity(proof, options?) Signs the proven account in and answers with its session cookie, or refuses with a 403.
fail(code, { status? }) A failure. status defaults to 400.
notice(name, body?) An outcome that isn’t a sign-in, such as “code sent”. body is what a fetch gets, { ok: true } when left out.

proveIdentity takes one of two proofs:

  • An email proof, { email, emailVerified }, for a method that proved an address. emailVerified is true only when the method proved the caller owns the inbox, as a code mailed there does. It signs in the account with that address, or creates one where the site’s sign-up rules allow. It can also carry providerAccount: { provider, accountId }, which links an external account so it signs its user in whatever address it reports later, and a profile with a name and avatarUrl, used only when the account is created.
  • An account proof, { userId }, for a credential bound to one existing account, such as a passkey. It never creates an account.

The second argument takes redirectTo, a value the method carried itself, such as through OAuth state, in place of the request’s ?redirectTo=. It also takes afterSession(user), which runs once the session exists and before auth.signedIn is emitted, and for an account proof, firstSignIn.

The kernel picks the shape of every answer from how the caller arrived. A handler returns what the kernel built and never shapes a response itself.

Answer A fetch with X-Plumix-Request A navigation (GET) A form post (POST)
proveIdentity { userId, redirectTo } and the session cookie 302 to redirectTo, with the cookie 303 to redirectTo, with the cookie
fail { error: code } with the status 302 to loginPath?<key>_error=<code> 303 to loginPath?<key>_error=<code>
notice body, with 200 302 to loginPath?<key>_<name>=1 303 to loginPath?<key>_<name>=1

A method’s key is its name with - replaced by _, so the email-code method’s wrong code lands on ?email_code_error=wrong_code. With no redirectTo at all, a sign-in is sent to the admin.

Every POST to a method’s route requires the X-Plumix-Request header, which a cross-site page can’t send, so a forged request never reaches the handler. authClient sends it on every call. A GET needs no header, which is what lets a navigation reach a route, so a GET route must change nothing a forged link shouldn’t.

A plain HTML form can’t send it either. A route that a form with no script should reach declares formPost: true. It then also accepts a POST without the header, as long as it comes from the site’s own origin, and that request runs with no signed-in visitor, so a forged form can’t act as whoever is signed in. Both email-code routes declare it, which is what lets a form with no script sign someone in.

The kernel has no rate limiter. How to throttle depends on the deploy’s edge, its storage and its threat model, so a method that needs a limit owns it, or the site in front of it does. A six-digit code needs one: without a cap, a script could mail an address a thousand codes, or guess a code in a million tries.

The email-code method caps both by counting rows of its own, which works on any deploy with no extra binding:

auth/email-code/codes.ts
import type { AppContext } from "plumix/plugin";
import { and, count, desc, eq, gte } from "plumix/db";
import { emailCodeMisses, emailCodes } from "./schema.js";
/** How long a code stays good, and the window the per-email cap counts. */
export const CODE_TTL_MINUTES = 10;
const CODE_TTL_MS = CODE_TTL_MINUTES * 60 * 1000;
const MAX_CODES_PER_EMAIL = 3;
const MAX_MISSES_PER_CODE = 5;
export type CodeCheck =
"ok" | "code_expired" | "wrong_code" | "too_many_attempts";
const ENCODER = new TextEncoder();
async function hash(code: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", ENCODER.encode(code));
return [...new Uint8Array(digest)]
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
}
const CODE_SPACE = 1_000_000;
/** Draws below the last whole multiple of the code space, so no code is likelier. */
const UNBIASED_LIMIT = 2 ** 32 - (2 ** 32 % CODE_SPACE);
function randomCode(): string {
const [random = 0] = crypto.getRandomValues(new Uint32Array(1));
if (random >= UNBIASED_LIMIT) return randomCode();
return String(random % CODE_SPACE).padStart(6, "0");
}
function liveCodes(email: string) {
const windowStart = new Date(Date.now() - CODE_TTL_MS);
return and(
eq(emailCodes.email, email),
gte(emailCodes.createdAt, windowStart),
);
}
/**
* A fresh six-digit code, or `null` once `email` has its cap this window.
* The kernel has no rate limiter, so the method counts its own rows.
*/
export async function issueCode(
ctx: AppContext,
email: string,
): Promise<string | null> {
const [sent] = await ctx.db
.select({ value: count() })
.from(emailCodes)
.where(liveCodes(email));
if ((sent?.value ?? 0) >= MAX_CODES_PER_EMAIL) return null;
const code = randomCode();
await ctx.db.insert(emailCodes).values({ email, codeHash: await hash(code) });
return code;
}
/** A match deletes every code the address holds, so a code can't be used twice. */
export async function checkCode(
ctx: AppContext,
email: string,
code: string,
): Promise<CodeCheck> {
const [latest] = await ctx.db
.select()
.from(emailCodes)
.where(liveCodes(email))
.orderBy(desc(emailCodes.id))
.limit(1);
if (latest === undefined) return "code_expired";
const [misses] = await ctx.db
.select({ value: count() })
.from(emailCodeMisses)
.where(eq(emailCodeMisses.codeId, latest.id));
if ((misses?.value ?? 0) >= MAX_MISSES_PER_CODE) return "too_many_attempts";
if ((await hash(code)) !== latest.codeHash) {
await ctx.db.insert(emailCodeMisses).values({ codeId: latest.id });
return "wrong_code";
}
await ctx.db.delete(emailCodes).where(eq(emailCodes.email, email));
return "ok";
}

Each cap counts rows and then inserts, with no transaction around the two, so a burst of concurrent requests can each pass the count and go over it by a few. A verified code deletes every code the address holds, so a successful sign-in also resets the per-address cap. Both are fine for this method’s threat, since a guess still needs the one code mailed last, but a method that must hold a hard limit needs storage that counts atomically.

The rows live in two tables of the method’s own:

auth/email-code/schema.ts
import { sql } from "drizzle-orm";
import { index, sqliteTable } from "drizzle-orm/sqlite-core";
/** One row per code sent. Only the code's hash is stored. */
export const emailCodes = sqliteTable(
"email_codes",
(t) => ({
id: t.integer().primaryKey({ autoIncrement: true }),
email: t.text().notNull(),
codeHash: t.text().notNull(),
createdAt: t
.integer({ mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
}),
(table) => [
index("email_codes_email_created_idx").on(table.email, table.createdAt),
],
);
/** One row per wrong guess at a code, deleted with the code. */
export const emailCodeMisses = sqliteTable(
"email_code_misses",
(t) => ({
id: t.integer().primaryKey({ autoIncrement: true }),
codeId: t
.integer()
.notNull()
.references(() => emailCodes.id, { onDelete: "cascade" }),
}),
(table) => [index("email_code_misses_code_idx").on(table.codeId)],
);

This plugin lives in the site’s folder, so its tables are the site’s, and plumix migrate generate writes their history to the site’s migrations/. A method published as a package ships its own history instead, as Plugins that own tables describes.

The browser half gets a kernel bound to the method’s mount, and returns its actions:

auth/email-code/client.ts
import type { RedirectOptions, SignedIn } from "plumix/auth/client";
import { defineAuthMethodClient } from "plumix/auth/client";
/** What the `request` route's `notice("sent")` answers a fetch with. */
interface CodeSent {
readonly ok: true;
}
const emailCodeClient = defineAuthMethodClient("email-code", (kernel) => ({
sendCode: ({ email }: { readonly email: string }) =>
kernel.post<CodeSent>("request", { email }),
signIn: ({
email,
code,
redirectTo,
}: { readonly email: string; readonly code: string } & RedirectOptions) =>
kernel.post<SignedIn>("verify", { email, code }, { redirectTo }),
}));
export default emailCodeClient;
Kernel member What it does
post<T>(route, body?, options) POSTs body as JSON with the CSRF header. A sign-in answer refreshes the session, and navigates to the server’s redirectTo only when the call passed one.
rpc<T>(procedure, input?) Calls an RPC procedure, such as auth/credentials/list, as the session’s user, and resolves its answer. A refusal resolves the code the auth client maps it to.
url(route?, options) The URL of route under the mount, with redirectTo on its query.
navigate(url) A full-page navigation, for a flow that leaves the site, such as an OAuth consent screen.
fail(code) A failure the browser hit itself, such as a cancelled prompt.

signIn appears as authClient.signIn.emailCode, and every other action under authClient.emailCode, so sendCode is authClient.emailCode.sendCode. Every action resolves { data, error }. Keep it so: a theme handles your method’s failures the way it handles core’s.

The module must be browser-safe, because it ships to the browser. Plumix’s Vite plugin reads every client from the loaded config and mounts each half before an island renders, and in the admin too. Nothing else registers it.

authClient learns about the method through AuthMethodRegistry, augmented with the browser half’s type from the plugin’s main entry, as the declare module "plumix" block in index.ts does. The theme sees it because plumix.config.ts imports the plugin. No .d.ts is generated, so a typecheck needs no dev server or build first. The key is the method’s kebab-case name, and a call to a method no plugin registered is a type error.

The method proves who the caller is, and core keeps every other rule:

  • Who may have an account. A new address needs one of the site’s allowed domains, or an open selfSignup. Otherwise the answer is domain_not_allowed. An email proof with emailVerified: false signs no one in and creates nothing, email_unverified.
  • The first account. While the site has no users, a method creates the first one only where bootstrap is allowed. Otherwise the answer is registration_closed.
  • Disabled accounts, account_disabled, and an account proof whose user is gone, link_broken.
  • The session, its cookie and the auth.signedIn event.
  • Where the visitor lands. redirectTo is kept only when it is a root-relative path inside the base path, and sent to the site root otherwise.

A refusal answers like a fail with a 403, so the theme shows it next to the method’s own codes.

The dispatcher harness boots the plugin against an in-memory database, and h.mail records the mail it sends. Apply the method’s tables first:

auth/email-code/email-code.test.ts
import { fileURLToPath } from "node:url";
import type { DispatcherHarness } from "plumix/test";
import {
applyTestSchema,
createDispatcherHarness,
createTestDb,
} from "plumix/test";
import { afterEach, expect, test, vi } from "vitest";
import { emailCode } from "./index.js";
/** The playground's own history, which holds the method's tables. */
const SITE_MIGRATIONS = fileURLToPath(
new URL("../../migrations", import.meta.url),
);
const EMAIL = "ann@example.test";
async function harness(): Promise<DispatcherHarness> {
const db = await createTestDb();
await applyTestSchema(db, SITE_MIGRATIONS);
return createDispatcherHarness({ db, config: { plugins: [emailCode] } });
}
/** The code is random; pinning it to 123456 gives a right and a wrong guess. */
function pinSentCode(): void {
vi.spyOn(crypto, "getRandomValues").mockImplementation((array) => {
(array as Uint32Array)[0] = 123456;
return array;
});
}
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
function requestCode(h: DispatcherHarness) {
return h.fetch("/_plumix/auth/email-code/request", {
method: "POST",
json: { email: EMAIL },
});
}
function verify(h: DispatcherHarness, code: string) {
return h.fetch("/_plumix/auth/email-code/verify", {
method: "POST",
json: { email: EMAIL, code },
});
}
/** Draws each value in turn, one per `getRandomValues` call. */
function drawRandom(...values: number[]): void {
const spy = vi.spyOn(crypto, "getRandomValues");
for (const value of values) {
spy.mockImplementationOnce((array) => {
(array as Uint32Array)[0] = value;
return array;
});
}
}
test("redraws a random value from the range modulo would skew", async () => {
drawRandom(0xff_ff_ff_ff, 123456);
const h = await harness();
(await requestCode(h)).assertStatus(200);
h.mail.assertSent("emailCode", {
to: EMAIL,
where: (props) => props.code === "123456",
});
});
test("refuses a fourth code to one address within ten minutes", async () => {
const h = await harness();
(await requestCode(h)).assertStatus(200);
(await requestCode(h)).assertStatus(200);
(await requestCode(h)).assertStatus(200);
const res = await requestCode(h);
res.assertStatus(429);
expect(await res.json()).toEqual({ error: "too_many_codes" });
h.mail.assertSent("emailCode", { to: EMAIL, times: 3 });
});
test("refuses even the right code after five wrong guesses at it", async () => {
pinSentCode();
const h = await harness();
(await requestCode(h)).assertStatus(200);
(await verify(h, "654321")).assertStatus(400);
(await verify(h, "654321")).assertStatus(400);
(await verify(h, "654321")).assertStatus(400);
(await verify(h, "654321")).assertStatus(400);
(await verify(h, "654321")).assertStatus(400);
const res = await verify(h, "123456");
res.assertStatus(429);
expect(await res.json()).toEqual({ error: "too_many_attempts" });
});
test("refuses the right code once ten minutes have passed", async () => {
pinSentCode();
const h = await harness();
(await requestCode(h)).assertStatus(200);
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 11 * 60 * 1000 });
const res = await verify(h, "123456");
res.assertStatus(400);
expect(await res.json()).toEqual({ error: "code_expired" });
});

Testing a Plugin covers the harness and the mail fake. Signing in through a theme’s page is an end-to-end test: the playground’s suite drives the pages from Sign-in in Your Theme, with script and without.

ADR 0041 records why a method only proves and core decides. Passkeys covers one of core’s methods, which run through the same kernel. Mail covers defineMail and ctx.mail.send.

Build the pages that call the method in Sign-in in Your Theme.