Skip to main content

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

FeatureConfig / providerWhen code is sentVerify endpointStorage key
Email magic linkemail.NewSendVerificationRequestPOST /signin/emailGET /callback/email?token=identifier=email
Email OTP (login)otp.NewSendCodePOST /signin/otpPOST /callback/otpidentifier=email
MFA after passwordMFA.SendCodeAfter POST /callback/credentialsPOST /mfa/verifyidentifier=mfa:{userId}

:::tip Rule of thumb

  • Sign-in with email codeotp provider + /callback/otp
  • Extra step after passwordMFA + /mfa/verify
  • Click link in emailemail provider + GET callback :::

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:

ModeConfig
GlobalMFA.Enabled: true — every login
Selectiveuser.RequireMFA in Authorizefull 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

FieldAliasesRequired
challengeYes (JWE from step 1)
codeotpYes
trustDevicetrust_deviceNo (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

FlowCSRF on sign-in request
Email / OTP POST /signin/*Yes (unless X-Auth-Flow: token)
Credentials callbackToken flow exempt
MFA verifyPOST with session context
Magic link GET callbackNo (one-time URL)

Get token: GET /auth/csrf → include csrfToken in form.


Choosing the right approach

Product requirementUse
"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 onlypasskey provider (no OTP)

Debugging checklist

ProblemCheck
Code always invalidSame identifier (email) on sign-in and verify
MFA verify 404SendCode not configured
OTP goes to wrong endpointMFA uses /mfa/verify, login OTP uses /callback/otp
Code expiredMaxAge / MFA.MaxAge vs clock skew
duplicate key on verification_tokensImplement DeleteVerificationTokensByIdentifier on adapter; upgrade goauth
Resend invalidates old codeExpected — only latest MFA/OTP code works
Link works onceUseVerificationToken deletes row — expected