Skip to main content
Multi-factor authentication (MFA) adds an extra layer of security by requiring users to authenticate using two different methods before gaining access.

Configuration

You can enable MFA in your auth collection settings:
type MFAConfig struct {
    Enabled  bool   `json:"enabled"`
    Duration int64  `json:"duration"` // in seconds
    Rule     string `json:"rule"`     // optional filter rule
}
Default configuration:
MFA: MFAConfig{
    Enabled:  false,
    Duration: 1800, // 30 minutes
    Rule:     "",   // Apply to all users
}
MFA duration must be between 10 and 86400 seconds (24 hours). This is the time window allowed between the first and second authentication.

Requirements

To enable MFA, you must have at least 2 authentication methods enabled:
// From core/collection_model_auth_options.go:186
if o.MFA.Enabled {
    authsEnabled := 0
    if o.PasswordAuth.Enabled {
        authsEnabled++
    }
    if o.OAuth2.Enabled {
        authsEnabled++
    }
    if o.OTP.Enabled {
        authsEnabled++
    }
    if authsEnabled < 2 {
        return validation.NewError(
            "validation_mfa_not_enough_auths", 
            "MFA requires at least 2 auth methods to be enabled."
        )
    }
}
You cannot enable MFA with only one authentication method. Enable at least two of: Password, OAuth2, or OTP.

MFA authentication flow

The MFA process requires two separate authentication steps:
1

First authentication

User authenticates using any enabled method (password, OAuth2, or OTP).
curl -X POST http://localhost:8090/api/collections/users/auth-with-password \
  -H "Content-Type: application/json" \
  -d '{
    "identity": "user@example.com",
    "password": "password123"
  }'
Response (401 Unauthorized):
{
  "mfaId": "pb_mfa_abc123xyz789"
}
Instead of returning an auth token, PocketBase returns an mfaId when MFA is required.
2

Second authentication

User authenticates again using a different method, including the mfaId.
curl -X POST http://localhost:8090/api/collections/users/auth-with-otp \
  -H "Content-Type: application/json" \
  -d '{
    "otpId": "pb_otp_def456",
    "password": "12345678",
    "mfaId": "pb_mfa_abc123xyz789"
  }'
Success response (200):
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "record": {
    "id": "RECORD_ID",
    "email": "user@example.com",
    "verified": true,
    ...
  }
}

MFA model

MFA sessions are stored in the _mfas system collection:
const CollectionNameMFAs = "_mfas"

const (
    MFAMethodPassword = "password"
    MFAMethodOAuth2   = "oauth2"
    MFAMethodOTP      = "otp"
)

type MFA struct {
    *Record
}

// Core methods
func (m *MFA) CollectionRef() string
func (m *MFA) SetCollectionRef(collectionId string)
func (m *MFA) RecordRef() string
func (m *MFA) SetRecordRef(recordId string)
func (m *MFA) Method() string
func (m *MFA) SetMethod(method string)
func (m *MFA) Created() types.DateTime
func (m *MFA) HasExpired(maxElapsed time.Duration) bool

MFA check implementation

The MFA check happens in the RecordAuthResponse function:
// From apis/record_helpers.go:71
mfaId, err := checkMFA(e.RequestEvent, e.Record, e.AuthMethod)
if err != nil {
    return err
}

// Require additional authentication
if mfaId != "" {
    e.JSON(http.StatusUnauthorized, map[string]string{
        "mfaId": mfaId,
    })
    return ErrMFA
}

First-time authentication

When a user authenticates for the first time (without an mfaId), PocketBase creates an MFA session:
// From apis/record_helpers.go:210
if mfaId == "" {
    mfa := core.NewMFA(e.App)
    mfa.SetCollectionRef(authRecord.Collection().Id)
    mfa.SetRecordRef(authRecord.Id)
    mfa.SetMethod(currentAuthMethod)
    if err := e.App.Save(mfa); err != nil {
        return "", err
    }
    return mfa.Id, nil
}

