Multi-Factor Authentication for Self-Hosted Supabase: TOTP Setup

Learn how to configure TOTP-based multi-factor authentication for your self-hosted Supabase instance with step-by-step setup and implementation.

Cover Image for Multi-Factor Authentication for Self-Hosted Supabase: TOTP Setup

Security-conscious teams running self-hosted Supabase often need multi-factor authentication to meet compliance requirements or protect sensitive data. While MFA is enabled by default in Supabase Auth, understanding how to configure, implement, and enforce it properly requires careful attention to detail.

This guide walks you through setting up TOTP-based multi-factor authentication for your self-hosted Supabase instance, covering server configuration, client implementation, and database-level enforcement.

Understanding Supabase MFA

Supabase Auth supports three MFA factor types, with TOTP (Time-based One-Time Password) being the most commonly used. TOTP works with authenticator apps like Google Authenticator, Authy, 1Password, and Apple's Keychain.

Authenticator Assurance Levels

Supabase uses Authenticator Assurance Levels (AAL) to indicate identity verification strength:

  • AAL1: User verified through conventional login (email/password, magic link, OAuth, phone auth)
  • AAL2: User additionally verified using at least one second factor (TOTP code)

These levels appear in JWT claims, allowing you to enforce different access levels based on authentication strength.

What's Enabled by Default

On self-hosted Supabase instances, TOTP is enabled by default. Users can immediately enroll with authenticator apps without additional configuration. However, MFA isn't enforced—it's opt-in unless you build enforcement into your application.

Configuring MFA on Self-Hosted Supabase

While TOTP works out of the box, you may need to adjust settings for your specific requirements.

Environment Variables

In your .env file, the MFA-related variables control behavior:

# Maximum MFA factors a user can enroll (default: 10)
GOTRUE_MFA_MAX_ENROLLED_FACTORS=10

# Minimum verification attempts before rate limiting
GOTRUE_MFA_RATE_LIMIT_CHALLENGE_AND_VERIFY=15

# Enable/disable TOTP (enabled by default)
GOTRUE_MFA_TOTP_ENROLL_ENABLED=true
GOTRUE_MFA_TOTP_VERIFY_ENABLED=true

Docker Compose Configuration

Ensure the corresponding settings are uncommented in your docker-compose.yml under the auth service:

auth:
  environment:
    GOTRUE_MFA_MAX_ENROLLED_FACTORS: ${GOTRUE_MFA_MAX_ENROLLED_FACTORS}
    GOTRUE_MFA_TOTP_ENROLL_ENABLED: ${GOTRUE_MFA_TOTP_ENROLL_ENABLED}
    GOTRUE_MFA_TOTP_VERIFY_ENABLED: ${GOTRUE_MFA_TOTP_VERIFY_ENABLED}

After making changes, restart the auth service:

docker compose restart auth

Implementing MFA in Your Application

The MFA flow involves three phases: enrollment, challenge, and verification.

Step 1: Enrolling a TOTP Factor

When a user wants to enable MFA, call the enroll method to generate a QR code:

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

if (error) {
  console.error('Enrollment failed:', error.message)
  return
}

// data.totp contains the QR code and secret
const { qr, secret, uri } = data.totp
const factorId = data.id

// Display QR code to user
// <img src={qr} alt="Scan with authenticator app" />

The response includes:

  • qr: A data URL for the QR code image
  • secret: The TOTP secret (for manual entry)
  • uri: An otpauth:// URI
  • id: The factor ID needed for subsequent operations

Step 2: Verifying the Initial Setup

After the user scans the QR code, verify their first code to activate the factor:

// Create a challenge
const { data: challengeData, error: challengeError } = 
  await supabase.auth.mfa.challenge({
    factorId: factorId
  })

if (challengeError) {
  console.error('Challenge failed:', challengeError.message)
  return
}

// Verify the code from the user's authenticator app
const { data: verifyData, error: verifyError } = 
  await supabase.auth.mfa.verify({
    factorId: factorId,
    challengeId: challengeData.id,
    code: userEnteredCode // The 6-digit code
  })

if (verifyError) {
  console.error('Verification failed:', verifyError.message)
  return
}

// Factor is now active!

Step 3: Handling Login with MFA

After a user signs in, check if they need to complete MFA:

const { data: { currentLevel, nextLevel } } = 
  await supabase.auth.mfa.getAuthenticatorAssuranceLevel()

if (currentLevel === 'aal1' && nextLevel === 'aal2') {
  // User has MFA enabled, redirect to MFA verification
  redirectToMfaVerification()
}

During verification at login:

// Get the user's enrolled factors
const { data: factors } = await supabase.auth.mfa.listFactors()

// Find an active TOTP factor
const totpFactor = factors.totp.find(f => f.status === 'verified')

if (totpFactor) {
  // Challenge and verify as shown above
  const { data: challenge } = await supabase.auth.mfa.challenge({
    factorId: totpFactor.id
  })
  
  const { error } = await supabase.auth.mfa.verify({
    factorId: totpFactor.id,
    challengeId: challenge.id,
    code: userEnteredCode
  })
  
  if (!error) {
    // User is now at AAL2
    redirectToDashboard()
  }
}

Enforcing MFA with Row Level Security

For sensitive data, you can enforce MFA at the database level using Row Level Security policies. This ensures that even if your application logic has bugs, the database won't expose protected data without AAL2 verification.

