sdxc

Type to search, or start from one of these:

[ Identity & security ]

Add two-factor sign-in with TOTP

Enroll an authenticator app, ask for its code after the password, spend each code once, back it up with recovery codes, and turn it off.

Last updated 2026-09-30

A time-based one-time password, the six digits an authenticator app shows, is a second thing an attacker needs after they have guessed or phished a password. This guide adds the whole flow to a Remix v3 app on Workers: enrolling an app and confirming its first code, asking for a code after the password step, refusing a code that has already been used, recovery codes for a lost phone, and turning two-factor off.

@sdxc/crypto generates and checks the codes (RFC 6238), seals the shared secret with AES-GCM and mints the recovery codes. @sdxc/auth reads the session the sign-in is held in, @sdxc/validate checks the submitted code, and @sdxc/result and @sdxc/http carry the outcomes.

npm add @sdxc/crypto @sdxc/auth @sdxc/validate @sdxc/result @sdxc/http

Three tables

A user has at most one factor. Its secret is stored sealed, and confirmed_at stays null until the user proves their app holds it. Codes already accepted and recovery codes each get a table whose primary key does the work, as the sections below show:

database/two-factor.ts
import { column as c, table } from "remix/data-table";

export const totpFactors = table({
	name: "totp_factors",
	primaryKey: ["user_id"],
	columns: {
		user_id: c.text(),
		sealed_secret: c.text(),
		confirmed_at: c.integer().nullable(),
	},
});

export const usedTotpCodes = table({
	name: "used_totp_codes",
	primaryKey: ["user_id", "code"],
	columns: { user_id: c.text(), code: c.text(), used_at: c.integer() },
});

export const recoveryCodes = table({
	name: "recovery_codes",
	primaryKey: ["user_id", "code_hash"],
	columns: { user_id: c.text(), code_hash: c.text() },
});

The secret is a key to the account, and it has to be read back to check every code, so it is sealed rather than hashed. sealSecret and openSecret are the two functions of the sealing module in Hash, sign and encrypt with Web Crypto, which import your SEAL_KEY once and wrap seal and open.

Start enrollment

Enrollment mints a secret, seals it straight away and returns it in the two forms an authenticator app takes: an otpauth:// URI to scan, and the setup key to type:

app/services/two-factor/enroll.ts
import type { Database } from "remix/data-table";

import { totp } from "@sdxc/crypto";
import { failure, isFailure, success } from "@sdxc/result";

import { sealSecret } from "~/app/services/sealing";
import { totpFactors } from "~/database/two-factor";

export async function beginEnrollment(
	db: Database,
	user: { id: string; email: string },
) {
	let existing = await db.find(totpFactors, { user_id: user.id });
	if (existing?.confirmed_at) return failure(new Error("Two-factor is already on"));

	let secret = totp.generateSecret();
	let sealed = await sealSecret(secret);
	if (isFailure(sealed)) return sealed;

	if (existing) await db.delete(totpFactors, { user_id: user.id });
	await db.create(totpFactors, {
		user_id: user.id,
		sealed_secret: sealed.data,
		confirmed_at: null,
	});

	let uri = totp.uri(secret, { issuer: "Invoices", account: user.email });
	return success({ uri, setupKey: secret });
}

generateSecret returns 20 random bytes as uppercase base32, the encoding authenticator apps accept. uri names the entry the app shows, here "Invoices: ada@example.com", and writes the defaults into the URI: SHA-1, six digits and a 30-second step, which is what those apps assume. Starting over replaces an unconfirmed secret, but never an active factor: turning it off comes first.

The setup route is a POST, since it writes, and renders the page:

app/http/controllers/two-factor/setup.tsx
import { redirect } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";

import { currentUser } from "~/app/auth/current-user";
import { qrCodeDataUrl } from "~/app/lib/qr-code";
import { beginEnrollment } from "~/app/services/two-factor/enroll";
import { EnrollTwoFactorPage } from "~/resources/views/enroll-two-factor";
import routes from "~/routes/web";

