Selective MFA from Authorize
Require a one-time code only for some users — after a successful password check in CredentialsProvider.Authorize. Use this when your API decides MFA per user (risk score, admin role, new device, etc.) instead of challenging every login.
:::info Two MFA modes
| Mode | Config | When MFA runs |
|---|---|---|
| Global | MFA.Enabled: true | Every credentials sign-in (unless device trusted) |
| Selective | MFA.Enabled: false + user.RequireMFA in Authorize | Only when you opt in per sign-in |
| ::: |
Both modes need MFA.SendCode and an adapter (recommended) to store the OTP.
Quick example
import (
"context"
"net/http"
"github.com/izetmolla/goauth"
"github.com/izetmolla/goauth/adapters/memory"
"github.com/izetmolla/goauth/providers/credentials"
)
auth, _ := goauth.New(goauth.Config{
Secret: []string{"your-32+-byte-secret"},
Adapter: memory.New(),
MFA: goauth.MFAConfig{
// Enabled can stay false for selective MFA only
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
if p.Channel == goauth.VerificationPhone {
return sms.Send(p.Phone, "Your code: "+p.Code)
}
return mail.Send(p.Email, "Your code: "+p.Code)
},
},
Providers: []goauth.Provider{
credentials.New(credentials.Options{
Authorize: func(ctx context.Context, creds map[string]string, r *http.Request) (*goauth.User, error) {
dbUser, err := api.ValidatePassword(creds["email"], creds["password"])
if err != nil || dbUser == nil {
return nil, nil
}
user := &goauth.User{
ID: dbUser.ID,
Email: dbUser.Email,
Phone: dbUser.Phone,
}
if api.UserRequiresMFA(dbUser) {
return credentials.RequireMFA(
user,
goauth.VerificationEmail,
goauth.MaskEmail(dbUser.Email),
), nil
}
return user, nil
},
}),
},
})
credentials.RequireMFA helper
credentials.RequireMFA(user, channel, destinationHint, targets...)
| Argument | Purpose |
|---|---|
user | Successful Authorize result |
channel | goauth.VerificationEmail or goauth.VerificationPhone |
destinationHint | Masked string for the UI — use goauth.MaskEmail / goauth.MaskPhone |
targets... | Optional raw email or phone when not on User (passed to SendCode) |
Email MFA
return credentials.RequireMFA(
&goauth.User{ID: u.ID, Email: u.Email},
goauth.VerificationEmail,
goauth.MaskEmail(u.Email),
), nil
Phone MFA
return credentials.RequireMFA(
&goauth.User{ID: u.ID, Phone: u.Phone},
goauth.VerificationPhone,
goauth.MaskPhone(u.Phone),
u.Phone, // explicit SendCode target
), nil
Manual (without helper)
user.RequireMFA = true
user.MFADelivery = &goauth.MFADeliveryHint{
Channel: goauth.VerificationEmail,
Destination: goauth.MaskEmail(user.Email),
Email: user.Email,
}
return user, nil
Frontend response
When MFA is required, POST /auth/callback/credentials returns 200 JSON (use Accept: application/json or X-Auth-Flow: token):
{
"challenge": "eyJhbGciOi...",
"expiresIn": 600,
"mfaRequired": true,
"channel": "email",
"destination": "u***@example.com"
}
| Field | Use in UI |
|---|---|
mfaRequired | Show OTP step instead of dashboard |
channel | "email" or "phone" — icon / copy |
destination | “We sent a code to u***@example.com” |
challenge | Pass to verify step (opaque — store in memory) |
verifyUrl | Optional one-click link when MFA.LinkVerify is enabled |
When MFA is not required, the response is a normal session or bearer tokens.
Complete client flow (TypeScript)
async function signIn(email: string, password: string) {
const res = await fetch("/auth/callback/credentials", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
"X-Auth-Flow": "token",
},
body: new URLSearchParams({ email, password }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
if (data.mfaRequired && data.challenge) {
return {
step: "mfa" as const,
challenge: data.challenge,
channel: data.channel as "email" | "phone",
destination: data.destination as string,
};
}
// Direct sign-in — store tokens
localStorage.setItem("goauth", JSON.stringify(data));
return { step: "done" as const, tokens: data };
}
async function verifyMFA(challenge: string, code: string, trustDevice = false) {
const res = await fetch("/auth/mfa/verify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
challenge,
code,
...(trustDevice ? { trustDevice: "true" } : {}),
}),
});
if (!res.ok) throw new Error(await res.text());
// Cookie session or redirect — for token flow parse JSON if configured
}
React MFA screen sketch
function MfaScreen({
challenge,
channel,
destination,
}: {
challenge: string;
channel: string;
destination: string;
}) {
const [code, setCode] = useState("");
return (
<div>
<p>
Enter the code sent to your {channel}: <strong>{destination}</strong>
</p>
<input value={code} onChange={(e) => setCode(e.target.value)} />
<button onClick={() => verifyMFA(challenge, code, true)}>Verify</button>
</div>
);
}
Verify step
curl -X POST https://app.example.com/auth/mfa/verify \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "challenge=eyJ...&code=123456&trustDevice=true"
| Field | Required | Notes |
|---|---|---|
challenge | Yes | From step 1 |
code / otp | Yes | Digits or custom string from GenerateCode |
trustDevice | No | Skip MFA on this device next time |
deviceId | No | Pair with device trust |
Custom code generation
Override globally in MFAConfig.GenerateCode:
MFA: goauth.MFAConfig{
SendCode: sendMFA,
GenerateCode: func(ctx context.Context, p goauth.MFAGenerateCodeParams) string {
// Per-user logic — p.User, p.Email, p.Phone, p.Channel, p.Credentials
return goauth.NumericCode(6) // digits only
// return "ABC123" // or alphanumeric
},
},
goauth stores whatever string you return and compares it on verify.
Resend / login again
When the user triggers MFA or OTP again before verifying:
- goauth deletes existing tokens for that identifier (
mfa:{userId}, email, or phone) - A new code is generated and sent via
SendCode - The client receives a fresh
challenge(MFA) or can submit the new code (OTP)
You do not need custom resend endpoints — repeat the same sign-in/credentials request.
Decision flow in Authorize
flowchart TD
A[POST /callback/credentials] --> B[Authorize: validate password]
B -->|invalid| C[401 CredentialsSignin]
B -->|valid| D{Require MFA?}
D -->|your API says yes| E[RequireMFA user]
D -->|no| F[Issue session / tokens]
E --> G[goauth: NumericCode + SendCode]
G --> H[JSON challenge + channel + destination]
H --> I[POST /mfa/verify]
I --> F
Example conditions inside Authorize:
needsMFA := dbUser.MFAEnabled
needsMFA = needsMFA || dbUser.IsAdmin
needsMFA = needsMFA || api.IsNewDevice(r, dbUser.ID)
needsMFA = needsMFA || api.RiskScore(creds) > threshold
Global vs selective together
You can combine both:
MFA.Enabled: true— baseline MFA for everyone (minus trusted devices)user.RequireMFA = true— also works whenEnabledis false for selective-only apps
When Enabled is true, trusted devices still skip MFA. Selective RequireMFA always triggers MFA even if Enabled is false.
Fiber
Same config — mount with fiberauth.Handler(auth). See Sign in (credentials) and MFA & trusted devices.