Skip to main content
OTP (One-Time Password) authentication allows users to sign in using a temporary code sent to their email, providing a passwordless authentication experience.

Configuration

You can enable OTP authentication in your auth collection settings:
type OTPConfig struct {
    Enabled       bool          `json:"enabled"`
    Duration      int64         `json:"duration"`      // in seconds
    Length        int           `json:"length"`        // password length
    EmailTemplate EmailTemplate `json:"emailTemplate"`
}
Default configuration:
OTP: OTPConfig{
    Enabled:  false,
    Duration: 180,  // 3 minutes
    Length:   8,    // 8-digit code
    EmailTemplate: defaultOTPTemplate,
}
OTP duration must be between 10 and 86400 seconds (24 hours). The minimum password length is 4 characters.

OTP authentication flow

The OTP authentication process consists of two steps:
1

Request OTP

User submits their email to request an OTP code.
curl -X POST http://localhost:8090/api/collections/users/request-otp \
  -H "Content-Type: application/json" \
  -d '{"email": "test@example.com"}'
Response (200):
{
  "otpId": "pb_otp_abc123xyz789"
}
The API always returns a 200 response with an otpId (real or dummy) to prevent email enumeration attacks.
2

Receive email

User receives an email with the OTP code (usually 8 digits by default).
3

Authenticate with OTP

User submits the OTP ID and password to complete authentication.
curl -X POST http://localhost:8090/api/collections/users/auth-with-otp \
  -H "Content-Type: application/json" \
  -d '{
    "otpId": "pb_otp_abc123xyz789",
    "password": "12345678"
  }'
Success response (200):
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "record": {
    "id": "RECORD_ID",
    "email": "test@example.com",
    "verified": true,
    ...
  }
}

Request OTP endpoint

POST /api/collections/{collection}/request-otp
curl -X POST http://localhost:8090/api/collections/users/request-otp \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'
Request validation:
type createOTPForm struct {
    Email string `json:"email"` // Required, 1-255 chars, valid email format
}

Rate limiting and abuse prevention

PocketBase implements several protections against OTP abuse:
  1. Maximum active OTPs: Users can have a maximum of 10 non-expired OTPs. After that, the last issued OTP is reused:
// From apis/record_auth_otp_request.go:75
if totalRecent > 9 {
    otp = otps[0] // Reuse the last issued OTP
    e.App.Logger().Warn(
        "Too many OTP requests - reusing the last issued",
        "email", form.Email,
        "recordId", e.Record.Id,
    )
}
  1. Background email sending: OTP emails are sent in the background to prevent timing attacks:
// From apis/record_auth_otp_request.go:103
routine.FireAndForget(func() {
    err = mails.SendRecordOTP(originalApp, e.Record, otp.Id, e.Password)
    if err != nil {
        originalApp.Logger().Error("Failed to send OTP email", "error", err)
    }
})
  1. Dummy responses: Returns a fake otpId if the email doesn’t exist to prevent enumeration:
// From apis/record_auth_otp_request.go:53
if e.Record == nil {
    e.JSON(http.StatusOK, map[string]string{
        "otpId": core.GenerateDefaultRandomId(),
    })
    return fmt.Errorf("missing or invalid auth record")
}

Authenticate with OTP endpoint

POST /api/collections/{collection}/auth-with-otp
curl -X POST http://localhost:8090/api/collections/users/auth-with-otp \
  -H "Content-Type: application/json" \
  -d '{
    "otpId": "pb_otp_abc123xyz789",
    "password": "12345678"
  }'
Request body:
type authWithOTPForm struct {
    OTPId    string `json:"otpId"`    // Required, 1-255 characters
    Password string `json:"password"` // Required, 1-71 characters
}

Validation and security

The OTP authentication endpoint performs several security checks:
// From apis/record_auth_with_otp.go:38
event.OTP, err = e.App.FindOTPById(form.OTPId)
if err != nil {
    return e.BadRequestError("Invalid or expired OTP", err)
}

if event.OTP.CollectionRef() != collection.Id {
    return e.BadRequestError("Invalid or expired OTP", 
        errors.New("the OTP is for a different collection"))
}

if event.OTP.HasExpired(collection.OTP.DurationTime()) {
    return e.BadRequestError("Invalid or expired OTP", 
        errors.New("the OTP is expired"))
}
Extra rate limiting: OTP validation has an additional rate limit to prevent brute force attacks:
// From apis/record_auth_with_otp.go:57
err = checkRateLimit(e, "@pb_otp_"+event.Record.Id, 
    core.RateLimitRule{MaxRequests: 5, Duration: 180})
if err != nil {
    return e.TooManyRequestsError(
        "Too many attempts, please try again later with a new OTP.", nil)
}
Users are limited to 5 OTP validation attempts per 3 minutes per record. After exceeding the limit, they must request a new OTP.

Email verification

