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. This mirrors a common Auth.js extension pattern.
Enable 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 {
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 when MFA is enabled.
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).
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: device trusted?
TrustedDevice --> Session: yes
PasswordCheck --> SendOTP: no
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 \
-d "email=user@example.com&password=secret"
When MFA applies:
{
"challenge": "eyJhbGciOi...",
"expiresIn": 600
}
Behind the scenes:
- OTP generated (
numericCode, length fromCodeLength) - Stored as verification token
identifier: mfa:{userId},token: {code} SendCodeinvoked- Challenge JWE encodes user + account for completion
Step 2 — Verify
curl -X POST https://app.example.com/auth/mfa/verify \
-d "challenge=eyJ...&code=123456&trustDevice=true"
| Field | Required | Purpose |
|---|---|---|
challenge | Yes | JWE from step 1 |
code | Yes | OTP digits |
trustDevice | No | Set goauth.trusted-device cookie |
On success → normal session cookie or bearer tokens.
Trusted device
When trustDevice=true, goauth sets an encrypted goauth.trusted-device cookie binding the browser to the user. Future credentials sign-ins skip MFA until TrustDeviceMaxAge expires.
Set TrustDeviceMaxAge to a negative duration to disable trust entirely.
TrustDeviceMaxAge: -1, // always require OTP
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" },
body: new URLSearchParams({ email, password }),
credentials: "include",
});
const data = await r1.json();
if (data.challenge) {
const code = prompt("Enter code from email");
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) |
| 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.