diff --git a/.server-changes/mfa-required.md b/.server-changes/mfa-required.md
new file mode 100644
index 00000000000..6ac90cd3f01
--- /dev/null
+++ b/.server-changes/mfa-required.md
@@ -0,0 +1,6 @@
+---
+area: webapp
+type: feature
+---
+
+Self-hosted instances can set `MFA_REQUIRED` to make multi-factor authentication mandatory for every dashboard user. Until a user has enrolled a second factor they're taken to a setup page instead of the dashboard, and once enrolled they can no longer turn MFA off. Off by default.
diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts
index 729a2331e89..ea2cc05dd21 100644
--- a/apps/webapp/app/env.server.ts
+++ b/apps/webapp/app/env.server.ts
@@ -400,6 +400,11 @@ const EnvironmentSchema = z
WEBAPP_TELNET_LOGS_PORT: z.coerce.number().optional(),
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
+ // Opt-in for self-hosted instances under a compliance program (FedRAMP,
+ // SOC 2, HIPAA): require every dashboard user to enroll a second factor
+ // (TOTP) before they can use the dashboard, and prevent them from
+ // disabling it afterwards. Off by default.
+ MFA_REQUIRED: BoolEnv.default(false),
APP_ORIGIN: z.string().default("http://localhost:3030"),
// Extra exact origins (comma separated) added to the document `img-src` CSP,
// e.g. an SSO host serving profile images. Wildcards are refused.
diff --git a/apps/webapp/app/root.tsx b/apps/webapp/app/root.tsx
index 2939faf0958..79f665e49a2 100644
--- a/apps/webapp/app/root.tsx
+++ b/apps/webapp/app/root.tsx
@@ -23,6 +23,7 @@ import { usePostHog } from "./hooks/usePostHog";
import { resolveThemePreference, useSystemThemeSync } from "./hooks/useSystemThemeSync";
import { clearImpersonation } from "./models/admin.server";
import { getImpersonationState, getRawImpersonationId } from "./services/impersonation.server";
+import { throwIfMfaRequired } from "./services/mfa/mfaRequirement.server";
import { getUser } from "./services/session.server";
import {
normalizeIconContrast,
@@ -81,6 +82,18 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const timezone = await getTimezonePreference(request);
const user = await getUser(request);
+ // `MFA_REQUIRED`: refuse every non-exempt request from a signed-in user who
+ // has not enrolled a second factor, sending them to the setup page instead.
+ //
+ // `requireUser` applies the same check, but it only guards the routes that
+ // call it — this covers routes that authenticate with `requireUserId`
+ // (which stays cookie-only, with no user row to inspect). Action-only
+ // resource routes skip the root loader entirely, so they are gated only
+ // where they call `requireUser`; the MFA enrollment endpoint is exempt by
+ // design, since it is the only way to enroll.
+ //
+ // Logged-out requests are untouched — they take the normal login flow.
+ throwIfMfaRequired(request, user);
// Feature-flagged; while off everyone stays on Dark at contrast 0. Admins
// always get it. Cached: this loader runs on every request and navigation.
const showThemeSwitcher = user
diff --git a/apps/webapp/app/routes/_app/route.tsx b/apps/webapp/app/routes/_app/route.tsx
index e1acc65f27e..8691bc88576 100644
--- a/apps/webapp/app/routes/_app/route.tsx
+++ b/apps/webapp/app/routes/_app/route.tsx
@@ -9,6 +9,11 @@ import { tenantContext } from "~/services/tenantContext.server";
import { confirmBasicDetailsPath } from "~/utils/pathBuilder";
export const loader = async ({ request }: LoaderFunctionArgs) => {
+ // `requireUser` also enforces `MFA_REQUIRED`: a signed-in user without a
+ // second factor is redirected to /mfa/setup before this loader gets any
+ // further. That check lives there rather than here so it also covers the
+ // routes not nested under this layout (/account/*, /invites, /admin/*) —
+ // see services/mfa/mfaRequirement.server.ts.
const user = await requireUser(request);
tenantContext.enrich({ userId: user.id });
diff --git a/apps/webapp/app/routes/account.security/route.tsx b/apps/webapp/app/routes/account.security/route.tsx
index 62f08f6e08b..857b818a923 100644
--- a/apps/webapp/app/routes/account.security/route.tsx
+++ b/apps/webapp/app/routes/account.security/route.tsx
@@ -8,6 +8,7 @@ import {
import { Header2 } from "~/components/primitives/Headers";
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
import { $replica } from "~/db.server";
+import { env } from "~/env.server";
import { requireUser } from "~/services/session.server";
import {
getAllowedSessionOptions,
@@ -49,7 +50,12 @@ export default function Page() {
Security
-
+ {/*
+ Under MFA_REQUIRED every signed-in user is gated to the setup
+ page until they enroll, so this page is only reachable with MFA
+ already on — the toggle is hidden and there is nothing to disable.
+ */}
+
+
+ You'll scan a QR code with an app like Google Authenticator, 1Password or Authy, then enter
+ the 6-digit code it generates to prove it's set up correctly.
+
+
+
+ Multi-factor authentication is on. Copy and store these recovery codes somewhere safe —
+ they're the only way back in if you lose your device. Each code works once.
+
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx
index 7c1f4893d7f..030e2b2b370 100644
--- a/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx
+++ b/apps/webapp/app/routes/resources.account.mfa.setup/route.tsx
@@ -1,6 +1,7 @@
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
import { typedjson } from "remix-typedjson";
import { z } from "zod";
+import { env } from "~/env.server";
import {
redirectWithSuccessMessage,
redirectWithErrorMessage,
@@ -10,6 +11,8 @@ import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuth
import { requireUserId } from "~/services/session.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { MfaRateLimitError } from "~/services/mfa/mfaRateLimiterGlobal.server";
+import { sanitizeRedirectPath } from "~/utils";
+import { accountSecurityPath, mfaSetupPath, rootPath } from "~/utils/pathBuilder";
import { useMfaSetup } from "./useMfaSetup";
import { MfaToggle } from "./MfaToggle";
import { MfaSetupDialog } from "./MfaSetupDialog";
@@ -26,6 +29,10 @@ const formSchema = z.discriminatedUnion("action", [
}),
z.object({
action: z.literal("saved-recovery-codes"),
+ // Where to send the user once enrollment is complete. Supplied by the
+ // forced-setup page so a user who was bounced there mid-navigation lands
+ // back where they were headed.
+ redirectTo: z.string().optional(),
}),
z.object({
action: z.literal("cancel-totp"),
@@ -36,6 +43,36 @@ const formSchema = z.discriminatedUnion("action", [
}),
]);
+// Where enrollment sends the user when they did not arrive via the forced
+// setup page (i.e. they enabled MFA voluntarily from Account → Security).
+const DEFAULT_ENROLLMENT_REDIRECT = accountSecurityPath();
+
+/**
+ * Resolves the post-enrollment destination.
+ *
+ * `redirectTo` is user-influenced (it round-trips through the browser), so it
+ * goes through the same sanitizer the login flow uses: it must be a relative,
+ * navigable path, otherwise we fall back to the security page.
+ */
+function enrollmentRedirectPath(redirectTo: string | undefined): string {
+ if (env.MFA_REQUIRED) {
+ return sanitizeRedirectPath(redirectTo, rootPath());
+ }
+
+ return sanitizeRedirectPath(redirectTo, DEFAULT_ENROLLMENT_REDIRECT);
+}
+
+/**
+ * Where to send the user when enrollment fails with an error.
+ *
+ * Under `MFA_REQUIRED` they have nowhere else to go — every other dashboard
+ * path bounces them straight back here — so send them to the setup page
+ * directly rather than making them round-trip through the gate.
+ */
+function enrollmentErrorRedirectPath(): string {
+ return env.MFA_REQUIRED ? mfaSetupPath() : DEFAULT_ENROLLMENT_REDIRECT;
+}
+
function validateForm(formData: FormData) {
const formEntries = Object.fromEntries(formData.entries());
@@ -82,6 +119,18 @@ export async function action({ request }: ActionFunctionArgs) {
});
}
case "disable-mfa": {
+ // Refused here too (the service refuses it independently) so the
+ // client gets a clean error rather than an unexpected redirect when
+ // the instance requires MFA.
+ if (env.MFA_REQUIRED) {
+ return typedjson({
+ action: "disable-mfa" as const,
+ success: false as const,
+ error:
+ "Multi-factor authentication is required on this instance and cannot be disabled.",
+ });
+ }
+
const result = await mfaSetupService.disableTotp(userId, {
totpCode: submission.data.totpCode,
recoveryCode: submission.data.recoveryCode,
@@ -130,17 +179,21 @@ export async function action({ request }: ActionFunctionArgs) {
});
}
case "saved-recovery-codes": {
- return redirectWithSuccessMessage("/account/security", request, "Successfully enabled MFA");
+ return redirectWithSuccessMessage(
+ enrollmentRedirectPath(submission.data.redirectTo),
+ request,
+ "Successfully enabled MFA"
+ );
}
}
} catch (error) {
if (error instanceof ServiceValidationError) {
- return redirectWithErrorMessage("/account/security", request, error.message);
+ return redirectWithErrorMessage(enrollmentErrorRedirectPath(), request, error.message);
}
if (error instanceof MfaRateLimitError) {
return redirectWithErrorMessage(
- "/account/security",
+ enrollmentErrorRedirectPath(),
request,
"Too many attempts. Please try again later."
);
@@ -151,7 +204,13 @@ export async function action({ request }: ActionFunctionArgs) {
}
}
-export function MfaSetup({ isEnabled }: { isEnabled: boolean }) {
+export function MfaSetup({
+ isEnabled,
+ isRequired = false,
+}: {
+ isEnabled: boolean;
+ isRequired?: boolean;
+}) {
const {
state,
actions,
@@ -170,7 +229,12 @@ export function MfaSetup({ isEnabled }: { isEnabled: boolean }) {
return (
<>
-
+ {/*
+ With MFA_REQUIRED the second factor cannot be turned off, so the toggle
+ and the disable dialog are omitted entirely — there is no state the
+ user is allowed to reach by interacting with them.
+ */}
+ {!isRequired && }
-
+ {!isRequired && (
+
+ )}
>
);
}
diff --git a/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts
index a5efd18935c..3d91079121f 100644
--- a/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts
+++ b/apps/webapp/app/routes/resources.account.mfa.setup/useMfaSetup.ts
@@ -168,7 +168,7 @@ function mfaReducer(state: MfaState, action: MfaAction): MfaState {
}
}
-export function useMfaSetup(initialIsEnabled: boolean) {
+export function useMfaSetup(initialIsEnabled: boolean, enrollmentRedirectTo?: string) {
const fetcher = useTypedFetcher();
const [state, dispatch] = useReducer(mfaReducer, {
@@ -259,7 +259,11 @@ export function useMfaSetup(initialIsEnabled: boolean) {
saveRecoveryCodes: () => {
dispatch({ type: "RECOVERY_CODES_SAVED" });
fetcher.submit(
- { action: "saved-recovery-codes" },
+ // Only sent when there's somewhere to go — the server falls back to
+ // the account security page otherwise.
+ enrollmentRedirectTo
+ ? { action: "saved-recovery-codes", redirectTo: enrollmentRedirectTo }
+ : { action: "saved-recovery-codes" },
{ method: "POST", action: "/resources/account/mfa/setup" }
);
},
diff --git a/apps/webapp/app/services/mfa/mfaRequirement.server.ts b/apps/webapp/app/services/mfa/mfaRequirement.server.ts
new file mode 100644
index 00000000000..042a64975d3
--- /dev/null
+++ b/apps/webapp/app/services/mfa/mfaRequirement.server.ts
@@ -0,0 +1,66 @@
+import { redirect } from "@remix-run/node";
+import { env } from "~/env.server";
+import { sanitizeRedirectPath } from "~/utils";
+import { mfaSetupPath } from "~/utils/pathBuilder";
+
+// Paths a signed-in user may reach while MFA is still required of them. The
+// setup page itself, the whole auth flow (any deeper and a user who has not
+// enrolled cannot log in at all, and `/login/mfa` must stay reachable for
+// users who HAVE enrolled), logout, and the MFA setup fetcher endpoint — which
+// is the only way to enroll, so gating it would deadlock the flow.
+//
+// `/logout` matters for the same loop reason as in `maybeAutoLogout`: the root
+// loader runs for `/logout` itself, so an unconditional redirect here would
+// prevent the session cookie ever being destroyed.
+const MFA_SETUP_EXACT_PATHS = new Set(["/magic", "/logout", "/login", "/mfa/setup"]);
+const MFA_SETUP_PREFIXES = ["/login/", "/auth/", "/resources/account/mfa/setup"];
+
+/** True when `pathname` is reachable without having enrolled MFA yet. */
+export function isMfaExemptPath(pathname: string): boolean {
+ if (MFA_SETUP_EXACT_PATHS.has(pathname)) return true;
+ return MFA_SETUP_PREFIXES.some((prefix) => pathname === prefix || pathname.startsWith(prefix));
+}
+
+/**
+ * The second half of the `MFA_REQUIRED` gate: given a user who is missing a
+ * second factor, decide whether this request may proceed anyway.
+ *
+ * Returns a redirect to the MFA setup page, or `null` when the request is
+ * allowed (either because the flag is off, the user has enrolled, or the path
+ * is on the exemption list above).
+ *
+ * Callers are expected to `throw` the returned response — the same convention
+ * `requireUser` uses for its login redirect.
+ */
+export function mfaRequirementRedirect(
+ request: Request,
+ user: { mfaEnabledAt: Date | null } | null
+): Response | null {
+ // Logged-out requests are handled by the normal login flow; they can only
+ // reach the exempt paths above anyway, but check the flag first so the
+ // common case costs a single boolean read.
+ if (!env.MFA_REQUIRED || !user || user.mfaEnabledAt) return null;
+
+ const url = new URL(request.url);
+ if (isMfaExemptPath(url.pathname)) return null;
+
+ // Carry the destination across the forced detour so the user lands where
+ // they were headed once enrollment completes. Sanitized: this is a
+ // user-influenced URL (the query string of the blocked request).
+ const redirectTo = sanitizeRedirectPath(`${url.pathname}${url.search}`);
+ const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
+ return redirect(`${mfaSetupPath()}?${searchParams}`);
+}
+
+/**
+ * Throwing variant for use inside auth helpers: aborts the request with the
+ * setup redirect when `MFA_REQUIRED` is on and the user has not enrolled.
+ * A no-op for every other case.
+ */
+export function throwIfMfaRequired(
+ request: Request,
+ user: { mfaEnabledAt: Date | null } | null
+): void {
+ const redirectResponse = mfaRequirementRedirect(request, user);
+ if (redirectResponse) throw redirectResponse;
+}
diff --git a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts
index 6a41f161cff..e2e9b73f22c 100644
--- a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts
+++ b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts
@@ -1,4 +1,5 @@
import { type SecretReference, type User, type PrismaClient } from "@trigger.dev/database";
+import { env } from "~/env.server";
import { prisma } from "~/db.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { createRandomStringGenerator } from "@better-auth/utils/random";
@@ -24,6 +25,16 @@ export class MultiFactorAuthenticationService {
}
public async disableTotp(userId: string, params: { totpCode?: string; recoveryCode?: string }) {
+ // Instance-level requirement: when `MFA_REQUIRED` is on, the second factor
+ // is not optional, so there is no code that can turn it off. Checked here
+ // rather than only in the route action so every caller — including future
+ // admin tooling — inherits the rule.
+ if (env.MFA_REQUIRED) {
+ throw new ServiceValidationError(
+ "Multi-factor authentication is required on this instance and cannot be disabled."
+ );
+ }
+
const user = await this.#prismaClient.user.findFirst({
where: { id: userId },
include: {
@@ -111,12 +122,26 @@ export class MultiFactorAuthenticationService {
public async enableTotp(userId: string) {
const user = await this.#prismaClient.user.findFirst({
where: { id: userId },
+ include: { mfaSecretReference: true },
});
if (!user) {
throw new ServiceValidationError("User not found");
}
+ // A user can leave enrollment half-finished (navigating away, or reloading
+ // the forced-setup page under `MFA_REQUIRED`) and come back to start over.
+ // Drop the pending secret first — otherwise the old row and its stored
+ // secret are orphaned every time they retry.
+ if (user.mfaSecretReference) {
+ const previousStore = getSecretStore(user.mfaSecretReference.provider);
+ await previousStore.deleteSecret(user.mfaSecretReference.key);
+ await this.#prismaClient.user.update({
+ where: { id: userId },
+ data: { mfaSecretReference: { delete: true } },
+ });
+ }
+
const secretStore = getSecretStore("DATABASE");
// Generate a new secret
diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts
index 90cda576a08..5cb5fcde6b2 100644
--- a/apps/webapp/app/services/session.server.ts
+++ b/apps/webapp/app/services/session.server.ts
@@ -5,6 +5,7 @@ import { extractClientIp } from "~/utils/extractClientIp.server";
import { authenticator } from "./auth.server";
import { getImpersonationId, getImpersonationState } from "./impersonation.server";
import { logger } from "./logger.server";
+import { throwIfMfaRequired } from "./mfa/mfaRequirement.server";
import { revalidateSsoSession } from "./ssoSessionRevalidation.server";
/**
@@ -135,6 +136,14 @@ export async function requireUser(request: Request) {
throw redirect(`/login?${searchParams}`);
}
+ // `MFA_REQUIRED`: a signed-in user who has not enrolled a second factor may
+ // only reach the exempt paths (auth flow, logout, the setup page itself).
+ // Enforced here — rather than only in `_app/route.tsx` — because `/account/*`,
+ // `/invites`, `/admin/*` and the resource routes are not nested under `_app`,
+ // so the layout loader alone would leave them reachable. root.tsx applies the
+ // same check for routes that authenticate with `requireUserId` only.
+ throwIfMfaRequired(request, user);
+
// Shared with the root loader so the client never reads a different answer
// than the one computed here.
const { isImpersonating, isViewingAsUser } = await getImpersonationState(request, user.id);
diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts
index 38e4b9c7505..37679681146 100644
--- a/apps/webapp/app/utils/pathBuilder.ts
+++ b/apps/webapp/app/utils/pathBuilder.ts
@@ -110,6 +110,10 @@ export function accountSecurityPath() {
return `/account/security`;
}
+export function mfaSetupPath() {
+ return `/mfa/setup`;
+}
+
export function invitesPath() {
return `/invites`;
}
diff --git a/apps/webapp/test/mfaRequirement.test.ts b/apps/webapp/test/mfaRequirement.test.ts
new file mode 100644
index 00000000000..cb4ec948e09
--- /dev/null
+++ b/apps/webapp/test/mfaRequirement.test.ts
@@ -0,0 +1,143 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+// env.server.ts parses process.env at import time, so flip the flag before the
+// module under test (and the env module it depends on) loads. The off-state
+// cases below turn it back off per-test rather than needing a second file.
+process.env.MFA_REQUIRED = "1";
+
+const { env } = await import("~/env.server");
+const { isMfaExemptPath, mfaRequirementRedirect } =
+ await import("~/services/mfa/mfaRequirement.server");
+
+const requestFor = (path: string) => new Request(`https://app.example.com${path}`);
+
+// The Location header is emitted as a relative path ("/mfa/setup?..."), so
+// resolving it needs the same origin the request was made against.
+const locationOf = (response: Response) =>
+ new URL(response.headers.get("location")!, "https://app.example.com");
+
+const enrolled = { mfaEnabledAt: new Date() };
+const notEnrolled = { mfaEnabledAt: null };
+
+afterEach(() => {
+ // Restored rather than left on, so a later test in this file that expects the
+ // flag off starts from the right state.
+ env.MFA_REQUIRED = true;
+});
+
+describe("isMfaExemptPath", () => {
+ it("exempts the setup page, the whole auth flow and logout", () => {
+ for (const path of [
+ "/mfa/setup",
+ "/login",
+ "/login/mfa",
+ "/login/magic",
+ "/login/sso",
+ "/logout",
+ "/magic",
+ "/auth/github/callback",
+ "/auth/google/callback",
+ "/auth/sso/callback",
+ ]) {
+ expect(isMfaExemptPath(path), `${path} should be exempt`).toBe(true);
+ }
+ });
+
+ it("exempts the MFA setup fetcher endpoint", () => {
+ expect(isMfaExemptPath("/resources/account/mfa/setup")).toBe(true);
+ });
+
+ it("does not exempt other resource endpoints", () => {
+ expect(isMfaExemptPath("/resources/account/session-duration")).toBe(false);
+ expect(isMfaExemptPath("/resources/some/other/endpoint")).toBe(false);
+ });
+
+ it("does not exempt dashboard routes", () => {
+ for (const path of [
+ "/",
+ "/account/security",
+ "/account/tokens",
+ "/invites",
+ "/admin",
+ "/orgs/acme/envs/production/runs",
+ "/confirm-basic-details",
+ ]) {
+ expect(isMfaExemptPath(path), `${path} should not be exempt`).toBe(false);
+ }
+ });
+
+ it("does not treat a prefix of an exempt path as exempt", () => {
+ // "/logins" must not match the "/login" exact entry, and a bare "/auth"
+ // (no trailing segment) is not a real route but is also not exempt here.
+ expect(isMfaExemptPath("/logins")).toBe(false);
+ });
+});
+
+describe("mfaRequirementRedirect", () => {
+ it("redirects a signed-in user without MFA to the setup page", () => {
+ const response = mfaRequirementRedirect(requestFor("/orgs/acme"), notEnrolled);
+
+ expect(response).not.toBeNull();
+ expect(response?.status).toBe(302);
+
+ const location = locationOf(response!);
+ expect(location.pathname).toBe("/mfa/setup");
+ // The blocked destination is carried across so they land where they meant to.
+ expect(location.searchParams.get("redirectTo")).toBe("/orgs/acme");
+ });
+
+ it("preserves the query string of the blocked request", () => {
+ const response = mfaRequirementRedirect(
+ requestFor("/orgs/acme/envs/dev/runs?run=abc&tab=logs"),
+ notEnrolled
+ );
+
+ const location = locationOf(response!);
+ expect(location.searchParams.get("redirectTo")).toBe(
+ "/orgs/acme/envs/dev/runs?run=abc&tab=logs"
+ );
+ });
+
+ it("sanitizes a redirect target that is not a navigable path", () => {
+ // A resource endpoint would render blank as a destination, so it falls back
+ // to the root path instead of being propagated.
+ const response = mfaRequirementRedirect(requestFor("/resources/foo"), notEnrolled);
+
+ const location = locationOf(response!);
+ expect(location.searchParams.get("redirectTo")).toBe("/");
+ });
+
+ it("allows a user who has enrolled MFA", () => {
+ expect(mfaRequirementRedirect(requestFor("/orgs/acme"), enrolled)).toBeNull();
+ });
+
+ it("does nothing when the flag is off", () => {
+ env.MFA_REQUIRED = false;
+
+ expect(mfaRequirementRedirect(requestFor("/orgs/acme"), notEnrolled)).toBeNull();
+ });
+
+ it("does nothing for a logged-out request", () => {
+ expect(mfaRequirementRedirect(requestFor("/orgs/acme"), null)).toBeNull();
+ });
+
+ it("allows the exempt paths through so the user can enroll or log out", () => {
+ for (const path of ["/mfa/setup", "/login", "/login/mfa", "/logout", "/magic"]) {
+ expect(mfaRequirementRedirect(requestFor(path), notEnrolled), `${path}`).toBeNull();
+ }
+ });
+});
+
+describe("throwIfMfaRequired", () => {
+ it("throws the redirect when the user is missing MFA", async () => {
+ const { throwIfMfaRequired } = await import("~/services/mfa/mfaRequirement.server");
+
+ expect(() => throwIfMfaRequired(requestFor("/orgs/acme"), notEnrolled)).toThrow();
+ });
+
+ it("does not throw when the user has enrolled", async () => {
+ const { throwIfMfaRequired } = await import("~/services/mfa/mfaRequirement.server");
+
+ expect(() => throwIfMfaRequired(requestFor("/orgs/acme"), enrolled)).not.toThrow();
+ });
+});
diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx
index 8d31694686e..1768409a688 100644
--- a/docs/self-hosting/env/webapp.mdx
+++ b/docs/self-hosting/env/webapp.mdx
@@ -42,6 +42,7 @@ mode: "wide"
| **Auth** | | | |
| `WHITELISTED_EMAILS` | No | — | Whitelisted emails regex. |
| `LOGIN_RATE_LIMITS_ENABLED` | No | true | Enable rate limiting on magic-link login. |
+| `MFA_REQUIRED` | No | false | Require every dashboard user to set up multi-factor authentication (TOTP). When enabled, a user without MFA is sent to a setup page and cannot use the dashboard until they enroll, and MFA cannot be turned off afterwards. |
| `AUTH_GITHUB_CLIENT_ID` | No | — | GitHub client ID. |
| `AUTH_GITHUB_CLIENT_SECRET` | No | — | GitHub client secret. |
| `CSP_IMG_SRC_ALLOWLIST` | No | — | Extra hosts the dashboard may load images from, e.g. an SSO or avatar host serving profile images. A comma-separated list of exact origins (`https://sso.example.com`); a wildcard host such as `https://*.example.com` is refused. |