OTP & verification — complete guide
goauth has three different code-delivery mechanisms. They look similar but use different endpoints, different config fields, and different storage keys.
Comparison table
| Feature | Config / provider | When code is sent | Verify endpoint | Storage key |
|---|---|---|---|---|
| Email magic link | email.New → SendVerificationRequest | POST /signin/email | GET /callback/email?token= | identifier=email |
| Email OTP (login) | otp.New → SendCode | POST /signin/otp | POST /callback/otp | identifier=email |
| MFA after password | MFA.SendCode | After POST /callback/credentials | POST /mfa/verify | identifier=mfa:{userId} |
:::tip Rule of thumb
- Sign-in with email code →
otpprovider +/callback/otp - Extra step after password →
MFA+/mfa/verify - Click link in email →
emailprovider + GET callback :::
1. Email magic link
What you implement
email.New(email.Options{
SendVerificationRequest: func(ctx context.Context, p goauth.SendVerificationRequestParams) error {
// p.URL — full magic link (send this to user)
// p.Token — random token in URL
// p.Identifier — email address
return mailer.SendHTML(p.Identifier, "Sign in", `<a href="`+p.URL+`">Click here</a>`)
},
}),
Server flow
sequenceDiagram
participant U as User
participant A as Your API
participant G as goauth
participant DB as Adapter
U->>A: POST /auth/signin/email (email)
A->>G: forward
G->>DB: CreateVerificationToken(email, token)
G->>G: SendVerificationRequest(URL)
G->>U: Redirect check-your-email
U->>G: GET /callback/email?email&token
G->>DB: UseVerificationToken (consume)
G->>DB: GetUserByEmail or CreateUser
G->>U: Session cookie
Simple curl test
# 1. Request link
curl -X POST http://localhost:3000/auth/signin/email \
-d "email=test@example.com" \
-b cookies.txt -c cookies.txt
# 2. User clicks link from email (simulate)
curl "http://localhost:3000/auth/callback/email?email=test@example.com&token=PASTE_TOKEN" \
-b cookies.txt -c cookies.txt
Advanced: custom token generator
email.New(email.Options{
// Set on EmailProvider directly when not using otp.New wrapper:
// GenerateVerificationToken receives context + form fields + user lookup.
})
// Or via otp.New:
otp.New(otp.Options{
GenerateCode: func(ctx context.Context, p goauth.GenerateVerificationTokenParams) string {
return goauth.NumericCode(6) // or custom string
},
SendCode: send,
})
2. Email OTP provider (passwordless login code)
Built on EmailProvider but generates numeric codes via otp.New.
What you implement
otp.New(otp.Options{
CodeLength: 6,
MaxAge: 600, // seconds
SendCode: func(ctx context.Context, p goauth.SendVerificationRequestParams) error {
// p.Token — THE OTP CODE (e.g. "482913")
// p.Identifier — email (otp provider) or phone (phone provider)
// p.Channel — "email" | "phone"
return mailer.Send(p.Identifier, "Your login code: "+p.Token)
},
GenerateCode: func(ctx context.Context, p goauth.GenerateVerificationTokenParams) string {
// p.Identifier, p.Fields, p.User — per-user logic
return goauth.NumericCode(6)
},
}),
Phone OTP (providers/phone)
Same flow as email OTP but POST /auth/signin/phone with phone=… and callback with phone=…&code=…. See Email & OTP.
Server flow
sequenceDiagram
participant U as User
participant G as goauth
participant DB as Adapter
U->>G: POST /signin/otp (email)
G->>DB: DeleteVerificationTokens(email) — resend-safe
G->>DB: CreateVerificationToken(email, "482913")
G->>U: SendCode("482913")
G->>U: Redirect verify-request page
U->>G: POST /callback/otp (email + code)
G->>DB: UseVerificationToken
G->>U: Session
Simple frontend
async function loginWithOTP(email: string) {
await fetch("/auth/signin/otp", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ email }),
credentials: "include",
});
const code = prompt("Enter code from email");
const res = await fetch("/auth/callback/otp", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ email, code }),
credentials: "include",
});
if (!res.ok) throw new Error(await res.text());
}
Advanced: mobile token response
curl -X POST https://api.example.com/auth/callback/otp \
-H "X-Auth-Flow: token" \
-d "email=user@example.com&code=123456"
Accepted field names for code: code, otp, or token.
Implementation detail (source)
providers/otp/otp.go wraps EmailProvider with GenerateCode (context + user params) and SendCode. Resend purges prior tokens via DeleteVerificationTokensByIdentifier before storing a new code.
MFA after credentials (second factor)
Not a provider — Config.MFA.
Two modes:
| Mode | Config |
|---|---|
| Global | MFA.Enabled: true — every login |
| Selective | user.RequireMFA in Authorize — full guide |
What you implement
MFA: goauth.MFAConfig{
Enabled: true, // optional for selective-only apps
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
// p.Code — OTP string (numeric or custom)
// p.Email — delivery target
// p.Phone — when channel is phone
// p.Channel — goauth.VerificationEmail | VerificationPhone
// p.User — full user after password OK
if p.Channel == goauth.VerificationPhone {
return sms.Send(p.Phone, p.Code)
}
return mailer.Send(p.Email, "Verification code: "+p.Code)
},
GenerateCode: func(ctx context.Context, p goauth.MFAGenerateCodeParams) string {
return goauth.NumericCode(6) // or custom string
},
},
Selective MFA in Authorize
import "github.com/izetmolla/goauth/providers/credentials"
Authorize: func(ctx context.Context, creds map[string]string, r *http.Request) (*goauth.User, error) {
u, _ := api.Validate(creds["email"], creds["password"])
if u == nil {
return nil, nil
}
user := &goauth.User{ID: u.ID, Email: u.Email}
if api.NeedsMFA(u) {
return credentials.RequireMFA(user, goauth.VerificationEmail, goauth.MaskEmail(u.Email)), nil
}
return user, nil
},
Server flow
sequenceDiagram
participant U as User
participant G as goauth
participant DB as Adapter
U->>G: POST /callback/credentials
G->>G: Authorize password OK
G->>DB: DeleteVerificationTokens("mfa:userId")
G->>DB: CreateVerificationToken("mfa:userId", code)
G->>U: SendCode
G->>U: JSON { challenge, expiresIn, mfaRequired, channel, destination }
U->>G: POST /mfa/verify (challenge, code)
G->>DB: UseVerificationToken("mfa:userId", code)
G->>U: JWT session / tokens
Simple two-step client
const step1 = await fetch("/auth/callback/credentials", {
method: "POST",
body: new URLSearchParams({ email, password }),
credentials: "include",
});
const body = await step1.json();
if (body.mfaRequired && body.challenge) {
const code = await promptUserForCode(); // show body.destination + body.channel
await fetch("/auth/mfa/verify", {
method: "POST",
body: new URLSearchParams({
challenge: body.challenge,
code,
trustDevice: "true",
}),
credentials: "include",
});
}
Verify request fields
| Field | Aliases | Required |
|---|---|---|
challenge | — | Yes (JWE from step 1) |
code | otp | Yes |
trustDevice | trust_device | No (true to skip MFA next time) |
Advanced: trusted device
When trustDevice=true, goauth sets encrypted cookie goauth.trusted-device for TrustDeviceMaxAge (default 90 days). Next credentials login skips MFA if cookie matches user id.
Disable trust:
TrustDeviceMaxAge: -1,
MFA without adapter
If Adapter is nil, OTP is not stored in DB — verification relies on challenge JWE only (weaker). Always use an adapter in production MFA.
Resend & duplicate keys
Before storing a new MFA or OTP code, goauth calls DeleteVerificationTokensByIdentifier so only the latest code is valid. This prevents Postgres/MySQL duplicate-key errors when users log in again or request a new code.
Custom adapters must implement this method — see Adapters overview.
CSRF requirements
| Flow | CSRF on sign-in request |
|---|---|
Email / OTP POST /signin/* | Yes (unless X-Auth-Flow: token) |
| Credentials callback | Token flow exempt |
| MFA verify | POST with session context |
| Magic link GET callback | No (one-time URL) |
Get token: GET /auth/csrf → include csrfToken in form.
Choosing the right approach
| Product requirement | Use |
|---|---|
| "Click link in email" | email provider |
| "Enter 6-digit code to log in" | otp provider |
| "Password + code from phone" | credentials + MFA or selective RequireMFA |
| Passkey only | passkey provider (no OTP) |
Debugging checklist
| Problem | Check |
|---|---|
| Code always invalid | Same identifier (email) on sign-in and verify |
| MFA verify 404 | SendCode not configured |
| OTP goes to wrong endpoint | MFA uses /mfa/verify, login OTP uses /callback/otp |
| Code expired | MaxAge / MFA.MaxAge vs clock skew |
duplicate key on verification_tokens | Implement DeleteVerificationTokensByIdentifier on adapter; upgrade goauth |
| Resend invalidates old code | Expected — only latest MFA/OTP code works |
| Link works once | UseVerificationToken deletes row — expected |