Authsome

Multi-Factor Authentication

Add TOTP, SMS OTP, and recovery codes as a second authentication factor with the MFA plugin.

The mfa plugin adds a second authentication factor to any sign-in flow. It supports TOTP (Time-based One-Time Passwords) for authenticator apps, SMS OTP for phone-based verification, and one-time recovery codes as a fallback. MFA is enforced after primary authentication succeeds — the user signs in with their password (or other primary method), receives an MFA challenge, and must complete it before a session is issued.

Setup

import (
    "github.com/xraph/authsome"
    "github.com/xraph/authsome/plugins/mfa"
    "github.com/xraph/authsome/bridge/smsadapter"
)

eng, err := authsome.New(
    authsome.WithStore(store),
    authsome.WithPlugin(password.New()),
    authsome.WithMFAStore(mfaStore),       // mfa.Store implementation
    authsome.WithSMSSender(smsSender),      // required for SMS OTP
    authsome.WithConfig(authsome.Config{
        AppID: "myapp",
        MFA: authsome.MFAConfig{
            Enabled:            true,
            Issuer:             "MyApp",          // shown in authenticator apps
            RecoveryCodeCount:  8,                // number of backup codes
            SMSCodeLength:      6,                // digits in SMS codes
            SMSCodeTTL:         "5m",             // SMS code validity window
            EnforceForAllUsers: false,            // true = require MFA for every user
        },
    }),
)

Note: The MFA plugin requires a dedicated mfa.Store for enrollment and recovery code persistence. All store backends (PostgreSQL, SQLite, MongoDB, memory) ship with MFA store implementations.

Concepts

Enrollment

An Enrollment represents a user's MFA registration for a specific method. Each user can have one enrollment per method (e.g., one TOTP enrollment and one SMS enrollment).

type Enrollment struct {
    ID        id.MFAID  `json:"id"`
    UserID    id.UserID `json:"user_id"`
    Method    string    `json:"method"`   // "totp" or "sms"
    Secret    string    `json:"-"`        // hidden from JSON serialization
    Verified  bool      `json:"verified"` // true after first successful verification
    CreatedAt time.Time `json:"created_at"`
    UpdatedAt time.Time `json:"updated_at"`
}

The Secret field stores the Base32-encoded TOTP secret (for TOTP enrollments) or is empty for SMS enrollments. It is never exposed in JSON responses.

Recovery codes

Recovery codes are single-use backup codes that allow account access when the primary MFA device is unavailable. They are bcrypt-hashed before storage — only the plaintext codes are shown to the user once, at generation time.

type RecoveryCode struct {
    ID        id.RecoveryCodeID `json:"id"`
    UserID    id.UserID         `json:"user_id"`
    CodeHash  string            `json:"-"`    // bcrypt hash
    Used      bool              `json:"used"`
    UsedAt    *time.Time        `json:"used_at,omitempty"`
    CreatedAt time.Time         `json:"created_at"`
}

By default, 8 recovery codes are generated per enrollment. Each code is 8 characters long, drawn from an unambiguous alphabet (abcdefghjkmnpqrstuvwxyz23456789 — no 0, o, 1, l, i).

TOTP setup flow

TOTP (Time-based One-Time Password) works with authenticator apps such as Google Authenticator, Authy, and 1Password. The setup flow is a two-step process: enroll, then verify.

Enroll: Generate a TOTP secret and return the provisioning URI.

POST /v1/auth/mfa/enroll

{
  "method": "totp"
}

Response:

{
  "enrollment": {
    "id": "amfa_01jb...",
    "method": "totp",
    "verified": false,
    "created_at": "2024-11-01T10:00:00Z"
  },
  "totp_uri": "otpauth://totp/MyApp:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp",
  "totp_secret": "JBSWY3DPEHPK3PXP",
  "recovery_codes": [
    "a3b8c9d4",
    "e5f2g7h1",
    "j6k9m2n8",
    "p4q7r3s5",
    "t8u2v6w9",
    "x3y7z4a8",
    "b9c5d2e6",
    "f7g3h8j4"
  ]
}

The totp_uri can be rendered as a QR code for the user to scan. The recovery_codes are shown exactly once — instruct the user to save them securely.

Verify: Confirm enrollment by submitting a valid TOTP code from the authenticator app.

POST /v1/auth/mfa/verify

{
  "method": "totp",
  "code": "482917"
}

This validates the code against the stored secret using the standard TOTP algorithm (RFC 6238, 30-second time step, SHA-1, 6-digit codes). On success, the enrollment is marked as verified: true.

TOTP in Go

import "github.com/xraph/authsome/plugins/mfa"

// Generate a new TOTP key
key, err := mfa.GenerateTOTPKey(mfa.TOTPConfig{
    Issuer:      "MyApp",
    AccountName: "alice@example.com",
})
// key.URL()    → provisioning URI for QR code
// key.Secret() → Base32 secret string

// Validate a user-submitted code against the secret
valid := mfa.ValidateTOTP("482917", key.Secret())

// Generate a code programmatically (useful for testing)
code, err := mfa.GenerateTOTPCode(key.Secret())

SMS OTP flow

SMS OTP sends a 6-digit numeric code to the user's verified phone number. It requires an SMS bridge to be configured.

Enroll: Register the user's phone number for SMS MFA.

POST /v1/auth/mfa/enroll

{
  "method": "sms",
  "phone": "+15551234567"
}

The engine sends an initial verification code to confirm the phone number is valid and reachable.

Verify: Confirm enrollment by submitting the SMS code.

POST /v1/auth/mfa/verify

