Skip to main content

Multi-factor authentication (MFA)

goauth implements post-login OTP for credentials providers — after Authorize succeeds, the user must enter a one-time code before a session is issued.

Two ways to trigger MFA

ModeConfigurationUse case
GlobalMFA.Enabled: trueEvery credentials login (except trusted devices)
Selectiveuser.RequireMFA in AuthorizePer-user / per-risk from your API

Both require MFA.SendCode. See the full Selective MFA from Authorize guide for per-user examples.


Enable global MFA

goauth.Config{
MFA: goauth.MFAConfig{
Enabled: true,
CodeLength: 6, // default
MaxAge: 10 * time.Minute, // OTP lifetime
TrustDeviceMaxAge: 90 * 24 * time.Hour, // trusted device cookie
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
if p.Channel == goauth.VerificationPhone {
return sms.Send(p.Phone, fmt.Sprintf("Your code: %s", p.Code))
}
return email.Send(p.Email, fmt.Sprintf("Your code: %s", p.Code))
},
// Optional: database-backed device trust (mobile / multi-device)
IsDeviceTrusted: func(ctx context.Context, p goauth.MFADeviceTrustParams) (bool, error) {
return trustDB.Has(ctx, p.UserID, p.DeviceID)
},
TrustDevice: func(ctx context.Context, p goauth.MFADeviceTrustParams) error {
return trustDB.Save(ctx, p.UserID, p.DeviceID, time.Now().Add(90*24*time.Hour))
},
},
Adapter: adapter, // recommended — stores OTP in verification_tokens
}

SendCode is required whenever MFA runs (global or selective).


Selective MFA from Authorize

Check your API inside Authorize and opt in only when needed:

import "github.com/izetmolla/goauth/providers/credentials"

credentials.New(credentials.Options{
Authorize: func(ctx context.Context, creds map[string]string, r *http.Request) (*goauth.User, error) {
u, err := api.Validate(creds["email"], creds["password"])
if u == nil {
return nil, err
}
user := &goauth.User{ID: u.ID, Email: u.Email, Phone: u.Phone}

if api.NeedsMFA(u, r) {
return credentials.RequireMFA(
user,
goauth.VerificationEmail,
goauth.MaskEmail(u.Email),
), nil
}
return user, nil
},
})

MFA.Enabled can stay false. goauth sends the code via SendCode and returns challenge JSON to the client.


Challenge JSON (frontend)

When MFA applies, step 1 returns:

{
"challenge": "eyJhbGciOi...",
"expiresIn": 600,
"mfaRequired": true,
"channel": "email",
"destination": "u***@example.com"
}
FieldPurpose
mfaRequiredClient shows OTP UI
channel"email" or "phone"
destinationMasked hint for “code sent to …”
challengeOpaque token for POST /mfa/verify
verifyUrlOne-click link when MFA.LinkVerify is enabled (optional)

Use goauth.MaskEmail / goauth.MaskPhone when building hints in Authorize.


Set MFA.LinkVerify: true to generate a signed link the user can open instead of typing the OTP. The URL is passed to SendCode as VerifyURL and returned in the challenge JSON as verifyUrl.

MFA: goauth.MFAConfig{
LinkVerify: true,
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
// p.VerifyURL — GET link with linkToken query param
// p.LinkToken — same token (for custom templates)
return sendEmail(p.Email, "Confirm sign-in", "Click: "+p.VerifyURL)
},
},

When Tokens.Enabled is on, the link includes flow=token. Opening it in a browser:

  1. Validates the embedded code via GET /auth/mfa/verify?linkToken=…&flow=token
  2. Returns the same HTML callback page as OAuth — tokens stored in localStorage under "goauth"
  3. Redirects to callbackUrl (query param) or Tokens.CallbackPage

Example link shape:

GET /auth/mfa/verify?linkToken=eyJ…&flow=token&callbackUrl=/dashboard

You can also verify manually via GET with challenge + code query params (custom email templates). POST remains the primary API for SPAs.


Trusted device by userId + deviceId

Clients send a stable device id (install id, fingerprint hash, etc.) on sign-in and MFA verify:

ParameterWhere
deviceId / device_idForm body or query
X-Device-IdRequest header

Check before showing OTP UI

curl "https://app.example.com/auth/mfa/device?userId=user-1&deviceId=phone-abc"
{ "trusted": true, "skipMfa": true }

When skipMfa is true, POST /auth/callback/credentials with the same deviceId skips SendCode and returns a session directly (if password is valid). Applies to global MFA only.

