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{
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

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.


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
Passkey onlypasskey provider (no OTP)

Debugging checklist

ProblemCheck
Code always invalidSame identifier (email) on sign-in and verify
MFA verify 404MFA.Enabled true
OTP goes to wrong endpointMFA uses /mfa/verify, login OTP uses /callback/otp
Code expiredMaxAge / MFA.MaxAge vs clock skew
Link works onceUseVerificationToken deletes row — expected