Build This Now
Build This Now
Was ist der Claude Code?Claude Code installierenClaude Code Native InstallerDein erstes Claude Code-Projekt
Failed Payment RecoveryLocalization and i18nPDF InvoicesReferral ProgramTwo-Factor AuthNext.js DevTools MCPNext.js Agent SetupSubscription BillingMulti-Tenant SaaSAI Chat FeatureRate LimitingStripe WebhooksSemantic SearchRealtime UpdatesDrip Email Sequencesv2.1.122 Release NotesClaude Code Dynamic Workflows: 1.000 Subagents auf einer echten Codebase orchestrierenClaude Code Best PracticesClaude Opus 4.7 Best PracticesClaude Code auf einem VPSGit-IntegrationClaude Code ReviewClaude Code WorktreesClaude Code Remote ControlClaude Code ChannelsChannels, Routines, Teleport, DispatchGeplante Aufgaben mit Claude CodeClaude Code BerechtigungenClaude Code Auto-ModusStripe-Zahlungen mit Claude Code einbauenFeedback-LoopsTodo-WorkflowsClaude Code TasksProjekt-TemplatesClaude Code Preise und Token-NutzungClaude Code Preise: Was du wirklich zahlstClaude Code Ultra ReviewEine Next.js-App mit Claude Code bauenSupabase DatabaseVercel DeepsecTest-Driven DevelopmentSo baust du ein SaaS-MVP mit Claude CodeAuthentifizierung mit Claude Code einbauen (Supabase Auth)Transaktions-E-Mails mit Claude Code einbauen (Resend + React Email)Eine typsichere API mit Claude Code bauen (oRPC + Zod)File UploadsBackground Jobs (Inngest)Admin DashboardFull-Text SearchClaude Agent SDKKiro Migration GuideClaude Research AgentLeave Grok BuildRoute Subagent ModelsClaude Monorepo SetupCI Repair AgentVisual Regression TestsAgent Cost DashboardCursor Migration GuideAgentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based BillingWas es 2026 kostet, ein SaaS mit Claude Code zu bauenParallel AI AgentsCoding Agent Injection
speedy_devvkoen_salo
Blog/Handbook/Workflow/Two-Factor Auth

Two-Factor Auth

Add TOTP two factor authentication to a Next.js 16 app with Supabase MFA: QR enrollment, verified factors, hashed 2FA recovery codes, AAL2 step up authentication before billing, and RLS policies that read the assurance level.

Sie möchten das Framework hinter diesen Projekten?

Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.

Sehen Sie, was wir für Unternehmen bauen →
speedy_devvkoen_salo
speedy_devvWritten by speedy_devvPublished Jul 30, 202611 min readHandbook hubWorkflow index

Passwords get phished. A second factor is the cheapest fix you can ship in an afternoon, and Supabase Auth already has the hard parts built. What it does not have is recovery codes, step-up prompts before destructive actions, or database policies that know whether the current session actually passed the second factor. This guide adds all three to a Next.js 16 app with working code.

What Supabase Gives You and What It Does Not

Supabase Auth ships TOTP multi-factor authentication in the JS client under supabase.auth.mfa. The core methods are enroll, challenge, verify, challengeAndVerify, unenroll, and listFactors, plus getAuthenticatorAssuranceLevel for reading the current state and a webauthn namespace for passkey factors.

The concept that ties it together is the Authenticator Assurance Level. A session is aal1 when the user proved one factor (password, magic link, OAuth). It becomes aal2 after they pass a second factor. Supabase writes that value into the aal claim on the JWT, which is what makes it readable from your app and from Postgres.

That last part is the reason to prefer TOTP over emailed or texted codes for a builder shipping solo. A TOTP factor is a shared secret between your Supabase project and the user's authenticator app, so there is no SMS provider to pay, no deliverability to babysit, and no SIM swap in the threat model. Supabase also supports phone and WebAuthn factors, and everything below works the same way for them because the assurance level is factor agnostic.

