Skip to main content

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

ModeConfigWhen MFA runs
GlobalMFA.Enabled: trueEvery credentials sign-in (unless device trusted)
SelectiveMFA.Enabled: false + user.RequireMFA in AuthorizeOnly 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...)
ArgumentPurpose
userSuccessful Authorize result
channelgoauth.VerificationEmail or goauth.VerificationPhone
destinationHintMasked 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"
}
FieldUse in UI
mfaRequiredShow OTP step instead of dashboard
channel"email" or "phone" — icon / copy
destination“We sent a code to u***@example.com”
challengePass to verify step (opaque — store in memory)
verifyUrlOptional 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"
FieldRequiredNotes
challengeYesFrom step 1
code / otpYesDigits or custom string from GenerateCode
trustDeviceNoSkip MFA on this device next time
deviceIdNoPair 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:

  1. goauth deletes existing tokens for that identifier (mfa:{userId}, email, or phone)
  2. A new code is generated and sent via SendCode
  3. 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 when Enabled is 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.