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 imagesecret: The TOTP secret (for manual entry)uri: An otpauth:// URIid: 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:
- Enrollment flow: Users can successfully scan QR codes and verify initial setup
- Login flow: MFA challenge appears for enrolled users
- AAL enforcement: Protected routes/data require AAL2
- Edge cases: Handle failed verifications, rate limiting, and factor management
- 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.