export default createAction(routes.twoFactor.setup, async (ctx) => {
	let started = await beginEnrollment(ctx.db, currentUser(ctx));
	if (isFailure(started)) {
		ctx.log.warn("two_factor.setup_refused", { reason: started.error.message });
		return redirect(routes.account.href());
	}

	let { uri, setupKey } = started.data;
	let qrCode = await qrCodeDataUrl(uri);
	return ctx.render(
		<EnrollTwoFactorPage uri={uri} setupKey={setupKey} qrCode={qrCode} />,
	);
});

currentUser is your own lookup of the signed-in account, behind whatever middleware protects the route. No @sdxc package draws QR codes, so qrCodeDataUrl is a small wrapper around the QR library of your choice that turns the URI into an SVG or PNG data URL. The page shows the image, the setup key for anyone who cannot scan, and the URI as a link, which opens the app directly on a phone:

resources/views/enroll-two-factor.tsx
import type { Handle } from "remix/ui";

import { Button, TextField } from "@sdxc/ui";

import routes from "~/routes/web";

interface EnrollTwoFactorProps {
	uri: string;
	setupKey: string;
	qrCode: string;
}

export function EnrollTwoFactorPage(handle: Handle<EnrollTwoFactorProps>) {
	return () => {
		let { uri, setupKey, qrCode } = handle.props;
		return (
			<form method="post" action={routes.twoFactor.confirm.href()}>
				<img
					src={qrCode}
					alt="QR code for your authenticator app"
					width={200}
				/>
				<p>
					Can't scan it? <a href={uri}>Open it in your app</a> or enter the
					key <code>{setupKey}</code>.
				</p>
				<TextField
					label="Code from the app"
					name="code"
					autoComplete="one-time-code"
				/>
				<Button type="submit">Turn on two-factor</Button>
			</form>
		);
	};
}

The setup key is the secret itself. Render it on this one response, and never again.

Accept each code once

Checking a code opens the secret and verifies with a window of one step either side, which tolerates a phone clock that is a little off. That window keeps a code valid for up to 90 seconds, so a code read over someone's shoulder or lifted from a shared screen works a second time unless you remember it:

app/services/two-factor/accept-code.ts
import type { Database } from "remix/data-table";

import { totp } from "@sdxc/crypto";
import { isFailure, isSuccess, wrap } from "@sdxc/result";

import { openSecret } from "~/app/services/sealing";
import { usedTotpCodes } from "~/database/two-factor";

export async function acceptCode(
	db: Database,
	userId: string,
	sealedSecret: string,
	code: string,
) {
	let secret = await openSecret(sealedSecret);
	if (isFailure(secret)) return false;

	let valid = await totp.verify(secret.data, code, { window: 1 });
	if (isFailure(valid) || !valid.data) return false;

	let claimed = await wrap(() =>
		db.create(usedTotpCodes, { user_id: userId, code, used_at: Date.now() }),
	);
	return isSuccess(claimed);
}

totp.verify evaluates every step in the window, even after a match, and compares each in constant time, so neither the time taken nor the work done reveals which step matched. A code of the wrong shape is a plain mismatch, success(false). The replay guard is the insert: (user_id, code) is the primary key, so a second use of the same code violates it and the claim fails. That is one statement, so it holds on D1 even when two submissions race, and any database failure fails closed. Delete rows older than a few minutes from a cron job, as in Background jobs and cron; nothing older than the window can verify anyway.

Recovery codes

A user who loses their phone needs another way past the second step. Ten single-use recovery codes, shown once when the factor is turned on, are that way. Store only their digests, like any other credential:

app/services/two-factor/recovery-codes.ts
import type { Database } from "remix/data-table";

import { Base32, Hex, randomBytes, sha256 } from "@sdxc/crypto";
import { failure, isFailure, success } from "@sdxc/result";

import { recoveryCodes } from "~/database/two-factor";

async function digest(code: string) {
	let hashed = await sha256(code.replaceAll(/[\s-]/g, "").toUpperCase());
	return isFailure(hashed) ? null : Hex.encode(hashed.data);
}