What Supabase does not do: generate recovery codes, decide which routes deserve a re-prompt, or enforce anything at the database layer. Those are the three gaps this guide fills.

The Enrollment Flow

Enrollment is two round trips. First enroll creates an unverified factor and hands back a QR code. Then the user scans it and types a six digit code, which challengeAndVerify checks. Only after that second step does the factor become verified and the session get promoted to aal2.

Two details bite people. Supabase rejects a second factor with a friendly name already used by that user, and a failed enrollment leaves an unverified factor sitting on the account. So clear the unverified ones before enrolling a new one.

The qr_code field comes back as raw SVG markup, not an image URL, so wrap it in a data URL before handing it to the browser.

// app/settings/security/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { createClient } from '@/utils/supabase/server'
import { createAdminClient } from '@/utils/supabase/admin'
import { generateRecoveryCodes, hashRecoveryCode } from '@/lib/recovery-codes'

export type EnrollResult =
  | { factorId: string; qrDataUrl: string; secret: string }
  | { error: string }

export async function startEnrollment(friendlyName: string): Promise<EnrollResult> {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
  if (!user) return { error: 'Not signed in.' }

  // A retried enrollment leaves orphaned unverified factors behind,
  // and Supabase rejects duplicate friendly names. Clean up first.
  const { data: factors } = await supabase.auth.mfa.listFactors()
  for (const factor of factors?.all ?? []) {
    if (factor.status === 'unverified') {
      await supabase.auth.mfa.unenroll({ factorId: factor.id })
    }
  }

  const { data, error } = await supabase.auth.mfa.enroll({
    factorType: 'totp',
    friendlyName,
    issuer: 'Acme',
  })

  if (error || !data) return { error: error?.message ?? 'Could not start enrollment.' }

  return {
    factorId: data.id,
    qrDataUrl: `data:image/svg+xml;utf8,${encodeURIComponent(data.totp.qr_code)}`,
    secret: data.totp.secret,
  }
}

The verification step does double duty. It confirms the factor and it is the only moment where you have a fresh, trustworthy signal that this user controls the authenticator, which is exactly when recovery codes should be minted.

// app/settings/security/actions.ts (continued)

export type VerifyResult = { codes: string[] } | { error: string }

export async function verifyEnrollment(
  factorId: string,
  code: string
): Promise<VerifyResult> {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
  if (!user) return { error: 'Not signed in.' }

  const { error } = await supabase.auth.mfa.challengeAndVerify({
    factorId,
    code: code.replace(/\s/g, ''),
  })
  if (error) return { error: error.message }

  const codes = generateRecoveryCodes()
  const rows = await Promise.all(
    codes.map(async (plain) => ({
      user_id: user.id,
      code_hash: await hashRecoveryCode(plain),
    }))
  )

  // Service role only. The table has RLS on and no policies,
  // so the browser client cannot touch it under any circumstance.
  const admin = createAdminClient()
  await admin.from('mfa_recovery_codes').delete().eq('user_id', user.id)
  const { error: insertError } = await admin.from('mfa_recovery_codes').insert(rows)
  if (insertError) {
    return { error: 'Authenticator verified, but recovery codes failed to save.' }
  }

  revalidatePath('/settings/security')
  return { codes }
}

challengeAndVerify collapses challenge and verify into one call. Use the split version only when you need the challenge ID for something else, such as a phone factor where the challenge sends the SMS.

The Enrollment UI

The client component holds three states: nothing started, QR shown, and codes revealed. React 19 lets a Client Component call Server Actions inside a transition, so no route handler is needed.

The plaintext recovery codes exist exactly once, in this render. If the user closes the tab, they are gone and the only path forward is re-enrolling.

// app/settings/security/enroll-totp.tsx
'use client'

import { useState, useTransition } from 'react'
import { startEnrollment, verifyEnrollment } from './actions'

type Enrollment = { factorId: string; qrDataUrl: string; secret: string }

