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
| Mode | Configuration | Use case |
|---|---|---|
| Global | MFA.Enabled: true | Every credentials login (except trusted devices) |
| Selective | user.RequireMFA in Authorize | Per-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"
}
| Field | Purpose |
|---|---|
mfaRequired | Client shows OTP UI |
channel | "email" or "phone" |
destination | Masked hint for “code sent to …” |
challenge | Opaque token for POST /mfa/verify |
verifyUrl | One-click link when MFA.LinkVerify is enabled (optional) |
Use goauth.MaskEmail / goauth.MaskPhone when building hints in Authorize.
One-click verify link (LinkVerify)
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:
- Validates the embedded code via
GET /auth/mfa/verify?linkToken=…&flow=token - Returns the same HTML callback page as OAuth — tokens stored in
localStorageunder"goauth" - Redirects to
callbackUrl(query param) orTokens.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:
| Parameter | Where |
|---|---|
deviceId / device_id | Form body or query |
X-Device-Id | Request 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:
- Any prior code for
mfa:{userId}is deleted (resend-safe) - OTP generated (
NumericCodeby default, orMFA.GenerateCode) - Stored as verification token
identifier: mfa:{userId},token: {code} SendCodeinvoked- 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.
| Field | Required | Purpose |
|---|---|---|
challenge | Yes | JWE from step 1 |
code | Yes | OTP digits (or custom string) |
trustDevice | No | Set 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
| Topic | Behavior |
|---|---|
| Adapter absent | OTP not stored in DB; verification still checks challenge JWE (weaker) |
| Non-credentials providers | MFA ignored (OAuth goes straight to session) |
| Selective MFA | RequireMFA in Authorize; Enabled may be false |
| Resend | Old mfa:{userId} tokens purged before each new code |
| Error kinds | MFARequired, MFAVerification — see Errors |
| CSRF | MFA verify uses POST; include CSRF for cookie-based apps |
MFA does not apply to passkey sign-in — WebAuthn is already a second factor.