export async function mintRecoveryCodes(db: Database, userId: string) {
	let codes = Array.from({ length: 10 }, () => Base32.encode(randomBytes(10)));
	let rows = [];
	for (let code of codes) {
		let codeHash = await digest(code);
		if (codeHash === null)
			return failure(new Error("Could not hash a recovery code"));
		rows.push({ user_id: userId, code_hash: codeHash });
	}

	await db.deleteMany(recoveryCodes, { where: { user_id: userId } });
	await db.createMany(recoveryCodes, rows);
	return success(codes.map((code) => code.replace(/(.{4})(?!$)/g, "$1-")));
}

export async function spendRecoveryCode(db: Database, userId: string, code: string) {
	let codeHash = await digest(code);
	if (codeHash === null) return false;
	return await db.delete(recoveryCodes, { user_id: userId, code_hash: codeHash });
}

Each code is 10 random bytes, 80 bits, which no guessing budget behind a rate limit gets near. They use Base32 over randomBytes rather than randomToken, whose base64url suits tokens a browser carries: a person types these, and base32 has one case and no 0 or 1. Shown as ABCD-EFGH-JKLM-NPQR, a code is folded back before hashing, so hyphens, spaces and case never matter. A plain sha256 is enough, as for the reset tokens in the Web Crypto guide, because the codes carry their own entropy.

Spending is db.delete, which answers whether a row was removed. A code works exactly once, in one statement, with no read before the write for a second request to slip between. Minting replaces the whole set, so offer "regenerate recovery codes" by calling it again.

Confirm the first code

The factor turns on only once the user submits a code that verifies, which proves their app holds the secret. That same moment mints the recovery codes:

app/services/two-factor/confirm.ts
import type { Database } from "remix/data-table";

import { failure } from "@sdxc/result";

import { acceptCode } from "~/app/services/two-factor/accept-code";
import { mintRecoveryCodes } from "~/app/services/two-factor/recovery-codes";
import { totpFactors } from "~/database/two-factor";

export async function confirmEnrollment(db: Database, userId: string, code: string) {
	let factor = await db.find(totpFactors, { user_id: userId });
	if (factor === null || factor.confirmed_at !== null) {
		return failure(new Error("No setup in progress"));
	}

	if (!(await acceptCode(db, userId, factor.sealed_secret, code))) {
		return failure(new Error("Wrong code"));
	}

	await db.update(totpFactors, { user_id: userId }, { confirmed_at: Date.now() });
	return await mintRecoveryCodes(db, userId);
}

The code form is shared by every step that asks for one:

app/http/validators/two-factor.ts
import * as s from "remix/data-schema";
import { maxLength, minLength } from "remix/data-schema/checks";

export const CODE_FORM = s.object({
	code: s.string().pipe(minLength(6), maxLength(24)),
});

The confirm route shows the recovery codes on success. A wrong code sends the user back to start again, which mints a fresh secret:

app/http/controllers/two-factor/confirm.tsx
import { redirect } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import { createAction } from "remix/router";

import { currentUser } from "~/app/auth/current-user";
import { CODE_FORM } from "~/app/http/validators/two-factor";
import { confirmEnrollment } from "~/app/services/two-factor/confirm";
import { RecoveryCodesPage } from "~/resources/views/recovery-codes";
import routes from "~/routes/web";

export default createAction(routes.twoFactor.confirm, async (ctx) => {
	let form = await validate(ctx.formData, CODE_FORM);
	if (isFailure(form)) return redirect(routes.account.href());

	let codes = await confirmEnrollment(ctx.db, currentUser(ctx).id, form.data.code);
	if (isFailure(codes)) return redirect(routes.account.href());

	return ctx.render(<RecoveryCodesPage codes={codes.data} />);
});

Ask for the code after the password

The password step no longer signs the user in when they have a factor. It holds the sign-in in the session for a few minutes and sends the browser to the code page:

app/auth/pending-sign-in.ts
import type { RequestContext } from "remix/router";

import { sessionOf } from "@sdxc/auth/remix/context";
import * as s from "remix/data-schema";

const PENDING_KEY = "two-factor:pending";
const PENDING = s.object({ userId: s.string(), until: s.number() });

