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:
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.
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
Step 1: Password
Step 2: 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
}
}
// 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 );
OAuth2 + Password
Step 1: OAuth2
Step 2: 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
}
// Authenticate with password including mfaId
const authData = await pb . collection ( 'users' ). authWithPassword (
'user@example.com' ,
'password123' ,
{ mfaId: mfaId }
);
console . log ( authData . token );
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
Short MFA duration : Keep the MFA session duration reasonable (15-30 minutes) to minimize the risk of session hijacking.
Clear user communication : Inform users that they need to authenticate twice and explain why.
Selective MFA : Use the MFA rule to apply MFA only to high-privilege accounts if needed.
Monitor MFA usage : Track MFA authentications using hooks to detect unusual patterns.
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