export function EnrollTotp() {
  const [enrollment, setEnrollment] = useState<Enrollment | null>(null)
  const [codes, setCodes] = useState<string[] | null>(null)
  const [error, setError] = useState<string | null>(null)
  const [pending, startTransition] = useTransition()

  function begin() {
    setError(null)
    startTransition(async () => {
      const result = await startEnrollment('Authenticator app')
      if ('error' in result) return setError(result.error)
      setEnrollment(result)
    })
  }

  function confirm(formData: FormData) {
    if (!enrollment) return
    setError(null)
    startTransition(async () => {
      const result = await verifyEnrollment(
        enrollment.factorId,
        String(formData.get('code') ?? '')
      )
      if ('error' in result) return setError(result.error)
      setEnrollment(null)
      setCodes(result.codes)
    })
  }

  if (codes) {
    return (
      <div>
        <h2>Save your recovery codes</h2>
        <p>Each code works once. This is the only time they are shown.</p>
        <ul className="grid grid-cols-2 gap-2 font-mono">
          {codes.map((code) => (
            <li key={code}>{code}</li>
          ))}
        </ul>
      </div>
    )
  }

  if (enrollment) {
    return (
      <form action={confirm}>
        <img src={enrollment.qrDataUrl} alt="Authenticator QR code" width={200} height={200} />
        <p>
          Cannot scan? Enter this key: <code>{enrollment.secret}</code>
        </p>
        <input name="code" inputMode="numeric" autoComplete="one-time-code" required />
        <button type="submit" disabled={pending}>
          {pending ? 'Verifying...' : 'Verify'}
        </button>
        {error && <p role="alert">{error}</p>}
      </form>
    )
  }

  return (
    <div>
      <button onClick={begin} disabled={pending}>
        {pending ? 'Starting...' : 'Set up two-factor auth'}
      </button>
      {error && <p role="alert">{error}</p>}
    </div>
  )
}

Hashed Recovery Codes

Recovery codes are a password equivalent, so store them the way you would store passwords. The table has row-level security enabled and no policies at all, which means every client-side request against it returns nothing. Only the service role, used exclusively in Server Actions, can read or write it.

create table public.mfa_recovery_codes (
  id         uuid primary key default gen_random_uuid(),
  user_id    uuid not null references auth.users (id) on delete cascade,
  code_hash  text not null,
  used_at    timestamptz,
  created_at timestamptz not null default now()
);

alter table public.mfa_recovery_codes enable row level security;

-- No policies on purpose: RLS with zero policies denies everything
-- to anon and authenticated. Service role bypasses RLS entirely.

create index mfa_recovery_codes_unused_idx
  on public.mfa_recovery_codes (user_id)
  where used_at is null;

Hashing runs in Node with scrypt from node:crypto. No dependency, no plaintext ever reaching a SQL statement where it could land in query logs. The alphabet drops the characters people misread (I, O, 0, 1), and because it holds exactly 32 symbols the modulo below is uniform over a byte.

// lib/recovery-codes.ts
import 'server-only'
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'

const scryptAsync = promisify(scrypt) as (
  password: string,
  salt: Buffer,
  keylen: number
) => Promise<Buffer>

const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // 32 symbols, no I O 0 1
const CODE_COUNT = 10
const HALF = 5

function normalize(code: string): string {
  return code.trim().toUpperCase().replace(/[^A-Z0-9]/g, '')
}

export function generateRecoveryCodes(): string[] {
  return Array.from({ length: CODE_COUNT }, () => {
    const bytes = randomBytes(HALF * 2)
    let raw = ''
    for (const byte of bytes) raw += ALPHABET[byte % ALPHABET.length]
    return `${raw.slice(0, HALF)}-${raw.slice(HALF)}`
  })
}

export async function hashRecoveryCode(code: string): Promise<string> {
  const salt = randomBytes(16)
  const derived = await scryptAsync(normalize(code), salt, 64)
  return `scrypt$${salt.toString('base64')}$${derived.toString('base64')}`
}