Creating AAL-Aware Policies

-- Function to check current AAL level
create or replace function auth.current_aal()
returns text as $$
  select 
    coalesce(
      current_setting('request.jwt.claims', true)::json->>'aal',
      'aal1'
    )
$$ language sql stable;

-- Policy requiring AAL2 for sensitive data
create policy "Require MFA for sensitive data"
on sensitive_table
for all
using (
  auth.uid() = user_id 
  and auth.current_aal() = 'aal2'
);

Tiered Access Policies

You can create different policies for different AAL levels:

-- Basic access at AAL1
create policy "Basic read access"
on user_profiles
for select
using (auth.uid() = user_id);

-- Write access requires AAL2
create policy "Write requires MFA"
on user_profiles
for update
using (
  auth.uid() = user_id 
  and auth.current_aal() = 'aal2'
);

-- Financial data always requires AAL2
create policy "Financial data requires MFA"
on transactions
for all
using (
  auth.uid() = user_id 
  and auth.current_aal() = 'aal2'
);

For more on securing your database, see our guide on Row Level Security for self-hosted Supabase.

Common Pitfalls and Solutions

Unverified Factor Accumulation

A known issue: if users start enrollment but don't complete verification, unverified factors accumulate. After reaching the limit (default 10), they can't enroll new factors.

Solution: Implement cleanup logic or use unenroll to remove incomplete factors:

// List factors and remove unverified ones
const { data: factors } = await supabase.auth.mfa.listFactors()

for (const factor of factors.totp) {
  if (factor.status === 'unverified') {
    await supabase.auth.mfa.unenroll({ factorId: factor.id })
  }
}

Friendly Name Conflicts

Using email or username as the friendly name can cause 500 errors if the same user creates multiple factors.

Solution: Generate unique friendly names:

const friendlyName = `Authenticator-${Date.now()}`

Clock Skew Issues

TOTP codes are time-sensitive. If your server's clock drifts, valid codes may be rejected.

Solution: Ensure NTP is configured on your server:

# Check time synchronization
timedatectl status

# Enable NTP if needed
sudo timedatectl set-ntp true

Recovery Without Recovery Codes

Supabase doesn't currently support recovery codes. If users lose access to their authenticator app, they're locked out.

Solution: Allow users to enroll multiple factors (up to 10). Encourage backup methods:

// During enrollment, suggest backup options
const { data: factors } = await supabase.auth.mfa.listFactors()

if (factors.totp.length === 1) {
  showMessage('Consider adding a backup authenticator app')
}

For enterprise needs, consider implementing SAML SSO which typically includes organization-level recovery options.

Enforcement Strategies

Opt-In MFA

Users choose whether to enable MFA. This is the default behavior—you provide the UI, users decide.

Mandatory MFA for New Users

Require MFA enrollment during onboarding:

// After sign-up, redirect to MFA enrollment
const { data: { user } } = await supabase.auth.signUp({
  email,
  password
})

// Check if user has MFA enrolled
const { data: factors } = await supabase.auth.mfa.listFactors()

if (factors.totp.length === 0) {
  redirectToMfaEnrollment()
}

Organization-Wide Enforcement

For team accounts, enforce MFA at the application level:

async function checkMfaRequirement(userId: string) {
  // Check organization settings
  const { data: org } = await supabase
    .from('organization_members')
    .select('organizations(require_mfa)')
    .eq('user_id', userId)
    .single()
  
  if (org?.organizations?.require_mfa) {
    const { data: { currentLevel } } = 
      await supabase.auth.mfa.getAuthenticatorAssuranceLevel()
    
    if (currentLevel !== 'aal2') {
      redirectToMfaVerification()
    }
  }
}

Testing Your MFA Implementation

Before deploying to production, verify:

  1. Enrollment flow: Users can successfully scan QR codes and verify initial setup
  2. Login flow: MFA challenge appears for enrolled users
  3. AAL enforcement: Protected routes/data require AAL2
  4. Edge cases: Handle failed verifications, rate limiting, and factor management
  5. Multiple factors: Users can enroll and use backup authenticators

Monitoring MFA Usage

Track MFA adoption and issues by querying the auth schema:

-- MFA enrollment statistics
select 
  count(distinct user_id) as users_with_mfa,
  count(*) as total_factors,
  avg(count(*)) over () as avg_factors_per_user
from auth.mfa_factors
where status = 'verified';

-- Recent MFA verifications
select 
  user_id,
  created_at,
  factor_id
from auth.mfa_challenges
where created_at > now() - interval '24 hours'
order by created_at desc;

For comprehensive monitoring, see our guide on monitoring self-hosted Supabase.

Conclusion

MFA adds a critical security layer to your self-hosted Supabase instance. While TOTP is enabled by default, proper implementation requires:

  • Understanding AAL levels and how they appear in JWTs
  • Building enrollment, challenge, and verification flows in your application
  • Enforcing MFA through RLS policies for sensitive data
  • Handling edge cases like unverified factors and recovery scenarios

For organizations with strict compliance requirements—whether GDPR, HIPAA, or SOC 2—MFA enforcement is often mandatory. Self-hosting gives you full control over the authentication configuration and audit logging needed to demonstrate compliance.

If you're looking to simplify your self-hosted Supabase management, including authentication configuration and security monitoring, Supascale provides a unified dashboard for managing multiple projects with proper security controls.


Further Reading