export function holdSignIn(ctx: RequestContext, userId: string) {
	sessionOf(ctx).set(PENDING_KEY, { userId, until: Date.now() + 5 * 60 * 1000 });
}

export function pendingUserId(ctx: RequestContext): string | null {
	let pending = s.parseSafe(PENDING, sessionOf(ctx).get(PENDING_KEY));
	if (!pending.success || pending.value.until < Date.now()) return null;
	return pending.value.userId;
}

export function completeSignIn(ctx: RequestContext, userId: string) {
	let session = sessionOf(ctx);
	session.unset(PENDING_KEY);
	session.regenerateId?.(true);
	session.set("userId", userId);
}

sessionOf reads the session Remix's session middleware put on the context. In your password action, once the password checks out, look the factor up: when it is confirmed, call holdSignIn(ctx, user.id) and redirect to the code page; otherwise call completeSignIn directly. The pending entry is not a signed-in session, since nothing reads userId from it, and it expires, so a password alone never gets further than the code page.

The code page accepts a current code or a recovery code:

app/services/two-factor/check.ts
import type { Database } from "remix/data-table";

import { acceptCode } from "~/app/services/two-factor/accept-code";
import { spendRecoveryCode } from "~/app/services/two-factor/recovery-codes";
import { totpFactors } from "~/database/two-factor";

export async function checkSecondFactor(db: Database, userId: string, code: string) {
	let factor = await db.find(totpFactors, { user_id: userId });
	if (factor === null || factor.confirmed_at === null) return false;

	if (await acceptCode(db, userId, factor.sealed_secret, code)) return true;
	return await spendRecoveryCode(db, userId, code);
}
app/http/controllers/sign-in/two-factor.tsx
import { redirect } from "@sdxc/http/response";
import { isFailure } from "@sdxc/result";
import { validate } from "@sdxc/validate";
import { createAction } from "remix/router";

import { completeSignIn, pendingUserId } from "~/app/auth/pending-sign-in";
import { CODE_FORM } from "~/app/http/validators/two-factor";
import { checkSecondFactor } from "~/app/services/two-factor/check";
import { TwoFactorPage } from "~/resources/views/two-factor";
import routes from "~/routes/web";

export default createAction(routes.signIn.twoFactor, async (ctx) => {
	let userId = pendingUserId(ctx);
	if (userId === null) return redirect(routes.signIn.index.href());

	let form = await validate(ctx.formData, CODE_FORM);
	let passed =
		!isFailure(form) && (await checkSecondFactor(ctx.db, userId, form.data.code));
	if (!passed) {
		ctx.log.note("two_factor.refused", { user: userId });
		return ctx.render(<TwoFactorPage error="That code didn't work." />, {
			status: 400,
		});
	}

	completeSignIn(ctx, userId);
	return redirect(routes.dashboard.href(), { status: redirect.Status.SeeOther });
});

A wrong code, a used code and an unknown recovery code all answer the same page, so the response never says which one it was. Six digits are a million possibilities, and the window accepts three at a time, so put a rate limit on this route keyed on the pending user, as in Protect forms from bots and abuse. Regenerating the session id on the way in keeps the id the browser held during the password step from becoming the signed-in one.

Turn two-factor off

Turning the factor off should take the same proof as signing in with it, so a session left open on a shared computer is not enough to remove it:

app/services/two-factor/disable.ts
import type { Database } from "remix/data-table";

import { checkSecondFactor } from "~/app/services/two-factor/check";
import { recoveryCodes, totpFactors } from "~/database/two-factor";

export async function disableTwoFactor(db: Database, userId: string, code: string) {
	if (!(await checkSecondFactor(db, userId, code))) return false;

	await db.delete(totpFactors, { user_id: userId });
	await db.deleteMany(recoveryCodes, { where: { user_id: userId } });
	return true;
}

The route in front of it validates CODE_FORM and passes the signed-in user's id and the code. Removing the factor removes its recovery codes with it, since they exist only to stand in for it. Tell the user by email when it happens, as in Send email: if it was not them, they learn while they can still act.

Where to go next