export async function verifyRecoveryCode(code: string, stored: string): Promise<boolean> {
  const [scheme, saltB64, hashB64] = stored.split('$')
  if (scheme !== 'scrypt' || !saltB64 || !hashB64) return false

  const expected = Buffer.from(hashB64, 'base64')
  const derived = await scryptAsync(normalize(code), Buffer.from(saltB64, 'base64'), expected.length)
  return derived.length === expected.length && timingSafeEqual(derived, expected)
}

The admin client is small but it must never be imported from a Client Component. The server-only package turns that mistake into a build error instead of a leaked service role key.

// utils/supabase/admin.ts
import 'server-only'
import { createClient } from '@supabase/supabase-js'

export function createAdminClient() {
  return createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!,
    { auth: { autoRefreshToken: false, persistSession: false } }
  )
}

Redeeming a Code

Here is the honest constraint: a recovery code cannot mint an aal2 session. Supabase only promotes a session through a real factor verification. So redemption does the next best thing. It proves identity, marks the code used, deletes the TOTP factor through the admin API, and sends the user back to sign in and re-enroll.

Deleting a verified factor logs the user out of every active session, which is the correct blast radius for "I lost my phone."

Two operational notes before you ship this route. The loop runs scrypt once per unused code, which is deliberate work but also a cheap denial of service if left open, so rate limit by IP and by user ID and lock the account after a handful of failures. And treat redemption as a security event worth an email: send the user a "your two-factor authentication was reset" message on success, because if they did not do it, that email is the only warning they will get.

// app/recover/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
import { createAdminClient } from '@/utils/supabase/admin'
import { verifyRecoveryCode } from '@/lib/recovery-codes'

export async function redeemRecoveryCode(_prev: unknown, formData: FormData) {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
  if (!user) redirect('/login')

  const submitted = String(formData.get('code') ?? '')
  if (!submitted) return { error: 'Enter a recovery code.' }

  const admin = createAdminClient()
  const { data: stored } = await admin
    .from('mfa_recovery_codes')
    .select('id, code_hash')
    .eq('user_id', user.id)
    .is('used_at', null)

  let matchedId: string | null = null
  for (const row of stored ?? []) {
    if (await verifyRecoveryCode(submitted, row.code_hash)) {
      matchedId = row.id
      break
    }
  }

  if (!matchedId) return { error: 'That recovery code is not valid.' }

  await admin
    .from('mfa_recovery_codes')
    .update({ used_at: new Date().toISOString() })
    .eq('id', matchedId)

  const { data: factorData } = await admin.auth.admin.mfa.listFactors({ userId: user.id })
  for (const factor of factorData?.factors ?? []) {
    await admin.auth.admin.mfa.deleteFactor({ id: factor.id, userId: user.id })
  }

  await supabase.auth.signOut()
  redirect('/login?recovered=1')
}

Step-Up Before Billing

Being aal2 at login is not the same as being aal2 five hours later on the page that cancels a subscription. Step-up authentication re-prompts for the second factor right before a sensitive action, no matter how the session started.

getAuthenticatorAssuranceLevel() returns currentLevel, nextLevel, and currentAuthenticationMethods. The pair that matters: currentLevel of aal1 with a nextLevel of aal2 means the user has a verified factor but has not used it on this session. The AMR array carries a UNIX timestamp per method, which is how you measure freshness.

// lib/require-aal2.ts
import 'server-only'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

const MFA_METHODS = new Set(['totp', 'mfa/totp', 'mfa/phone', 'mfa/webauthn'])
const MAX_AGE_SECONDS = 15 * 60

export async function requireAal2(next: string) {
  const supabase = await createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
  if (!user) redirect(`/login?next=${encodeURIComponent(next)}`)

  const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()

  if (aal?.currentLevel !== 'aal2') {
    if (aal?.nextLevel === 'aal2') redirect(`/step-up?next=${encodeURIComponent(next)}`)
    redirect(`/settings/security?enroll=1&next=${encodeURIComponent(next)}`)
  }

  const lastVerified = (aal.currentAuthenticationMethods ?? [])
    .filter((entry) => MFA_METHODS.has(entry.method))
    .reduce((newest, entry) => Math.max(newest, entry.timestamp), 0)

  const ageSeconds = Math.floor(Date.now() / 1000) - lastVerified
  if (!lastVerified || ageSeconds > MAX_AGE_SECONDS) {
    redirect(`/step-up?next=${encodeURIComponent(next)}&reason=stale`)
  }

  return user
}

