SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Getting Started
Installation
Configuration
Deployment

Components

  • Hero
  • Pricing
  • Features

Features

  • Authentication
  • Payments
  • Emails
  • Database
  • Blog
  • Security Headers
  • Claude Code Skills

Recipes

  • Add a Server Action
  • Add a Database Table
  • Add an OAuth Provider
  • Add an Email Template
  • Customize the Auth Flow
  • Add an Admin Metric

Customize the Auth Flow

Capture custom signup fields, require terms acceptance, gate signup by email allowlist, or add a post-signup onboarding step.

What you are building

A customized signup or login flow. Common asks: capture extra fields at signup (company name, role), require terms-of-service acceptance, restrict signups to an email allowlist or company domain, or redirect new users to an onboarding flow after first login.

The default auth lives in actions/auth.ts and the forms in components/forms/. The patterns below extend without rewriting.

Capturing extra fields at signup

The default signup action takes email, password, and full name. To capture additional fields:

Step 1: extend the Zod schema and form

// actions/auth.ts
const signupSchema = z.object({
  email: z.email(),
  password: z.string().min(8),
  fullName: z.string().min(1),
  companyName: z.string().min(1),
  role: z.enum(['founder', 'developer', 'other']),
})

export async function signup(formData: FormData) {
  const parsed = signupSchema.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
    fullName: formData.get('fullName'),
    companyName: formData.get('companyName'),
    role: formData.get('role'),
  })

  if (!parsed.success) {
    return { error: 'Invalid input' }
  }

  // ... existing Supabase signUp call ...
}

Step 2: store the extra fields

The profiles table is auto-created by a database trigger when a user signs up. To capture custom fields, either:

  • Add columns to profiles (recommended for solo founder + handful of fields). Add the column to supabase/schema.sql, regenerate types, then insert the values from the signup action right after the user is created.
  • Use Supabase user metadata for one-off fields that do not need to be queryable. Pass them in the signUp options.data:
const { data, error } = await supabase.auth.signUp({
  email: parsed.data.email,
  password: parsed.data.password,
  options: {
    data: {
      full_name: parsed.data.fullName,
      company_name: parsed.data.companyName,
      role: parsed.data.role,
    },
  },
})

options.data lands in auth.users.raw_user_meta_data and is accessible via user.user_metadata. Use this for display-only fields; for anything you'll filter or query on, use a column on profiles.

Step 3: extend the form component

In components/forms/signup-form.tsx, add the new fields as inputs with matching name attributes:

<input name="companyName" placeholder="Company name" required />
<select name="role" required>
  <option value="founder">Founder</option>
  <option value="developer">Developer</option>
  <option value="other">Other</option>
</select>

Requiring terms-of-service acceptance

Add a checkbox to the signup form and validate it in the Zod schema:

const signupSchema = z.object({
  // ... existing fields ...
  acceptTerms: z.literal('on', {
    errorMap: () => ({ message: 'You must accept the terms' }),
  }),
})
<label>
  <input name="acceptTerms" type="checkbox" required />
  I accept the <a href="/terms">Terms of Service</a> and{' '}
  <a href="/privacy">Privacy Policy</a>
</label>

For legal compliance, also store the acceptance timestamp on the user's profile so you can prove the user accepted at a specific moment:

ALTER TABLE public.profiles
  ADD COLUMN terms_accepted_at timestamptz;

Then in the signup Server Action, after the user is created:

await admin
  .from('profiles')
  .update({ terms_accepted_at: new Date().toISOString() })
  .eq('id', user.id)

Gating signup by email allowlist or domain

For closed-beta or company-internal apps:

const ALLOWED_DOMAINS = ['yourcompany.com']
const ALLOWED_EMAILS = new Set(['founder@example.com'])

export async function signup(formData: FormData) {
  const parsed = signupSchema.safeParse({ /* ... */ })
  if (!parsed.success) return { error: 'Invalid input' }

  const email = parsed.data.email.toLowerCase()
  const domain = email.split('@')[1]

  const isAllowed =
    ALLOWED_EMAILS.has(email) || ALLOWED_DOMAINS.includes(domain)

  if (!isAllowed) {
    return { error: 'Signups are currently invite-only' }
  }

  // ... continue with Supabase signUp ...
}

For larger allowlists, store them in a database table (signup_allowlist with an email or domain column) and query it instead. For company SSO, configure SAML in Supabase Auth and skip the allowlist entirely.

Post-signup onboarding redirect

To send new users to an onboarding flow after first login (instead of straight to the dashboard), add an onboarding_completed_at column to profiles:

ALTER TABLE public.profiles
  ADD COLUMN onboarding_completed_at timestamptz;

Then check it in proxy.ts (or in the dashboard layout):

// proxy.ts (relevant section)
const user = await getUser()

if (user && pathname.startsWith('/dashboard')) {
  const admin = createAdminClient()
  const { data: profile } = await admin
    .from('profiles')
    .select('onboarding_completed_at')
    .eq('id', user.id)
    .single()

  if (!profile?.onboarding_completed_at) {
    return NextResponse.redirect(new URL('/onboarding', request.url))
  }
}

The /onboarding route is a separate page where you collect whatever first-run info you need (company info, team size, integrations). Mark onboarding_completed_at when the user finishes:

await admin
  .from('profiles')
  .update({ onboarding_completed_at: new Date().toISOString() })
  .eq('id', user.id)

Common mistakes

  • Trusting client-controlled fields for authorization. Email allowlists, terms acceptance, and role choices submitted via form are all client-controlled. Validate them server-side; the form can be tampered with.
  • Storing TOS acceptance only in client state. Acceptance needs to be persisted server-side with a timestamp. Legal teams will ask for proof a specific user accepted on a specific date.
  • Adding onboarding-gating in client components instead of the proxy. Client-side redirects can be bypassed by disabling JavaScript or navigating directly. Always enforce gating in proxy.ts (middleware) or in Server Components.
  • Skipping rate limiting on the signup endpoint. Signup is a public endpoint and a common abuse target (mass account creation for spam, billing exhaustion). The default signup action already applies rate limiting; do not remove it when customizing.

What to read next

  • Authentication feature docs for the broader auth surface.
  • Add a Server Action for the canonical mutation pattern.
  • Multi-tenancy and RBAC in Supabase if your customization involves team or tenant-scoped signup.