Skip to content

2FA

View as Markdown

This builds on Users & Sessions and works with either password or OAuth login. You’ll add TOTP-based two-factor authentication with recovery codes.

  • Go 1.22+
  • Existing primary auth flow (password, OAuth, or both)
  • Existing server-side sessions
  • Our TOTP library: github.com/pquerna/otp
/auth
/sessions.go
/two_factor.go // TOTP + recovery code logic
/handlers
/two_factor.go // setup + challenge handlers
/pages.go // 2FA setup/challenge pages
/main.go

Store one TOTP credential per user and separate one-time recovery codes.

CREATE TABLE user_totp_credentials (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL UNIQUE REFERENCES users(id),
secret_encrypted TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE user_recovery_codes (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
code_hash BLOB NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX user_recovery_codes_user_id_idx ON user_recovery_codes(user_id);
CREATE TABLE user_totp_pending_setups (
id TEXT NOT NULL PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
token_hash BLOB NOT NULL UNIQUE,
secret_encrypted TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX user_totp_pending_setups_user_id_idx ON user_totp_pending_setups(user_id);

Use session auth levels so primary auth and full 2FA auth are distinct.

Put this in auth/sessions.go:

const (
SessionAuthLevelPrimary = 1
SessionAuthLevelFull = 2
)
type Session struct {
ID string
SecretHash []byte
UserID string
AuthLevel int
CreatedAt time.Time
}
func CreateSession(ctx context.Context, db *sql.DB, userID string, authLevel int) (string, error) {
id, err := gonanoid.New()
if err != nil {
return "", err
}
secret, err := gonanoid.New()
if err != nil {
return "", err
}
now := time.Now().UTC().Unix()
_, err = db.ExecContext(ctx,
"INSERT INTO sessions (id, secret_hash, user_id, auth_level, created_at) VALUES (?, ?, ?, ?, ?)",
id, hashSecret(secret), userID, authLevel, now,
)
if err != nil {
return "", err
}
return id + "." + secret, nil
}
func UpgradeSessionToFull(ctx context.Context, db *sql.DB, sessionID string) error {
_, err := db.ExecContext(ctx,
"UPDATE sessions SET auth_level = ? WHERE id = ?",
SessionAuthLevelFull, sessionID,
)
return err
}

When primary auth succeeds:

  • if user has no TOTP credential: create full session
  • if user has TOTP enabled: create primary session and redirect to /2fa/challenge

Put this in auth/two_factor.go:

package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"strings"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
gonanoid "github.com/matoous/go-nanoid/v2"
)
var ErrInvalidTwoFactorCode = errors.New("invalid two-factor code")
// EncryptValue/DecryptValue should use authenticated encryption (AES-256-GCM)
// with a server-side key (KMS or env-injected key material).
func EncryptValue(plain string) (string, error) { panic("implement me") }
func DecryptValue(ciphertext string) (string, error) { panic("implement me") }
func HasTwoFactorEnabled(ctx context.Context, db *sql.DB, userID string) (bool, error) {
row := db.QueryRowContext(ctx,
"SELECT 1 FROM user_totp_credentials WHERE user_id = ?",
userID,
)
var one int
err := row.Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func GenerateTOTPSetup(secretLabel, issuer string) (*otp.Key, error) {
return totp.Generate(totp.GenerateOpts{
Issuer: issuer,
AccountName: secretLabel,
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
}
func VerifyTOTPCode(secret, code string, now time.Time) bool {
return totp.ValidateCustom(code, secret, now,
totp.ValidateOpts{Period: 30, Skew: 1, Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1},
)
}
func UpsertUserTOTPSecret(ctx context.Context, db *sql.DB, userID, secret string) error {
secretEncrypted, err := EncryptValue(secret)
if err != nil {
return err
}
credentialID, err := gonanoid.New()
if err != nil {
return err
}
now := time.Now().UTC().Unix()
_, err = db.ExecContext(ctx, `
INSERT INTO user_totp_credentials (id, user_id, secret_encrypted, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET secret_encrypted = excluded.secret_encrypted
`, credentialID, userID, secretEncrypted, now)
return err
}
func LoadUserTOTPSecret(ctx context.Context, db *sql.DB, userID string) (string, error) {
row := db.QueryRowContext(ctx,
"SELECT secret_encrypted FROM user_totp_credentials WHERE user_id = ?",
userID,
)
var secretEncrypted string
if err := row.Scan(&secretEncrypted); err != nil {
return "", err
}
return DecryptValue(secretEncrypted)
}
func randomToken(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func CreatePendingTwoFactorSetup(ctx context.Context, db *sql.DB, userID, secret string, ttl time.Duration) (string, error) {
secretEncrypted, err := EncryptValue(secret)
if err != nil {
return "", err
}
token, err := randomToken(32)
if err != nil {
return "", err
}
tokenHash := sha256.Sum256([]byte(token))
setupID, err := gonanoid.New()
if err != nil {
return "", err
}
now := time.Now().UTC()
_, err = db.ExecContext(ctx,
"DELETE FROM user_totp_pending_setups WHERE user_id = ?",
userID,
)
if err != nil {
return "", err
}
_, err = db.ExecContext(ctx, `
INSERT INTO user_totp_pending_setups (id, user_id, token_hash, secret_encrypted, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
`, setupID, userID, tokenHash[:], secretEncrypted, now.Unix(), now.Add(ttl).Unix())
if err != nil {
return "", err
}
return token, nil
}
func ConsumePendingTwoFactorSetup(ctx context.Context, db *sql.DB, userID, token string) (string, error) {
tokenHash := sha256.Sum256([]byte(token))
now := time.Now().UTC().Unix()
row := db.QueryRowContext(ctx, `
SELECT id, secret_encrypted
FROM user_totp_pending_setups
WHERE user_id = ? AND token_hash = ? AND expires_at > ?
`, userID, tokenHash[:], now)
var setupID string
var secretEncrypted string
if err := row.Scan(&setupID, &secretEncrypted); err != nil {
return "", err
}
if _, err := db.ExecContext(ctx,
"DELETE FROM user_totp_pending_setups WHERE id = ?",
setupID,
); err != nil {
return "", err
}
return DecryptValue(secretEncrypted)
}
func CreateRecoveryCodes(ctx context.Context, db *sql.DB, userID string, count int) ([]string, error) {
if count <= 0 {
count = 8
}
_, err := db.ExecContext(ctx, "DELETE FROM user_recovery_codes WHERE user_id = ?", userID)
if err != nil {
return nil, err
}
now := time.Now().UTC().Unix()
plain := make([]string, 0, count)
for i := 0; i < count; i++ {
codeID, _ := gonanoid.New()
codeRaw, _ := gonanoid.Generate("ABCDEFGHJKLMNPQRSTUVWXYZ23456789", 10)
code := strings.ToUpper(codeRaw)
hash := sha256.Sum256([]byte(code))
_, err = db.ExecContext(ctx,
"INSERT INTO user_recovery_codes (id, user_id, code_hash, created_at) VALUES (?, ?, ?, ?)",
codeID, userID, hash[:], now,
)
if err != nil {
return nil, err
}
plain = append(plain, fmt.Sprintf("%s-%s", code[:5], code[5:]))
}
return plain, nil
}
func ConsumeRecoveryCode(ctx context.Context, db *sql.DB, userID, code string) (bool, error) {
normalized := strings.ToUpper(strings.ReplaceAll(code, "-", ""))
h := sha256.Sum256([]byte(normalized))
now := time.Now().UTC().Unix()
result, err := db.ExecContext(ctx,
"UPDATE user_recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ? AND used_at IS NULL",
now, userID, h[:],
)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
if err != nil {
return false, err
}
return rows == 1, nil
}

This flow encrypts TOTP secrets before database writes and never puts the raw secret in browser-visible state.

Put this in handlers/two_factor.go:

package handlers
import (
"database/sql"
"net/http"
"time"
auth "github.com/.../auth"
)
func HandleTwoFactorSetupStart(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := auth.GetSession(r.Context())
if session == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
key, err := auth.GenerateTOTPSetup(session.UserID, "Sesame")
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
setupToken, err := auth.CreatePendingTwoFactorSetup(
r.Context(),
db,
session.UserID,
key.Secret(),
10*time.Minute,
)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "pending_2fa_setup",
Value: setupToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int((10 * time.Minute).Seconds()),
})
http.Redirect(w, r, "/2fa/setup", http.StatusSeeOther)
}
}
func HandleTwoFactorSetupConfirm(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := auth.GetSession(r.Context())
if session == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
code := r.FormValue("code")
c, err := r.Cookie("pending_2fa_setup")
if err != nil || code == "" {
http.Error(w, "Invalid setup state", http.StatusBadRequest)
return
}
secret, err := auth.ConsumePendingTwoFactorSetup(r.Context(), db, session.UserID, c.Value)
if err != nil {
http.Error(w, "Invalid setup state", http.StatusBadRequest)
return
}
if !auth.VerifyTOTPCode(secret, code, time.Now().UTC()) {
http.Error(w, "Invalid code", http.StatusBadRequest)
return
}
if err := auth.UpsertUserTOTPSecret(r.Context(), db, session.UserID, secret); err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
recoveryCodes, err := auth.CreateRecoveryCodes(r.Context(), db, session.UserID, 8)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{Name: "pending_2fa_setup", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
_ = recoveryCodes // render on a one-time page
http.Redirect(w, r, "/settings/security?two_factor=enabled", http.StatusSeeOther)
}
}

Render /2fa/setup server-side: load the pending setup token from cookie, decrypt the pending secret on the server, and generate the QR/otpauth payload there. Do not put otpauth:// data in query params.

Put this in handlers/two_factor.go:

func HandleTwoFactorChallenge(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session := auth.GetSession(r.Context())
if session == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if session.AuthLevel == auth.SessionAuthLevelFull {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
code := r.FormValue("code")
recoveryCode := r.FormValue("recovery_code")
valid := false
if code != "" {
secret, err := auth.LoadUserTOTPSecret(r.Context(), db, session.UserID)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
valid = auth.VerifyTOTPCode(secret, code, time.Now().UTC())
}
if !valid && recoveryCode != "" {
ok, err := auth.ConsumeRecoveryCode(r.Context(), db, session.UserID, recoveryCode)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
valid = ok
}
if !valid {
http.Error(w, "Invalid code", http.StatusUnauthorized)
return
}
if err := auth.UpgradeSessionToFull(r.Context(), db, session.ID); err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
}

Put this in auth/middleware.go:

func RequirePrimarySession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if GetSession(r.Context()) == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}
func RequireFullSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
s := GetSession(r.Context())
if s == nil || s.AuthLevel != SessionAuthLevelFull {
http.Redirect(w, r, "/2fa/challenge", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}

Put this in main.go:

// Setup (already logged in)
mux.Handle("POST /settings/2fa/setup/start", requireSameOrigin(auth.RequireFullSession(handlers.HandleTwoFactorSetupStart(db))))
mux.Handle("POST /settings/2fa/setup/confirm", requireSameOrigin(auth.RequireFullSession(handlers.HandleTwoFactorSetupConfirm(db))))
// Challenge after primary login
mux.Handle("GET /2fa/challenge", auth.RequirePrimarySession(http.HandlerFunc(handlers.HandleTwoFactorChallengePage)))
mux.Handle("POST /2fa/challenge", requireSameOrigin(auth.RequirePrimarySession(handlers.HandleTwoFactorChallenge(db))))
// Protect app routes with full session
mux.Handle("GET /dashboard", auth.RequireFullSession(http.HandlerFunc(handleDashboard)))

Put this in handlers/pages.go:

func HandleTwoFactorChallengePage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`<!doctype html>
<html>
<body>
<h1>Two-factor authentication</h1>
<form method="post" action="/2fa/challenge">
<label>Authenticator code <input name="code" inputmode="numeric" /></label><br />
<label>Recovery code <input name="recovery_code" /></label><br />
<button type="submit">Continue</button>
</form>
</body>
</html>`))
}

Your security settings page can start setup with a simple form:

<form method="post" action="/settings/2fa/setup/start">
<button type="submit">Enable 2FA</button>
</form>

Apply strict limits:

  • POST /2fa/challenge: key by user ID, low burst
  • POST /settings/2fa/setup/confirm: key by user ID

Reuse the token-bucket middleware from Rate Limiting.

Head to Passkeys to add WebAuthn for phishing-resistant auth.