Any page that touches money or deletes data calls it first. params is a Promise in Next.js 16, so it needs await.

// app/settings/billing/[teamId]/page.tsx
import { requireAal2 } from '@/lib/require-aal2'
import { BillingPanel } from '@/components/billing-panel'

export default async function BillingPage({
  params,
}: {
  params: Promise<{ teamId: string }>
}) {
  const { teamId } = await params
  await requireAal2(`/settings/billing/${teamId}`)

  return <BillingPanel teamId={teamId} />
}

The step-up form itself re-verifies against the existing factor. Note that listFactors() returns an all array containing every factor and per-type arrays (totp, phone, webauthn) that hold only verified ones, which is exactly what you want here. The redirect guard rejects anything that is not a same-site path.

// app/step-up/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'

export async function stepUp(_prev: unknown, formData: FormData) {
  const supabase = await createClient()

  const code = String(formData.get('code') ?? '').replace(/\s/g, '')
  const rawNext = String(formData.get('next') ?? '/dashboard')
  const next = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/dashboard'

  const { data: factors } = await supabase.auth.mfa.listFactors()
  const factorId = factors?.totp?.[0]?.id
  if (!factorId) return { error: 'No verified authenticator on this account.' }

  const { error } = await supabase.auth.mfa.challengeAndVerify({ factorId, code })
  if (error) return { error: error.message }

  revalidatePath('/', 'layout')
  redirect(next)
}

Next.js 16 uses proxy.ts instead of middleware.ts, and the exported function is proxy rather than middleware. It is the right place for the coarse redirect, keeping users out of the sensitive section before the page renders.

// proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

const STEP_UP_PREFIXES = ['/settings/billing', '/settings/danger', '/admin']

export async function proxy(request: NextRequest) {
  let response = NextResponse.next({ request })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value))
          response = NextResponse.next({ request })
          cookiesToSet.forEach(({ name, value, options }) =>
            response.cookies.set(name, value, options)
          )
        },
      },
    }
  )

  const {
    data: { user },
  } = await supabase.auth.getUser()
  const path = request.nextUrl.pathname

  if (user && STEP_UP_PREFIXES.some((prefix) => path.startsWith(prefix))) {
    const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
    if (aal?.nextLevel === 'aal2' && aal.currentLevel !== 'aal2') {
      const url = request.nextUrl.clone()
      url.pathname = '/step-up'
      url.searchParams.set('next', path)
      return NextResponse.redirect(url)
    }
  }

  return response
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

RLS Policies That Read the Assurance Level

The redirect is user experience. The policy is enforcement. A Server Action, a route handler, or anything hitting PostgREST with the anon key skips proxy.ts entirely, so the assurance check has to exist in Postgres too.

Restrictive policies are the tool. Unlike permissive policies, which are ORed together, a restrictive policy is ANDed with every other policy on the table. It can only narrow access, never widen it, so you still need your normal ownership policy alongside it.

-- The usual ownership rule (permissive).
create policy "members read their subscription"
  on public.subscriptions
  for select
  to authenticated
  using (team_id in (
    select team_id from public.team_members where user_id = (select auth.uid())
  ));

-- ANDed on top of everything above.
create policy "subscriptions require aal2"
  on public.subscriptions
  as restrictive
  to authenticated
  using ((select auth.jwt() ->> 'aal') = 'aal2')
  with check ((select auth.jwt() ->> 'aal') = 'aal2');

Requiring aal2 for everyone locks out users who never enrolled. The gentler version only enforces it once a verified factor exists, using the array containment operator <@ against auth.mfa_factors.

