Skip to main content

Developer cookbook

One production-shaped integration explaining every moving part: config, callbacks, sending OTP, verifying OTP, creating users, and protecting routes.

Architecture

┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ React SPA │────▶│ Go API │────▶│ PostgreSQL │
│ /login │ │ /auth/* │ │ app_users │
└─────────────┘ │ goauth │ │ goauth_* │
└──────────────┘ └─────────────┘
  • goauth tables: sessions, verification tokens, authenticators
  • app_users: your canonical users (ResolveUser)

Full goauth.Config

package auth

import (
"context"
"database/sql"
"os"
"strings"
"time"

"github.com/izetmolla/goauth"
"github.com/izetmolla/goauth/adapters/postgres"
"github.com/izetmolla/goauth/providers/credentials"
"github.com/izetmolla/goauth/providers/github"
"github.com/izetmolla/goauth/providers/otp"
)

func NewHandler(appDB *sql.DB, mail Mailer) (http.Handler, error) {
goauthDB := appDB // same *sql.DB
adapter := postgres.New(goauthDB)

return goauth.New(goauth.Config{
Secret: []string{os.Getenv("AUTH_SECRET")},
URL: os.Getenv("APP_URL"),
Adapter: adapter,

Session: goauth.SessionConfig{
MaxAge: 30 * 24 * time.Hour,
},
Tokens: goauth.TokensConfig{
Enabled: true,
AccessTokenMaxAge: 15 * time.Minute,
RefreshTokenMaxAge: 30 * 24 * time.Hour,
CallbackPage: os.Getenv("APP_URL") + "/auth/done",
},
MFA: goauth.MFAConfig{
Enabled: true,
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
return mail.SendOTP(p.Email, p.Code)
},
},
Pages: goauth.Pages{
SignIn: "/login",
Error: "/login",
VerifyRequest: "/check-email",
},
Providers: []goauth.Provider{
github.New(os.Getenv("GITHUB_ID"), os.Getenv("GITHUB_SECRET")),
credentials.New(credentials.Options{
Authorize: authorizePassword(appDB),
}),
otp.New(otp.Options{
SendCode: func(ctx context.Context, p goauth.SendVerificationRequestParams) error {
return mail.SendLoginCode(p.Identifier, p.Token)
},
}),
},
Callbacks: goauth.Callbacks{
SignIn: signInPolicy,
ResolveUser: resolveAppUser(appDB),
JWT: jwtRoles(appDB),
Session: publicSession,
Redirect: safeRedirect,
},
Events: goauth.Events{
SignIn: func(ctx context.Context, p goauth.SignInCallbackParams) { audit("sign_in", p.User.ID) },
CreateUser: func(ctx context.Context, u *goauth.User) { audit("user_created", u.ID) },
},
})
}

1. Creating users — ResolveUser

func resolveAppUser(db *sql.DB) func(context.Context, goauth.ResolveUserParams) (*goauth.User, error) {
return func(ctx context.Context, p goauth.ResolveUserParams) (*goauth.User, error) {
var id string
err := db.QueryRowContext(ctx, `
INSERT INTO app_users (email, name, avatar_url)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET name = EXCLUDED.name
RETURNING id`,
p.User.Email, p.User.Name, p.User.Image,
).Scan(&id)
if err != nil {
return nil, err
}
if p.IsNewUser {
// welcome email, etc.
}
return &goauth.User{ID: id, Email: p.User.Email, Name: p.User.Name, Image: p.User.Image}, nil
}
}

OAuth: runs after adapter linking. Email OTP: runs when email callback resolves user. Credentials: runs with user from Authorize.


2. Password check — Authorize (not a callback)

func authorizePassword(db *sql.DB) func(context.Context, map[string]string, *http.Request) (*goauth.User, error) {
return func(ctx context.Context, creds map[string]string, r *http.Request) (*goauth.User, error) {
var id, hash, name, email string
err := db.QueryRowContext(ctx,
`SELECT id, password_hash, name, email FROM app_users WHERE email = $1`,
creds["email"]).Scan(&id, &hash, &name, &email)
if err != nil {
return nil, nil
}
if !checkBcrypt(hash, creds["password"]) {
return nil, nil
}
return &goauth.User{ID: id, Email: email, Name: name}, nil
}
}

3. Sending OTP — two different places

Login OTP (otp provider)

Triggered by POST /auth/signin/otp:

// otp.Options.SendCode — params.Token is the code
SendCode: func(ctx context.Context, p goauth.SendVerificationRequestParams) error {
return mail.SendLoginCode(p.Identifier, p.Token)
},

MFA OTP (after password)

Triggered automatically after successful credentials when MFA.Enabled:

// MFAConfig.SendCode — params.Code is the code
SendCode: func(ctx context.Context, p goauth.MFASendCodeParams) error {
return mail.SendOTP(p.Email, p.Code)
},

You never call these from callbacks — goauth invokes them when the right HTTP action completes.


4. Verifying OTP — two different endpoints

Login code

// POST /auth/callback/otp
await fetch("/auth/callback/otp", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Auth-Flow": "token" },
body: new URLSearchParams({ email, code: userEnteredCode }),
});

MFA code

// After credentials returned { challenge }
await fetch("/auth/mfa/verify", {
method: "POST",
body: new URLSearchParams({
challenge: step1.challenge,
code: userEnteredCode,
trustDevice: "true",
}),
});

5. JWT roles — JWT callback

func jwtRoles(db *sql.DB) func(context.Context, goauth.JWTCallbackParams) (goauth.JWT, error) {
return func(ctx context.Context, p goauth.JWTCallbackParams) (goauth.JWT, error) {
if p.User == nil {
return p.Token, nil
}
roles, _ := queryRoles(ctx, db, p.User.ID)
p.Token["roles"] = roles
return p.Token, nil
}
}

6. Sign-in policy — SignIn callback

func signInPolicy(ctx context.Context, p goauth.SignInCallbackParams) (bool, error) {
if p.User.Email == "" {
return false, nil
}
if strings.HasSuffix(p.User.Email, "@blocked.test") {
return false, nil
}
return true, nil
}

7. Protecting your API

func RequireAuth(auth *goauth.Auth, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := auth.GetSession(w, r)
if err != nil || session == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, session.User)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

Bearer tokens work automatically when Tokens.Enabled and Authorization: Bearer header is set.


8. Refresh tokens

async function refresh(refreshToken: string) {
const res = await fetch("/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ refresh_token: refreshToken }),
});
return res.json();
}

Testing checklist

FlowTest
GitHub OAuthGET /auth/signin/github
Password + MFAcredentials → challenge → mfa/verify
Email code loginsignin/otp → callback/otp
SessionGET /auth/session with cookie or Bearer
Sign outPOST /auth/signout + csrf
List sessionsGET /auth/sessions (DB strategy)