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. 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:

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).

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:

  1. OTP generated (numericCode, length from CodeLength)
  2. Stored as verification token identifier: mfa:{userId}, token: {code}
  3. SendCode invoked
  4. 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"
FieldRequiredPurpose
challengeYesJWE from step 1
codeYesOTP digits
trustDeviceNoSet 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

TopicBehavior
Adapter absentOTP not stored in DB; verification still checks challenge JWE (weaker)
Non-credentials providersMFA ignored (OAuth goes straight to session)
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.