When a user successfully authenticates with an OTP sent to their email, PocketBase automatically verifies their email if it matches:
// From apis/record_auth_with_otp.go:71
otpSentTo := e.OTP.SentTo()
if !e.Record.Verified() && otpSentTo != "" && e.Record.Email() == otpSentTo {
    e.Record.SetVerified(true)
    err = e.App.Save(e.Record)
    if err != nil {
        e.App.Logger().Error(
            "Failed to update record verified state after successful OTP validation",
            "error", err,
            "otpId", e.OTP.Id,
        )
    }
}

OTP cleanup

After successful authentication, the OTP is automatically deleted:
// From apis/record_auth_with_otp.go:85
err = e.App.Delete(e.OTP)
if err != nil {
    e.App.Logger().Error("Failed to delete used OTP", "error", err)
}

Email template customization

You can customize the OTP email template:
type EmailTemplate struct {
    Subject string `json:"subject"`
    Body    string `json:"body"`
}
Available placeholders:
  • {APP_NAME} - Your application name
  • {APP_URL} - Your application URL
  • {OTP} - The one-time password
  • {OTP_ID} - The OTP record ID
Example template:
Subject: Your login code for {APP_NAME}

Body:
Hello,

Your one-time login code is: {OTP}

This code will expire in 3 minutes.

If you didn't request this code, please ignore this email.

Thanks,
The {APP_NAME} Team

OTP model

The OTP record is stored in the _otps system collection:
type OTP struct {
    *Record
}

// Core methods
func (o *OTP) CollectionRef() string
func (o *OTP) SetCollectionRef(collectionId string)
func (o *OTP) RecordRef() string
func (o *OTP) SetRecordRef(recordId string)
func (o *OTP) SentTo() string
func (o *OTP) SetSentTo(email string)
func (o *OTP) ValidatePassword(password string) bool
func (o *OTP) SetPassword(password string)
func (o *OTP) HasExpired(maxElapsed time.Duration) bool

Integration with MFA

OTP can be used as one of the authentication methods for multi-factor authentication:
// From apis/record_auth_with_otp.go:90
return RecordAuthResponse(e.RequestEvent, e.Record, core.MFAMethodOTP, nil)
If MFA is enabled, users can authenticate first with password, then with OTP as the second factor.

Use cases

Passwordless authentication

OTP is ideal for applications that want to offer passwordless authentication:
// Request OTP
const { otpId } = await pb.collection('users').requestOTP('user@example.com');

// Prompt user for the code they received
const code = prompt('Enter the code from your email:');

// Authenticate
const authData = await pb.collection('users').authWithOTP(otpId, code);
You can implement magic link authentication by encoding the OTP in the email link:
Click here to login: https://yourdomain.com/auth?otpId={OTP_ID}&code={OTP}

Account recovery

OTP can serve as an alternative recovery method when users forget their password.

Security best practices

OTP codes should be random and unpredictable. By default, PocketBase uses 8-digit numeric codes.
  1. Short expiration: Keep OTP duration short (3-10 minutes) to minimize the attack window.
  2. One-time use: OTPs are automatically deleted after successful authentication.
  3. Rate limiting: Built-in rate limiting prevents brute force attacks on OTP validation.
  4. HTTPS only: Always use HTTPS to prevent OTP interception.
  5. Consider longer codes: For high-security applications, increase the OTP length beyond 8 characters.

Custom OTP hooks

You can customize OTP behavior using event hooks:
// Customize OTP creation
app.OnRecordRequestOTPRequest().Bind(&hook.Handler[*core.RecordCreateOTPRequestEvent]{
    Func: func(e *core.RecordCreateOTPRequestEvent) error {
        // Use a custom OTP format (e.g., alphanumeric)
        e.Password = security.RandomString(10)
        
        // Add custom validation
        if e.Record.GetBool("otpDisabled") {
            return errors.New("OTP authentication is disabled for this account")
        }
        
        return e.Next()
    },
})

// Track OTP usage
app.OnRecordAuthWithOTPRequest().Bind(&hook.Handler[*core.RecordAuthWithOTPRequestEvent]{
    Func: func(e *core.RecordAuthWithOTPRequestEvent) error {
        log.Printf("OTP auth for user: %s", e.Record.Email())
        return e.Next()
    },
})

Configuration example

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

// Enable OTP authentication
collection.OTP.Enabled = true
collection.OTP.Duration = 300 // 5 minutes
collection.OTP.Length = 10    // 10-digit codes

// Customize email template
collection.OTP.EmailTemplate.Subject = "Your login code"
collection.OTP.EmailTemplate.Body = `
Hi there,

Your verification code is: {OTP}

This code expires in 5 minutes.

Best regards,
Your App
`

app.Save(collection)

Email/Password auth

Traditional password authentication

OAuth2

Social login providers

MFA

Use OTP as second factor

Email templates

Customize email templates