create policy "aal2 once enrolled"
  on public.projects
  as restrictive
  to authenticated
  using (
    array[(select auth.jwt() ->> 'aal')] <@ (
      select
        case
          when count(id) > 0 then array['aal2']
          else array['aal1', 'aal2']
        end
      from auth.mfa_factors
      where user_id = (select auth.uid()) and status = 'verified'
    )
  );

For a destructive operation, a security invoker function gives a clean error instead of a silent zero-row result. Invoker means RLS still applies, so the policies above run on the delete as well.

create or replace function public.delete_project(p_project_id uuid)
returns void
language plpgsql
security invoker
set search_path = ''
as $$
begin
  if (select auth.jwt() ->> 'aal') is distinct from 'aal2' then
    raise exception 'step_up_required' using errcode = '42501';
  end if;

  delete from public.projects
  where id = p_project_id and owner_id = (select auth.uid());
end;
$$;

One timing note worth writing down: unenrolling a factor downgrades the session from aal2 to aal1 only after the token refresh interval passes. Call refreshSession() right after unenrolling if you need the downgrade to be immediate.

The same timing rule cuts the other way and is easy to miss in review. The aal claim lives on the access token, so a session that was aal1 when the page loaded stays aal1 in the eyes of Postgres until a new token is issued. challengeAndVerify issues one immediately, which is why the step-up action works. But if you promote a session through any other path, revalidate the layout so Server Components pick up the new cookie rather than serving a cached render built under the old assurance level.

Getting Claude Code to Build This Correctly

The failure mode when you ask Claude for two-factor auth is a generic otplib implementation with your own secrets table, ignoring that Supabase already does this. Name the API in CLAUDE.md and the shape of the answer changes.

## MFA

- Use supabase.auth.mfa for TOTP. Never implement TOTP manually.
- Enrollment: unenroll unverified factors, then enroll, then challengeAndVerify.
- Recovery codes: our own table, scrypt hashes only, RLS on with zero policies.
- Sensitive pages call requireAal2() from lib/require-aal2.ts.
- Every table behind MFA gets a restrictive RLS policy on auth.jwt()->>'aal'.
- Next.js 16: proxy.ts not middleware.ts, await params, await cookies().

Then plan before building, because the ordering constraints here are the whole game.

claude --permission-mode plan "add TOTP MFA using supabase.auth.mfa: enrollment page with QR, hashed recovery codes in their own table, requireAal2 helper with a 15 minute freshness window, and restrictive RLS policies on subscriptions and projects"

Review the plan for two things specifically. Recovery codes must be generated after challengeAndVerify succeeds, not before. And the RLS policies must be restrictive, not permissive, because a permissive aal2 policy silently grants access rather than requiring it.

Testing It

Three checks catch nearly everything.

Enroll, then open the billing page in a private window signed in with a fresh password login. You should land on /step-up, not on billing. Then verify and confirm you get redirected back to where you started.

Test the policy without the app in the way. Sign in as a user with a verified factor but do not step up, then hit the protected table straight from the browser client. The restrictive policy should return zero rows even though the ownership policy matches, which proves the enforcement lives in Postgres and not in your routing.

Then query the recovery codes table the same way. It must come back empty.

const { data } = await supabase.from('mfa_recovery_codes').select('*')
console.log(data) // []

Then run the gates before you commit.

npx tsc --noEmit && npx eslint . && npm run build

Where the Harness Helps

A feature like this is where a single Claude Code session tends to drift. The enrollment flow works, the RLS policy is quietly permissive instead of restrictive, and nothing surfaces the gap until someone bypasses the redirect. The $29 Code Kit ($29 one-time, no subscription) is a harness on top of Claude Code that runs the loop properly: plan first, build, evaluate the output, test it, then hold the work against quality gates (zero type errors, zero lint errors, clean build) before anything merges. It does not replace Claude Code, which still needs a paid Anthropic plan, and it does not write better code than Claude does. It just refuses to let a half-enforced security feature through the door.


The pattern underneath all of this is layered enforcement. proxy.ts redirects, requireAal2() guards the render, and a restrictive RLS policy makes the assurance level a property of the data rather than a property of the route. Skip any one layer and the other two still hold. That is the difference between two-factor auth that exists and two-factor auth that works.

