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{
GenerateVerificationToken: func() string {
return myHMACToken()
},
SendVerificationRequest: 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
return twilio.SendSMS(p.Identifier, "Your login code: "+p.Token)
},
}),
Server flow
sequenceDiagram
participant U as User
participant G as goauth
participant DB as Adapter
U->>G: POST /signin/otp (email)
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:
GenerateVerificationToken: func() string { return numericCode(length) },
SendVerificationRequest: o.SendCode,
Verification reuses emailCallback in actions_email.go.
MFA after credentials (second factor)
Not a provider — Config.MFA.
What you implement
MFA: goauth.MFAConfig{
Enabled: true,
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
// p.Code — numeric OTP
// p.Email — from user
// p.User — full user after password OK
return mailer.Send(p.Email, "Verification code: "+p.Code)
},
},
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: CreateVerificationToken("mfa:userId", code)
G->>U: SendCode
G->>U: JSON { challenge, expiresIn }
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.challenge) {
const code = await promptUserForCode(); // user reads email
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.
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 |
| Passkey only | passkey provider (no OTP) |
Debugging checklist
| Problem | Check |
|---|---|
| Code always invalid | Same identifier (email) on sign-in and verify |
| MFA verify 404 | MFA.Enabled true |
| OTP goes to wrong endpoint | MFA uses /mfa/verify, login OTP uses /callback/otp |
| Code expired | MaxAge / MFA.MaxAge vs clock skew |
| Link works once | UseVerificationToken deletes row — expected |