Second-time authentication

When authenticating with an mfaId, PocketBase validates the MFA session:
// From apis/record_helpers.go:224
mfa, err := e.App.FindMFAById(mfaId)
if err != nil || mfa.HasExpired(authRecord.Collection().MFA.DurationTime()) {
    deleteMFA()
    return "", e.BadRequestError("Invalid or expired MFA session.", err)
}

if mfa.RecordRef() != authRecord.Id || 
   mfa.CollectionRef() != authRecord.Collection().Id {
    return "", e.BadRequestError("Invalid MFA session.", nil)
}

if mfa.Method() == currentAuthMethod {
    return "", e.BadRequestError(
        "A different authentication method is required.", nil)
}
Users must use a different authentication method for the second factor. You cannot authenticate twice with the same method.

MFA rule (selective MFA)

You can use the rule field to apply MFA only to specific users:
// Require MFA only for admin users
collection.MFA.Enabled = true
collection.MFA.Rule = "role = 'admin'"

// Require MFA for all users
collection.MFA.Enabled = true
collection.MFA.Rule = ""

// Disable MFA (even if enabled) for specific users
collection.MFA.Enabled = true
collection.MFA.Rule = "mfaExempt = false"
The rule is evaluated in the wantsMFA function:
// From apis/record_helpers.go:141
func wantsMFA(e *core.RequestEvent, record *core.Record) (bool, error) {
    rule := record.Collection().MFA.Rule
    if rule == "" {
        return true, nil
    }

    requestInfo, err := e.RequestInfo()
    if err != nil {
        return true, err
    }

    var exists int
    query := e.App.RecordQuery(record.Collection()).
        Select("(1)").
        AndWhere(dbx.HashExp{record.Collection().Name + ".id": record.Id})

    resolver := core.NewRecordFieldResolver(e.App, record.Collection(), requestInfo, true)
    expr, err := search.FilterData(rule).BuildExpr(resolver)
    if err != nil {
        return true, err
    }

    err = query.AndWhere(expr).Limit(1).Row(&exists)
    return exists > 0, nil
}
If the MFA rule evaluation fails, PocketBase defaults to requiring MFA as a security precaution.

MFA cleanup

MFA sessions are automatically cleaned up in several scenarios:

Successful authentication

After successful second-factor authentication, the MFA session is deleted:
// From apis/record_helpers.go:246
deleteMFA()
return "", nil

Expired sessions

A cron job runs every hour to delete expired MFA sessions:
// From core/mfa_model.go:125
app.Cron().Add("__pbMFACleanup__", "0 * * * *", func() {
    if err := app.DeleteExpiredMFAs(); err != nil {
        app.Logger().Warn("Failed to delete expired MFA sessions", "error", err)
    }
})

Password changes

All MFA sessions are deleted when a user changes their password:
// From core/mfa_model.go:133
app.OnRecordUpdate().Bind(&hook.Handler[*RecordEvent]{
    Func: func(e *RecordEvent) error {
        old := e.Record.Original().GetString(FieldNamePassword + ":hash")
        new := e.Record.GetString(FieldNamePassword + ":hash")
        if old != new {
            err = e.App.DeleteAllMFAsByRecord(e.Record)
            if err != nil {
                e.App.Logger().Warn(
                    "Failed to delete all previous mfas",
                    "error", err,
                )
            }
        }
        return nil
    },
})

Example MFA flows

Password + OTP

const pb = new PocketBase('http://localhost:8090');

try {
  const authData = await pb.collection('users').authWithPassword(
    'user@example.com',
    'password123'
  );
  // Success without MFA
} catch (err) {
  if (err.status === 401 && err.data?.mfaId) {
    // MFA required
    const mfaId = err.data.mfaId;
    // Proceed to step 2
  }
}

OAuth2 + Password

// OAuth2 authentication
const authData = await pb.collection('users').authWithOAuth2Code(
  'google',
  code,
  codeVerifier,
  redirectURL
);

// If MFA is required, authData will contain mfaId instead of token
if (!authData.token && authData.mfaId) {
  const mfaId = authData.mfaId;
  // Proceed to step 2
}

Passing the mfaId

You can pass the mfaId either in the request body or as a query parameter:
# In request body
curl -X POST http://localhost:8090/api/collections/users/auth-with-otp \
  -H "Content-Type: application/json" \
  -d '{
    "otpId": "pb_otp_def456",
    "password": "12345678",
    "mfaId": "pb_mfa_abc123xyz789"
  }'

# As query parameter
curl -X POST "http://localhost:8090/api/collections/users/auth-with-otp?mfaId=pb_mfa_abc123xyz789" \
  -H "Content-Type: application/json" \
  -d '{
    "otpId": "pb_otp_def456",
    "password": "12345678"
  }'
The implementation checks both locations:
// From apis/record_helpers.go:196
mfaId := e.Request.URL.Query().Get("mfaId")
if mfaId == "" {
    data := struct {
        MfaId string `form:"mfaId" json:"mfaId" xml:"mfaId"`
    }{}
    if err := e.BindBody(&data); err != nil {
        return "", err
    }
    mfaId = data.MfaId
}

Configuration example

collection, _ := app.FindCollectionByNameOrId("users")

// Enable at least 2 auth methods
collection.PasswordAuth.Enabled = true
collection.PasswordAuth.IdentityFields = []string{"email"}

collection.OTP.Enabled = true
collection.OTP.Duration = 180
collection.OTP.Length = 8

// Enable MFA
collection.MFA.Enabled = true
collection.MFA.Duration = 1800 // 30 minutes
collection.MFA.Rule = ""        // Apply to all users

app.Save(collection)

Security considerations

MFA sessions expire after the configured duration. Users must complete both authentication steps within this time window.

Best practices

  1. Short MFA duration: Keep the MFA session duration reasonable (15-30 minutes) to minimize the risk of session hijacking.
  2. Clear user communication: Inform users that they need to authenticate twice and explain why.
  3. Selective MFA: Use the MFA rule to apply MFA only to high-privilege accounts if needed.
  4. Monitor MFA usage: Track MFA authentications using hooks to detect unusual patterns.
  5. Backup authentication: Ensure users have access to multiple authentication methods in case one fails.

Custom MFA hooks

You can customize MFA behavior using the auth hooks:
app.OnRecordAuthRequest().Bind(&hook.Handler[*core.RecordAuthRequestEvent]{
    Func: func(e *core.RecordAuthRequestEvent) error {
        // Track MFA completions
        if e.AuthMethod != "" && e.Collection.MFA.Enabled {
            log.Printf("MFA auth completed for user %s with method %s", 
                e.Record.Email(), e.AuthMethod)
        }
        
        return e.Next()
    },
})

Error handling

Handle MFA-related errors appropriately:
try {
  const authData = await pb.collection('users').authWithPassword(
    email,
    password
  );
  
  if (authData.mfaId) {
    // MFA required - show second factor UI
    showMFAPrompt(authData.mfaId);
  } else {
    // Normal authentication - user is logged in
    handleSuccessfulLogin(authData);
  }
} catch (error) {
  if (error.status === 401 && error.data?.mfaId) {
    // MFA required (alternative check)
    showMFAPrompt(error.data.mfaId);
  } else if (error.message.includes('different authentication method')) {
    // User tried to use the same method twice
    showError('Please use a different authentication method');
  } else if (error.message.includes('expired MFA session')) {
    // MFA session expired
    showError('Session expired. Please start over.');
  } else {
    // Other authentication errors
    showError(error.message);
  }
}

Email/Password auth

Configure password authentication

OAuth2

Set up social login

OTP

Enable one-time passwords

API rules

Configure access control