{
  "method": "sms",
  "code": "847293"
}

SMS bridge configuration

The SMS bridge implements bridge.SMSSender. Authsome ships with a Twilio adapter:

import "github.com/xraph/authsome/bridge/smsadapter"

smsSender := smsadapter.NewTwilioSender(
    "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", // Account SID
    "your_auth_token",                     // Auth Token
    "+15559876543",                         // From number
)

eng, err := authsome.New(
    authsome.WithSMSSender(smsSender),
    // ... other options
)

You can implement your own SMS bridge by satisfying the bridge.SMSSender interface:

type SMSSender interface {
    SendSMS(ctx context.Context, msg *SMSMessage) error
}

type SMSMessage struct {
    To   string `json:"to"`
    Body string `json:"body"`
}

SMS codes are 6 digits by default and expire after 5 minutes.

Challenge flow

When MFA is enabled for a user, the sign-in flow becomes a two-phase process:

Primary authentication: The user signs in with email/password (or any primary method).

POST /v1/auth/signin

{
  "email": "alice@example.com",
  "password": "Secure!Pass99",
  "app_id": "myapp"
}

If the user has a verified MFA enrollment, instead of returning a full session, the engine returns an MFA challenge:

{
  "mfa_required": true,
  "challenge_token": "chall_01jb...",
  "methods": ["totp"],
  "expires_at": "2024-11-01T10:05:00Z"
}

The challenge_token is a short-lived opaque token (default 5 minutes) that proves the user passed primary authentication.

MFA verification: The user submits the challenge token along with their MFA code.

POST /v1/auth/mfa/challenge

{
  "challenge_token": "chall_01jb...",
  "method": "totp",
  "code": "482917"
}

On success, the engine creates and returns a full session:

{
  "user": {
    "id": "ausr_01j9...",
    "email": "alice@example.com",
    "name": "Alice Liddell"
  },
  "session_token": "a3f8c9d4...",
  "refresh_token": "d72b1ef8...",
  "expires_at": "2024-11-01T11:00:00Z"
}

Using a recovery code

If the user does not have access to their MFA device, they can complete the challenge with a recovery code:

{
  "challenge_token": "chall_01jb...",
  "method": "recovery",
  "code": "a3b8c9d4"
}

The recovery code is consumed (marked as used) on success. Each code can only be used once.

Recovery code management

Regenerate codes

Users can regenerate their recovery codes at any time. This invalidates all previous codes.

POST /v1/auth/mfa/recovery-codes/regenerate

{
  "recovery_codes": [
    "n7p4q2r8",
    "s3t9u5v6",
    "w8x2y7z3",
    "a4b9c5d2",
    "e6f3g8h7",
    "j2k7m4n9",
    "p5q3r8s6",
    "t9u4v2w7"
  ]
}

In Go

import "github.com/xraph/authsome/plugins/mfa"

// Generate 8 recovery codes for a user
records, plaintexts, err := mfa.GenerateRecoveryCodes(userID, 8)
// records  → []*RecoveryCode (store these)
// plaintexts → []string (show to user once)

// Verify a recovery code
valid := mfa.VerifyRecoveryCode("a3b8c9d4", recoveryCodeRecord)

Disabling MFA

Users can disable MFA by deleting their enrollment:

DELETE /v1/auth/mfa/enrollment

This removes the enrollment record and all associated recovery codes. The user will no longer be prompted for MFA on sign-in.

Configuration reference

OptionTypeDefaultDescription
EnabledboolfalseEnable MFA support
Issuerstring""Application name shown in authenticator apps
RecoveryCodeCountint8Number of recovery codes to generate
SMSCodeLengthint6Number of digits in SMS codes
SMSCodeTTLstring"5m"SMS code validity duration
EnforceForAllUsersboolfalseRequire MFA for all users (not just enrolled ones)
ChallengeTTLstring"5m"How long a challenge token is valid

MFA store interface

The MFA plugin requires a store that implements the mfa.Store interface:

type Store interface {
    // Enrollment CRUD
    CreateEnrollment(ctx context.Context, e *Enrollment) error
    GetEnrollment(ctx context.Context, userID id.UserID, method string) (*Enrollment, error)
    GetEnrollmentByID(ctx context.Context, mfaID id.MFAID) (*Enrollment, error)
    UpdateEnrollment(ctx context.Context, e *Enrollment) error
    DeleteEnrollment(ctx context.Context, mfaID id.MFAID) error
    ListEnrollments(ctx context.Context, userID id.UserID) ([]*Enrollment, error)

    // Recovery codes
    CreateRecoveryCodes(ctx context.Context, codes []*RecoveryCode) error
    GetRecoveryCodes(ctx context.Context, userID id.UserID) ([]*RecoveryCode, error)
    ConsumeRecoveryCode(ctx context.Context, codeID id.RecoveryCodeID) error
    DeleteRecoveryCodes(ctx context.Context, userID id.UserID) error
}

Built-in implementations are provided in plugins/mfa/store_postgres.go, plugins/mfa/store_sqlite.go, plugins/mfa/store_mongo.go, and plugins/mfa/store_memory.go.

API routes

All MFA routes are registered under BasePath (default /v1/auth):

MethodPathDescription
POST/mfa/enrollStart MFA enrollment for a method
POST/mfa/verifyVerify enrollment with a code
POST/mfa/challengeComplete an MFA challenge during sign-in
DELETE/mfa/enrollmentRemove MFA enrollment and recovery codes
POST/mfa/recovery-codes/regenerateGenerate new recovery codes (invalidates old ones)
GET/mfa/enrollmentsList active enrollments for the current user

On this page