Programmatic check (no HTTP)

trusted, err := auth.IsMFADeviceTrusted(ctx, userID, deviceID, r)

Persist trust on verify

POST /auth/mfa/verify
challenge=...&code=123456&trustDevice=true&deviceId=phone-abc

Calls MFA.TrustDevice (if set) and sets the goauth.trusted-device cookie (includes deviceId in the token).


End-to-end flow

stateDiagram-v2
[*] --> PasswordCheck: POST /callback/credentials
PasswordCheck --> TrustedDevice: global MFA + device trusted?
TrustedDevice --> Session: yes
PasswordCheck --> RequireMFACheck: Authorize RequireMFA or global MFA
RequireMFACheck --> Session: no MFA needed
RequireMFACheck --> SendOTP: yes
SendOTP --> Challenge: 200 challenge JSON
Challenge --> Verify: POST /mfa/verify
Verify --> Session: code OK
Verify --> [*]: invalid code

Step 1 — Credentials sign-in

curl -X POST https://app.example.com/auth/callback/credentials \
-H "Accept: application/json" \
-d "email=user@example.com&password=secret"

When MFA applies:

{
"challenge": "eyJhbGciOi...",
"expiresIn": 600,
"mfaRequired": true,
"channel": "email",
"destination": "u***@example.com"
}

Behind the scenes:

  1. Any prior code for mfa:{userId} is deleted (resend-safe)
  2. OTP generated (NumericCode by default, or MFA.GenerateCode)
  3. Stored as verification token identifier: mfa:{userId}, token: {code}
  4. SendCode invoked
  5. Challenge JWE encodes user + account for completion

Resend / login again before verify

If the user submits credentials again before completing MFA, goauth replaces the stored code automatically. The client gets a new challenge; only the latest code works. You do not need to call the adapter yourself.

If you see duplicate key value violates unique constraint on verification_tokens, upgrade to a version that includes DeleteVerificationTokensByIdentifier, or implement it on custom adapters.

Step 2 — Verify

POST (SPA / API):

curl -X POST https://app.example.com/auth/mfa/verify \
-d "challenge=eyJ...&code=123456&trustDevice=true"

GET (one-click link when LinkVerify is enabled):

# Browser navigation — no CSRF; linkToken is a signed JWE with the code embedded
open "https://app.example.com/auth/mfa/verify?linkToken=eyJ...&flow=token&callbackUrl=/app"

With flow=token, success returns the HTML page that writes bearer tokens to localStorage.

FieldRequiredPurpose
challengeYesJWE from step 1
codeYesOTP digits (or custom string)
trustDeviceNoSet goauth.trusted-device cookie

On success → normal session cookie or bearer tokens.


Custom code generation

MFA: goauth.MFAConfig{
SendCode: sendMFA,
GenerateCode: func(ctx context.Context, p goauth.MFAGenerateCodeParams) string {
return goauth.NumericCode(6) // or any string token
},
},

MFAGenerateCodeParams includes User, Email, Phone, Channel, Credentials, and the request.


Trusted device

When trustDevice=true, goauth sets an encrypted goauth.trusted-device cookie binding the browser to the user. Future credentials sign-ins skip global MFA until TrustDeviceMaxAge expires.

Set TrustDeviceMaxAge to a negative duration to disable trust entirely.

TrustDeviceMaxAge: -1, // always require OTP (global mode)

Simple React example

async function login(email: string, password: string) {
const r1 = await fetch("/auth/callback/credentials", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({ email, password }),
credentials: "include",
});
const data = await r1.json();

if (data.mfaRequired && data.challenge) {
// Show: `Enter code sent to ${data.destination} (${data.channel})`
const code = prompt(`Code sent to ${data.destination}`);
await fetch("/auth/mfa/verify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
challenge: data.challenge,
code,
trustDevice: "true",
}),
credentials: "include",
});
return;
}
// signed in without MFA
}

Advanced details

TopicBehavior
Adapter absentOTP not stored in DB; verification still checks challenge JWE (weaker)
Non-credentials providersMFA ignored (OAuth goes straight to session)
Selective MFARequireMFA in Authorize; Enabled may be false
ResendOld mfa:{userId} tokens purged before each new code
Error kindsMFARequired, MFAVerification — see Errors
CSRFMFA verify uses POST; include CSRF for cookie-based apps

MFA does not apply to passkey sign-in — WebAuthn is already a second factor.