Build This Now
Build This Now
O que é o Código Claude?Instalar o Claude CodeInstalador Nativo do Claude CodeO Teu Primeiro Projeto com Claude Code
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: Como Orquestrar 1.000 Subagentes Num Codebase RealMelhores Práticas do Claude CodeBoas Práticas para o Claude Opus 4.7Claude Code num VPSIntegração GitRevisão de Código com ClaudeWorktrees no Claude CodeControle Remoto do Claude CodeChannels do Claude CodeChannels, Routines, Teleport, DispatchTarefas Agendadas no Claude CodePermissões do Claude CodeModo Auto do Claude CodeAdicionar Pagamentos Stripe Com o Claude CodeFeedback LoopsFluxos de Trabalho com TodosTarefas no Claude CodeTemplates de ProjetoPreços e Consumo de Tokens no Claude CodePreços do Claude Code: O Que Vais Mesmo PagarClaude Code Ultra ReviewConstruir Uma App Next.js Com o Claude CodeSupabase DatabaseVercel DeepsecTest-Driven DevelopmentComo Construir um MVP de SaaS Com o Claude CodeAdicionar Autenticação Com o Claude Code (Supabase Auth)Adicionar Email Transacional Com o Claude Code (Resend + React Email)Construir Uma API Type-Safe Com o Claude Code (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 GuideComércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar1M ContextUser API KeysAudit LogsCSV Import PipelineDatabase MigrationsProduction Error TrackingFeature FlagsGitHub ActionsHeadless ModeMax Plan vs APICaching and RevalidationIn-App NotificationsOutbound WebhooksPrompt CachingRoles & PermissionsMarketplace PaymentsUsage-Based BillingQuanto Custa Construir um SaaS com Claude Code em 2026Parallel 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.

Quer o framework por trás destes projetos?

Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.

Veja o que construímos para empresas →
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

  • Comércio Agêntico: Como Construir uma App Que Agentes de IA Podem Pagar
    Um guia em português simples sobre comércio agêntico em 2026: o que fazem o x402, o ACP e o Machine Payments Protocol, mais um passo a passo de fim de semana para lançar uma API paga que agentes de IA podem comprar.
  • Melhores Práticas do Claude Code
    Cinco hábitos separam os engenheiros que entregam com Claude Code: PRDs, regras modulares em CLAUDE.md, slash commands personalizados, resets com /clear e uma mentalidade de evolução do sistema.
  • Modo Auto do Claude Code
    Um segundo modelo Sonnet revê cada chamada de ferramenta do Claude Code antes de ser executada. O que o modo auto bloqueia, o que permite e as regras de permissão que cria nas tuas definições.
  • Channels, Routines, Teleport, Dispatch
    As quatro funcionalidades de Claude Code que a Anthropic lançou em março e abril de 2026 e que transformam a CLI numa camada de coordenação orientada a eventos entre telemóvel, web e desktop.
  • 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

  • Fundamentos do agente
    Cinco maneiras de criar agentes especializados no Código Claude: Sub-agentes de tarefas, .claude/agents YAML, comandos de barra personalizados, personas CLAUDE.md e prompts de perspetiva.
  • Engenharia de Harness para Agentes
    O harness é cada camada ao redor do seu agente de IA, exceto o modelo em si. Aprenda os cinco pontos de controle, o paradoxo das restrições, e por que o design do harness determina o desempenho do agente mais do que o modelo.
  • Padrões de Agentes
    Orchestrator, fan-out, cadeia de validação, routing especializado, refinamento progressivo e watchdog. Seis formas de orquestração para ligar sub-agentes no Claude Code.
  • Boas Práticas para Equipas de Agentes
    Padrões testados em produção para Equipas de Agentes Claude Code. Prompts de criação ricos em contexto, tarefas bem dimensionadas, posse de ficheiros, modo delegado, e correções das versões v2.1.33-v2.1.45.

Quer o framework por trás destes projetos?

Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.

Veja o que construímos para empresas →
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

Quer o framework por trás destes projetos?

Obtenha o sistema Claude Code que usamos para planejar, construir, testar e lançar software em produção.

Veja o que construímos para empresas →