Skip to content

feat(auth): add WorkOS two-factor authentication with backup codes - #1024

Merged
mezotv merged 21 commits into
mainfrom
t3code/workos-2fa-passkey-support
Sep 23, 2026
Merged

mezotv merged 21 commits into
mainfrom
t3code/workos-2fa-passkey-support

Conversation

@janburzinski

@janburzinski janburzinski commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Description

Adds two-factor authentication (TOTP) with backup codes on top of WorkOS AuthKit, fully inside our own login and settings UI. Passkeys were explored on this branch and removed again: WorkOS only offers passkey sign-in through its hosted AuthKit page, which does not fit the custom login.

Sign-in

  • Password and email-code sign-in now handle WorkOS mfa_challenge / mfa_enrollment results: a 6-digit challenge step, or an inline enrollment step (QR code, manual setup key and otpauth:// URI, verification) when an organization requires MFA.
  • Backup codes are issued once, on the first successful TOTP sign-in, and shown with download / print / copy before the redirect.
  • "Lost your device? Use a backup code": WorkOS has no recovery codes, so backup codes are ours (user_backup_codes, SHA-256 hashed, single use). Redeeming one removes the WorkOS TOTP factors and signs the user in again with the password still in the form. The code is only spent after the factors are gone, so a WorkOS failure never burns it.
  • Social sign-in that hits an MFA challenge is routed back to /login with the pending challenge; the user is identified for recovery via a short-lived httpOnly cookie instead of a client-visible token.

Settings → Security (new section)

  • Authenticator app: set up (stepper: scan → manual key → code → backup codes), remove.
  • Backup codes: remaining count, regenerate.

Shared components (packages/ui, also in the design system under #auth-mfa)

  • InputOTP on Base UI's OTPField (no new dependency), TotpCodeInput, MfaChallengeForm, MfaEnrollmentForm, TotpEnrollmentPanel, BackupCodesPanel, TwoFactorSettings, StepTransition.
  • useAuthFlow + AuthPendingStep own the pending-step state for login and signup; forms take a step and report results via onResult.

Server

  • New generic action framework lib/actions/{errors,run-action,validate-input}.ts (ActionFailure with optional code, runAction, ActionResult as a discriminated union); organization actions moved onto it.
  • lib/auth/mfa.ts, backup-codes.ts, short-lived-cookie.ts, security-actions.ts.

Dev tooling

  • /design-system/auth-flow: dev-only playground with an in-browser stand-in for WorkOS (real RFC 6238 TOTP codes, live authenticator widget, event log).
  • scripts/create-dev-auth-account.ts (idempotent, --reset-mfa) and scripts/mfa-smoke.ts (enroll → verify → sign in → challenge → authenticateWithTotp).

Migration

  • 0084_user_backup_codes adds the user_backup_codes table.

Requires

  • Multi-Factor Auth enabled in the WorkOS Dashboard → Authentication for each environment. Without it authenticateWithPassword never returns mfa_challenge (verified with the smoke script against the test environment).

Screenshot/Recording (if applicable)

See the design system section Auth · Two-factor (/design-system#auth-mfa) and the playground at /design-system/auth-flow.

Checklist
  • I ran a self-review before opening this PR
  • I ran formatting/linting/type checks locally
  • I updated docs when behavior or setup changed
  • I only added comments where the logic is not obvious
  • I have used conventional commits for the PR title and commit messages
  • I did not use AI to write the code in this PR or have disclosed that I did (written with Claude Code)

🤖 Generated with Claude Code

@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
notra Ready Ready Preview Sep 23, 2026 6:54pm UTC
4 Skipped Deployments
Project Deployment Actions Updated
notra-agent Skipped Skipped Sep 23, 2026 6:54pm UTC
notra-onboarding-agent Skipped Skipped Sep 23, 2026 6:54pm UTC
notra-ui Skipped Skipped Sep 23, 2026 6:54pm UTC
notra-web Skipped Skipped Sep 23, 2026 6:54pm UTC

Request Review

- Fold the security actions onto a generic action framework
  (ActionFailure, runAction, validateActionInput) and delete the
  security-only error/result types and rate-limit copy
- Replace the signed recovery token with a short-lived httpOnly cookie
  set when a challenge is issued; drop the URL/prop plumbing
- Spend a backup code only after the WorkOS factors are deleted so a
  failed removal never burns it
- Issue backup codes server-side on the first successful TOTP sign-in
  (new "enrolled" result) instead of trusting a client enrollment flag
- Share the pending-step state machine between login and signup via
  useAuthFlow + AuthPendingStep; forms take a step and report results
- Model the settings enrollment as scanning/verified, gate passkey
  actions through one step-up wrapper, parse widgets responses with zod
- Split the dev playground and keep its session in sync with the flow

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@vercel
vercel Bot temporarily deployed to Preview – notra-agent September 12, 2026 07:13 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-ui September 12, 2026 07:13 Inactive
@vercel
vercel Bot temporarily deployed to Preview – notra-onboarding-agent September 12, 2026 07:13 Inactive
WorkOS only offers passkey sign-in through its hosted AuthKit UI, so the
custom login cannot own that flow. Ship TOTP two-factor with backup codes
only and drop everything passkey-related:

- Login: passkey button, redirect action, and "passkey" auth method
- Settings: passkeys section, email step-up dialog, elevated-access cookie
- Server: WebAuthn client, Widgets API client, passkey and challenge
  actions, their schemas, rate limit, and analytics events
- UI package: AuthPasskeyButton, PasskeysSettings, StepUpVerification
- Design system: passkeys section and step-up demo; playground trimmed

Also tighten the remaining contracts: props that were optional only for
the passkey variants are required now, the backup-code path is wired for
signup as well, and unused error codes are gone.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@vercel
vercel Bot temporarily deployed to Preview – notra-ui September 14, 2026 18:34 Inactive
@janburzinski janburzinski changed the title feat(auth): add WorkOS 2FA and passkey support feat(auth): add WorkOS two-factor authentication with backup codes Sep 14, 2026
Resolve the settings section conflicts by keeping both the new Security
section and main's Appearance section, and renumber the backup-codes
migration from 0084 to 0090 behind main's migrations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown

React Doctor found 8 new issues in 4 files · 8 warnings · score 76 / 100 (Needs work) · 12 fixed · vs main

8 warnings

src/components/chat/chat-input.tsx

  • ⚠️ L606 React function has high control-flow complexity no-high-complexity-react-function
  • ⚠️ L1273 React function has high control-flow complexity no-high-complexity-react-function
  • ⚠️ L1893 Missing effect dependencies exhaustive-deps

src/components/geo/prompt-suggestions.tsx

  • ⚠️ L349 React function has high control-flow complexity no-high-complexity-react-function

src/components/shared/security/second-factor-confirm.tsx

  • ⚠️ L37 Loading flag reset outside finally no-loading-flag-reset-outside-finally

src/lib/hooks/use-chat-skill-slash.ts

  • ⚠️ L47 State adjusted after a prop changes no-adjust-state-on-prop-change
  • ⚠️ L48 State adjusted after a prop changes no-adjust-state-on-prop-change
  • ⚠️ L49 State adjusted after a prop changes no-adjust-state-on-prop-change

Reviewed by React Doctor for commit 419550b. See inline comments for fixes.

@mezotv

mezotv commented Sep 22, 2026

Copy link
Copy Markdown
Member

@greptile

Comment thread apps/dashboard/src/lib/auth/mfa-actions.ts Outdated
Comment thread apps/dashboard/src/lib/auth/security-actions.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 92 files

Confidence score: 2/5

  • apps/dashboard/src/constants/security.ts and apps/dashboard/src/utils/ratelimit.ts: MFA throttling can be bypassed or misapplied because the browser-wide identity cookie is overwritten across accounts and the account limit remains IP-scoped, allowing fresh windows from different IPs. Bind limits to a stable account/challenge bucket and avoid relying on a shared browser cookie for account identity.
  • apps/dashboard/src/lib/auth/backup-codes.ts: consumeBackupCode invalidates a recovery code before WorkOS factor operations complete, so transient failures can permanently remove a usable fallback and leave the user unable to recover. Consume the code only after all security mutations succeed.
  • apps/dashboard/src/lib/auth/mfa-actions.ts: The social enrollment handoff is consumed before rate limiting or factor creation succeeds, causing retryable sign-in flows to be lost on throttling or transient WorkOS failures. Check the limiter first and consume the handoff only after successful enrollment.
  • apps/dashboard/src/components/auth/login-content.tsx: The pending social challenge cookie remains after successful MFA, so revisiting the MFA login URL can reopen an already-consumed challenge until expiry. Clear the pending challenge flow when verifyMfaCodeAction completes successfully.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/dashboard/src/utils/ratelimit.ts">

<violation number="1" location="apps/dashboard/src/utils/ratelimit.ts:228">
P2: The account-scoped MFA limit is still IP-scoped, so an attacker can obtain a fresh five-attempt window from each IP. Use a stable account/challenge bucket separately from any IP-based bucket.</violation>
</file>

<file name="apps/dashboard/src/constants/security.ts">

<violation number="1" location="apps/dashboard/src/constants/security.ts:9">
P1: This browser-wide cookie can mis-bind MFA rate limiting: starting another account's flow overwrites the identity used by `verifyMfaCodeAction`, allowing fresh challenges for the original account to evade its per-account guess limit. Bind the rate-limit identity to the submitted MFA flow, or reject any cookie/challenge mismatch.</violation>
</file>

<file name="apps/dashboard/src/components/auth/login-content.tsx">

<violation number="1" location="apps/dashboard/src/components/auth/login-content.tsx:18">
P2: The social challenge cookie is never cleared after successful MFA, so revisiting the `/login?mfa=…` URL reopens an already-consumed challenge until expiry. Clear the pending challenge flow when `verifyMfaCodeAction` completes successfully.</violation>
</file>

<file name="apps/dashboard/src/lib/auth/backup-codes.ts">

<violation number="1" location="apps/dashboard/src/lib/auth/backup-codes.ts:87">
P1: `consumeBackupCode` burns a code before WorkOS factor operations. Move consumption after successful factor removal and other security mutations so transient WorkOS failures do not permanently invalidate the user’s fallback code.</violation>
</file>

<file name="apps/dashboard/src/lib/auth/mfa-actions.ts">

<violation number="1" location="apps/dashboard/src/lib/auth/mfa-actions.ts:272">
P2: This consumes the social enrollment handoff before rate limiting or factor creation succeeds, so rate limits and transient WorkOS failures discard a retryable sign-in flow. Check the limiter first and consume the handoff only with a retry-safe enrollment completion.</violation>
</file>

Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.

Re-trigger cubic

* mid-MFA, so backup-code recovery and per-account rate limits can be bound
* to it without the client ever handling the user's identity.
*/
export const MFA_ATTEMPT_COOKIE = "notra_mfa_attempt";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This browser-wide cookie can mis-bind MFA rate limiting: starting another account's flow overwrites the identity used by verifyMfaCodeAction, allowing fresh challenges for the original account to evade its per-account guess limit. Bind the rate-limit identity to the submitted MFA flow, or reject any cookie/challenge mismatch.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/constants/security.ts, line 9:

<comment>This browser-wide cookie can mis-bind MFA rate limiting: starting another account's flow overwrites the identity used by `verifyMfaCodeAction`, allowing fresh challenges for the original account to evade its per-account guess limit. Bind the rate-limit identity to the submitted MFA flow, or reject any cookie/challenge mismatch.</comment>

<file context>
@@ -0,0 +1,37 @@
+ * mid-MFA, so backup-code recovery and per-account rate limits can be bound
+ * to it without the client ever handling the user's identity.
+ */
+export const MFA_ATTEMPT_COOKIE = "notra_mfa_attempt";
+/**
+ * Carries the pending WorkOS credentials from the social callback to the
</file context>

): Promise<boolean> {
const rows = await db
.update(userBackupCodes)
.set({ usedAt: new Date() })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: consumeBackupCode burns a code before WorkOS factor operations. Move consumption after successful factor removal and other security mutations so transient WorkOS failures do not permanently invalidate the user’s fallback code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/dashboard/src/lib/auth/backup-codes.ts, line 87:

<comment>`consumeBackupCode` burns a code before WorkOS factor operations. Move consumption after successful factor removal and other security mutations so transient WorkOS failures do not permanently invalidate the user’s fallback code.</comment>

<file context>
@@ -0,0 +1,97 @@
+): Promise<boolean> {
+  const rows = await db
+    .update(userBackupCodes)
+    .set({ usedAt: new Date() })
+    .where(
+      and(
</file context>

Comment thread apps/dashboard/scripts/create-dev-auth-account.ts
Comment thread apps/dashboard/src/lib/auth/mfa-actions.ts Outdated
Comment thread apps/dashboard/src/lib/auth/mfa-actions.ts Outdated
Comment thread apps/dashboard/src/lib/auth/auth-flow.ts Outdated
Comment thread packages/ui/src/components/shared/auth/totp-code-input.tsx Outdated
Comment thread packages/ui/src/components/shared/auth/email-verification-form.tsx
Comment thread packages/ui/src/components/shared/auth/mfa-challenge-form.tsx Outdated
Comment thread packages/ui/src/components/shared/auth/mfa-challenge-form.tsx
@mezotv

mezotv commented Sep 22, 2026

Copy link
Copy Markdown
Member

@greptile

POSTHOG_EVENTS.MFA_VERIFIED
);
yield* Effect.promise(clearMfaAttemptCookie);
yield* Effect.promise(clearAllPendingMfaFlows);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unrelated MFA Flows Cleared

Completing one MFA challenge calls clearAllPendingMfaFlows, which deletes every pending MFA handoff cookie in the browser. If another social MFA flow is open in a second tab—or password MFA completes while a social handoff is pending—that unrelated flow loses its credentials and cannot continue. Clear only the handoff associated with the completed authentication attempt.

const value =
typeof next === "function" ? next(backupCodesRef.current) : next;
backupCodesRef.current = value;
updateBackupCodes(value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Backup Code Update Recurses

updateBackupCodes calls itself instead of the React state setter. Resetting the playground, issuing or regenerating codes, redeeming recovery, or removing MFA therefore recurses until a stack overflow and leaves the auth-flow playground unusable.

Suggested change
updateBackupCodes(value);
setBackupCodes(value);

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and no new issues found across 18 files (changes from recent commits).

Confidence score: 3/5

  • In apps/dashboard/src/lib/auth/backup-codes.ts, the error-handling path can restore a backup code after WorkOS has already applied the factor change, allowing the single-use code to authorize another change; ensure rollback does not make the code reusable when the external operation may have succeeded.

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

Comment thread apps/dashboard/src/components/design-system/use-auth-flow-playground.ts Outdated
Comment thread apps/dashboard/src/lib/auth/auth-flow.ts Outdated
Comment thread apps/dashboard/scripts/create-dev-auth-account.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 43 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/dashboard/src/components/settings/two-factor-section.tsx Outdated
ok: false as const,
message: CONFIRM_ERROR_FALLBACK,
}));
setIsPending(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-loading-flag-reset-outside-finally (warning)

This resets a loading/busy flag only on the success path: if the awaited call rejects the reset never runs and the flag stays stuck truthy (a spinner that never stops, a button disabled forever). Move the reset into a finally block, or mirror it on every catch, so it clears on rejection too.

Fix → A trailing setLoading(false) after an await never runs if the awaited call rejects, so the flag stays stuck truthy; reset it in a finally block (or mirror the reset on every catch) so it clears on both paths.

Docs

This branch was successfully deployed

1 active and 4 inactive deployments
Preview – notra 419550bd Deployed Sep 23, 2026 by vercel[bot]
Preview – notra-agent 419550bd Deployed Sep 23, 2026 by vercel[bot]
Preview – notra-web 419550bd Deployed Sep 23, 2026 by vercel[bot]
Preview – notra-onboarding-agent 419550bd Deployed Sep 23, 2026 by vercel[bot]
Preview – notra-ui 419550bd Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants