> ## Documentation Index
> Fetch the complete documentation index at: https://mintlify.com/pocketbase/pocketbase/llms.txt
> Use this file to discover all available pages before exploring further.

# Multi-factor authentication (MFA)

> Enable multi-factor authentication requiring two different auth methods in PocketBase

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:

```go theme={null}
type MFAConfig struct {
    Enabled  bool   `json:"enabled"`
    Duration int64  `json:"duration"` // in seconds
    Rule     string `json:"rule"`     // optional filter rule
}
```

**Default configuration:**

```go theme={null}
MFA: MFAConfig{
    Enabled:  false,
    Duration: 1800, // 30 minutes
    Rule:     "",   // Apply to all users
}
```

<Note>
  MFA duration must be between 10 and 86400 seconds (24 hours). This is the time window allowed between the first and second authentication.
</Note>

## Requirements

To enable MFA, you must have at least 2 authentication methods enabled:

```go theme={null}
// 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."
        )
    }
}
```

<Warning>
  You cannot enable MFA with only one authentication method. Enable at least two of: Password, OAuth2, or OTP.
</Warning>

## MFA authentication flow

The MFA process requires two separate authentication steps:

<Steps>
  <Step title="First authentication">
    User authenticates using any enabled method (password, OAuth2, or OTP).

    ```bash theme={null}
    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):**

    ```json theme={null}
    {
      "mfaId": "pb_mfa_abc123xyz789"
    }
    ```

    <Info>
      Instead of returning an auth token, PocketBase returns an `mfaId` when MFA is required.
    </Info>
  </Step>

  <Step title="Second authentication">
    User authenticates again using a **different** method, including the `mfaId`.

    ```bash theme={null}
    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):**

    ```json theme={null}
    {
      "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
      "record": {
        "id": "RECORD_ID",
        "email": "user@example.com",
        "verified": true,
        ...
      }
    }
    ```
  </Step>
</Steps>

## MFA model

MFA sessions are stored in the `_mfas` system collection:

```go theme={null}
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:

```go theme={null}
// 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:

```go theme={null}
// 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:

```go theme={null}
// 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)
}
```

<Note>
  Users must use a **different** authentication method for the second factor. You cannot authenticate twice with the same method.
</Note>

## MFA rule (selective MFA)

You can use the `rule` field to apply MFA only to specific users:

```go theme={null}
// 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:

```go theme={null}
// 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
}
```

<Info>
  If the MFA rule evaluation fails, PocketBase defaults to requiring MFA as a security precaution.
</Info>

## MFA cleanup

MFA sessions are automatically cleaned up in several scenarios:

### Successful authentication

After successful second-factor authentication, the MFA session is deleted:

```go theme={null}
// From apis/record_helpers.go:246
deleteMFA()
return "", nil
```

### Expired sessions

A cron job runs every hour to delete expired MFA sessions:

```go theme={null}
// 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:

```go theme={null}
// 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

<Tabs>
  <Tab title="Step 1: Password">
    ```javascript theme={null}
    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
      }
    }
    ```
  </Tab>

  <Tab title="Step 2: OTP">
    ```javascript theme={null}
    // Request OTP
    const { otpId } = await pb.collection('users').requestOTP('user@example.com');

    // Get code from user
    const code = prompt('Enter the code from your email:');

    // Authenticate with OTP and mfaId
    const authData = await pb.collection('users').authWithOTP(
      otpId,
      code,
      { mfaId: mfaId } // Include mfaId in body or query params
    );

    console.log(authData.token);
    ```
  </Tab>
</Tabs>

### OAuth2 + Password

<Tabs>
  <Tab title="Step 1: OAuth2">
    ```javascript theme={null}
    // 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
    }
    ```
  </Tab>

  <Tab title="Step 2: Password">
    ```javascript theme={null}
    // Authenticate with password including mfaId
    const authData = await pb.collection('users').authWithPassword(
      'user@example.com',
      'password123',
      { mfaId: mfaId }
    );

    console.log(authData.token);
    ```
  </Tab>
</Tabs>

## Passing the mfaId

You can pass the `mfaId` either in the request body or as a query parameter:

```bash theme={null}
# 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:

```go theme={null}
// 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

```go theme={null}
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

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

### 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:

```go theme={null}
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:

```javascript theme={null}
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);
  }
}
```

## Related topics

<CardGroup cols={2}>
  <Card title="Email/Password auth" icon="envelope" href="/auth/email-password">
    Configure password authentication
  </Card>

  <Card title="OAuth2" icon="globe" href="/auth/oauth2">
    Set up social login
  </Card>

  <Card title="OTP" icon="key" href="/auth/otp">
    Enable one-time passwords
  </Card>

  <Card title="API rules" icon="shield" href="/auth/api-rules">
    Configure access control
  </Card>
</CardGroup>