Posted by @speedy_devv

Continue in Workflow

  • Agentic Commerce: Wie du eine App baust, für die KI-Agents bezahlen können
    Ein Guide in einfachem Deutsch zu Agentic Commerce im Jahr 2026: Was x402, ACP und das Machine Payments Protocol tun, plus eine Wochenend-Anleitung, um eine bezahlte API auszuliefern, von der KI-Agents kaufen können.
  • Claude Code Best Practices
    Fünf Gewohnheiten trennen Entwickler, die mit Claude Code liefern: PRDs, modulare CLAUDE.md-Regeln, Custom-Slash-Commands, /clear-Resets und eine System-Evolutions-Denkweise.
  • Claude Code Auto-Modus
    Ein zweites Sonnet-Modell prüft jeden Claude Code-Tool-Aufruf, bevor er ausgeführt wird. Was der Auto-Modus blockiert, was er erlaubt, und die Erlaubnisregeln, die er in deine Einstellungen schreibt.
  • Channels, Routines, Teleport, Dispatch
    Die vier Claude-Code-Features, die Anthropic im März und April 2026 ausgeliefert hat und die die CLI in eine ereignisgesteuerte Koordinationsschicht über Handy, Web und Desktop verwandeln.
  • Claude Code 1M Context in Practice: When Bigger Isn't Better
    The 1M-token context window is GA at flat pricing, but bigger isn't always better. A decision framework, token-cost math, and when to use /compact, subagents, and dynamic workflows instead.
  • How to Build an Admin Dashboard With Claude Code
    Ship an internal admin panel with Claude Code: role-gated routes, a searchable users and orders table with pagination, impersonation-safe RLS, and metrics tiles pulled through a type-safe API.

More from Handbook

  • Grundlagen für Agenten
    Fünf Möglichkeiten, spezialisierte Agenten in Claude Code zu erstellen: Aufgaben-Unteragenten, .claude/agents YAML, benutzerdefinierte Slash-Befehle, CLAUDE.md Personas und perspektivische Aufforderungen.
  • Agent-Harness-Engineering
    Der Harness ist jede Schicht rund um deinen KI-Agenten, außer dem Modell selbst. Lern die fünf Steuerungshebel, das Constraint-Paradoxon und warum das Harness-Design die Performance des Agenten mehr bestimmt als das Modell.
  • Agenten-Muster
    Orchestrator, Fan-out, Validierungskette, Spezialistenrouting, Progressive Verfeinerung und Watchdog. Sechs Orchestrierungsformen, um Claude Code Sub-Agenten zu verdrahten.
  • Agent Teams Best Practices
    Bewährte Muster für Claude Code Agent Teams. Kontextreiche Spawn-Prompts, richtig bemessene Aufgaben, Datei-Eigentümerschaft, Delegate-Modus und Fixes für v2.1.33-v2.1.45.

Sie möchten das Framework hinter diesen Projekten?

Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.

Sehen Sie, was wir für Unternehmen bauen →
speedy_devvkoen_salo

Referral Program

Build a refer-a-friend feature end to end in Next.js 16: unique referral codes, cookie plus server-side attribution, a Stripe coupon and account credit granted on the first paid invoice, self-referral and fraud guards, and a referrer dashboard.

Next.js DevTools MCP

Connect Claude Code to Next.js DevTools MCP for live runtime errors, logs, route metadata, browser verification, and safer Next.js 16 upgrades.

On this page

What Supabase Gives You and What It Does Not
The Enrollment Flow
The Enrollment UI
Hashed Recovery Codes
Redeeming a Code
Step-Up Before Billing
RLS Policies That Read the Assurance Level
Getting Claude Code to Build This Correctly
Testing It
Where the Harness Helps

Sie möchten das Framework hinter diesen Projekten?

Holen Sie sich das Claude Code System, mit dem wir produktionsreife Software planen, bauen, testen und ausliefern.

Sehen Sie, was wir für Unternehmen bauen →