
Create Auth
- 18 installs
- 39 repo stars
- Updated July 28, 2026
- himself65/auth-spec
Scaffold hand-written sign-up and sign-in auth endpoints with User/Session/Account schema, adapted to the detected stack and optional MFA, passkey, or org features.
About
Detects the project stack then scaffolds hand-written signin and signup endpoints plus the User, Session, and Account schema, with optional features like OTP, passkeys, 2FA, and organizations. A developer uses it to add authentication from scratch without an auth library.
- Interactive stack/feature selection with reference implementations for Next.js, Express, FastAPI, Go, Rust, Kotlin
- Bans auth libraries and bakes in email-enumeration protection and 7-day sessions
Create Auth by the numbers
- 18 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,472 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/himself65/auth-spec --skill create-authAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 18 |
|---|---|
| repo stars | ★ 39 |
| Last updated | July 28, 2026 |
| Repository | himself65/auth-spec ↗ |
What it does
Scaffold hand-written sign-up and sign-in auth endpoints with User/Session/Account schema, adapted to the detected stack and optional MFA, passkey, or org features.
Files
Create Auth
You are scaffolding authentication (signin + signup) for the user's project.
Step 1: Detect Existing Project Context
Before asking any questions, scan the user's project to detect their stack:
1. Look for framework config files (e.g., next.config.*, package.json, go.mod, Cargo.toml, pyproject.toml, build.gradle*, pom.xml) 2. Look for existing database/ORM setup (e.g., prisma/schema.prisma, drizzle.config.*, alembic/, diesel.toml, ormconfig.*) 3. Look for existing auth code or dependencies
Use what you find to pre-select the best options in the questions below. If the project clearly uses a specific stack, set that as the recommended option.
Step 2: Gather Context with Interactive Questions
Use the AskUserQuestion tool to ask the user to make selections. Ask up to 3 questions in a single AskUserQuestion call so the user can answer everything at once.
Question 1: Language/Framework
Ask "Which language and framework are you using?" with header "Framework".
Pick the top 4 most relevant options based on what you detected in the project. If you detected the framework, put it first and mark it "(Recommended)". If you could not detect it, use these defaults:
- Next.js — "TypeScript, App Router, API routes"
- Express — "TypeScript/JavaScript, minimal and flexible"
- FastAPI — "Python, async-first with type hints"
- Go + Chi — "Go, lightweight and idiomatic"
The user can always pick "Other" to specify a different stack.
Question 2: Database/ORM
Ask "Which database and ORM/query layer?" with header "Database".
Again, pick the top 4 most relevant options based on the project. If detected, mark it "(Recommended)". Defaults:
- PostgreSQL + Prisma — "Type-safe ORM with migrations (JS/TS)"
- PostgreSQL + Drizzle — "Lightweight TypeScript ORM, SQL-like syntax"
- PostgreSQL + SQLAlchemy — "Full-featured Python ORM"
- SQLite + raw queries — "Simple, no server needed, good for prototyping"
Question 3: Session Strategy
Ask "How should sessions be managed?" with header "Sessions".
- Database sessions (Recommended) — "Server-side sessions stored in your database. More secure — sessions can be revoked instantly"
- JWT tokens — "Stateless tokens signed by the server. Simpler to scale, but harder to revoke"
Step 3: Ask Which Features to Add
After the user answers the stack questions, use AskUserQuestion again to ask which additional auth features they want. Use multiSelect: true so they can pick multiple features at once.
Question 1: Authentication Methods
Ask "Which authentication methods do you want to add?" with header "Auth methods". Set multiSelect to true.
- Email OTP — "Passwordless sign-in via one-time codes sent to email"
- Magic Link — "Passwordless sign-in via emailed links"
- Phone Number — "SMS-based OTP authentication"
- Passkey — "WebAuthn/FIDO2 passwordless authentication"
Question 2: Security Features
Ask "Which security features do you want?" with header "Security". Set multiSelect to true.
- Two-Factor Auth (Recommended) — "TOTP-based second factor with backup codes"
- Captcha — "Bot protection on sign-up and sign-in (reCAPTCHA, hCaptcha, Turnstile)"
- Password Breach Check — "Check passwords against the Have I Been Pwned database"
- Rate Limiting — "Throttle auth endpoints to prevent brute-force attacks (includes KV cache)"
Question 3: Additional Capabilities
Ask "Any additional capabilities?" with header "Extras". Set multiSelect to true.
- Multi-Session — "Allow multiple concurrent sessions per user"
- Username Auth — "Sign in with username instead of (or in addition to) email"
- Organization / Teams — "Multi-tenant support with roles, invitations, and RBAC"
- API Keys — "Generate API keys for programmatic access"
Step 4: Wait for All Answers
Do not write any code until the user has answered all questions. Once you have their selections, proceed to Step 5.
Step 5: Generate Auth
Generate the core auth (schema + endpoints below) plus any selected features. For each selected feature, read the matching reference file from references/features/ to get the schema additions, endpoint specs, and implementation details.
Dependency: If the user selects Rate Limiting, also read references/features/kv-cache.md and generate the KV cache module first — rate limiting depends on it. The KV cache is a general-purpose utility that other features can also use, so generate it as a standalone module.
| Feature | Reference file |
|---|---|
| Email OTP | references/features/email-otp.md |
| Magic Link | references/features/magic-link.md |
| Phone Number | references/features/phone-number.md |
| Passkey | references/features/passkey.md |
| Two-Factor Auth | references/features/two-factor.md |
| Captcha | references/features/captcha.md |
| Password Breach | references/features/password-breach.md |
| Rate Limiting | references/features/rate-limiting.md |
| KV Cache | references/features/kv-cache.md |
| Multi-Session | references/features/multi-session.md |
| Username Auth | references/features/username.md |
| Organization/Teams | references/features/organization.md |
| API Keys | references/features/api-key.md |
Core Schema and Endpoints
Generate the following core auth using the schema and endpoint specs below.
Adapt everything to the user's language/framework idioms:
- Naming:
email_verified(snake_case) in Python/Go/Rust,emailVerified(camelCase) in JS/TS,EmailVerified(PascalCase) in C# - Types: use the language's native types (e.g.
std::stringin C++,Stringin Rust/Java,stringin Go/TS) - IDs: use idiomatic generation —
uuid.New()(Go),Uuid::new_v4()(Rust),crypto.randomUUID()(JS),uuid4()(Python),boost::uuids::random_generator()(C++), etc. - Password hashing: use the idiomatic library —
bcrypt(Go/JS/Python),argon2(Rust),libsodium(C/C++), etc. - Error handling: use the language's conventions (Result types in Rust, error returns in Go, exceptions in Python/Java, etc.)
- File structure: follow the project's existing layout and conventions
Schema
Create these tables/models:
User
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| string | unique, not null | |
| name | string | nullable |
| emailVerified | boolean | default false |
| createdAt | datetime | default now |
| updatedAt | datetime | auto-update |
Session
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| userId | string | foreign key -> User, not null |
| token | string | unique, not null |
| expiresAt | datetime | not null |
| createdAt | datetime | default now |
Account
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| userId | string | foreign key -> User, not null |
| providerId | string | not null (e.g. "credential") |
| passwordHash | string | nullable |
| createdAt | datetime | default now |
| updatedAt | datetime | auto-update |
Endpoints
POST /api/auth/sign-up
- Body:
{ email, password, name? } - Validate email format and password length (min 8 chars)
- Hash password with a strong algorithm (bcrypt, argon2, or scrypt — use whichever is idiomatic for the language)
- Create User + Account (providerId: "credential") + Session
- Return session token and user (without password)
- Email enumeration protection: If the email already exists, return the same
200 OKstatus and same response shape as a successful sign-up — do not return 409 or any error that reveals the email is taken. The response should be indistinguishable from a real sign-up. Implementation: attempt the insert, catch the unique constraint violation, hash the password anyway (to keep timing consistent), and return a fake success with a dummy user ID and token (that won't actually work as a session). This prevents attackers from discovering which emails are registered via the sign-up endpoint.
POST /api/auth/sign-in
- Body:
{ email, password } - Look up user by email, verify password hash
- Create new Session
- Return session token and user (without password)
- Return 401 on invalid credentials (generic message, no user enumeration)
GET /api/auth/session
- Read session token from Authorization header (Bearer) or cookie
- Look up session, verify not expired
- Return user info if valid, 401 if not
POST /api/auth/sign-out
- Read session token
- Delete session from database
- Return 200
Implementation Rules
- Write all auth code by hand. Do NOT use auth libraries (better-auth, next-auth, Auth.js, lucia, passport, etc.). The only external dependencies allowed are: the web framework itself, the database/ORM layer, and a password hashing library (bcrypt, argon2, scrypt). Everything else — session management, token generation, route handlers — must be written directly. Keep it minimal.
- Use crypto-random IDs for all primary keys and session tokens — use the idiomatic method for the language (
crypto.randomUUID(),uuid.New(),Uuid::new_v4(),secrets.token_hex(), etc.) - Hash passwords with a strong algorithm — use what's standard for the ecosystem (bcrypt, argon2, scrypt, libsodium, etc.)
- Never log or expose password hashes
- Use constant-time comparison for password verification (the hashing library handles this)
- Set session expiry to 7 days by default
- Return generic "Invalid credentials" on sign-in failure — do not reveal whether the email exists
- Prevent email enumeration on sign-up: When a duplicate email is submitted, return the same status code and response shape as a successful sign-up. Always hash the password (even for duplicates) to prevent timing-based detection. Return a plausible but non-functional fake token and user ID so the response is indistinguishable from a real sign-up.
- Follow the project's existing code style, file structure, and patterns
- If the language has a strong type system (Rust, Go, C++, etc.), define proper types/structs for request/response bodies — do not use untyped maps
Step 6: Run the Migration
After generating all code, run the database migration automatically so the user doesn't hit "table does not exist" errors. Use the project's existing database driver/connection to execute the migration SQL.
For JS/TS projects using @neondatabase/serverless, the tagged-template sql function cannot run plain SQL strings. Use sql.query(statement) instead when executing migration statements programmatically.
Common Pitfalls
Before generating code, read all files in references/pitfalls/ and follow their rules strictly. These are real bugs encountered in production.
| Pitfall | Reference file |
|---|---|
| API routes must catch DB errors | references/pitfalls/api-error-handling.md |
| Sign-up catch must not re-throw | references/pitfalls/signup-rethrow.md |
| Auth helpers must not throw | references/pitfalls/auth-helpers-no-throw.md |
| Client must handle non-JSON | references/pitfalls/client-json-parsing.md |
| OAuth redirect must not use request.url | references/pitfalls/oauth-redirect-request-url.md |
| API key hash/gen must not be duplicated | references/pitfalls/api-key-shared-utils.md |
Reference Implementations
Full working examples are in the references/ directory alongside this skill. Use the matching reference as a starting point and adapt to the user's specific setup:
| File | Stack |
|---|---|
nextjs-drizzle.ts | Next.js App Router + Drizzle + PostgreSQL |
express-prisma.ts | Express + Prisma + PostgreSQL |
go-chi.go | Go + Chi + database/sql + PostgreSQL |
fastapi-sqlalchemy.py | FastAPI + SQLAlchemy + PostgreSQL |
axum-sqlx.rs | Rust + Axum + sqlx + PostgreSQL |
spring-boot.kt | Kotlin + Spring Boot + JPA + PostgreSQL |
If the user's stack doesn't match any reference, use the closest one as a structural guide and adapt idioms accordingly.
// Reference: Rust + Axum + sqlx + PostgreSQL
// This shows the complete auth implementation pattern for Rust.
// --- Cargo.toml dependencies ---
// axum = "0.8"
// sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
// argon2 = "0.5"
// uuid = { version = "1", features = ["v4"] }
// chrono = { version = "0.4", features = ["serde"] }
// serde = { version = "1", features = ["derive"] }
// serde_json = "1"
// rand = "0.8"
// hex = "0.4"
// tokio = { version = "1", features = ["full"] }
use axum::{
extract::{Json, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
Router,
};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
// --- models ---
#[derive(sqlx::FromRow, Serialize, Clone)]
pub struct User {
pub id: String,
pub email: String,
pub name: Option<String>,
pub email_verified: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct UserResponse {
pub id: String,
pub email: String,
pub name: Option<String>,
}
impl From<User> for UserResponse {
fn from(u: User) -> Self {
Self {
id: u.id,
email: u.email,
name: u.name,
}
}
}
// --- request/response types ---
#[derive(Deserialize)]
pub struct SignUpRequest {
pub email: String,
pub password: String,
pub name: Option<String>,
}
#[derive(Deserialize)]
pub struct SignInRequest {
pub email: String,
pub password: String,
}
#[derive(Serialize)]
pub struct AuthResponse {
pub user: UserResponse,
pub token: String,
}
#[derive(Serialize)]
pub struct SessionResponse {
pub user: UserResponse,
pub expires_at: DateTime<Utc>,
}
#[derive(Serialize)]
pub struct ErrorResponse {
pub error: String,
}
// --- helpers ---
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::thread_rng().fill(&mut bytes);
hex::encode(bytes)
}
fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
let salt = SaltString::generate(&mut rand::thread_rng());
let hash = Argon2::default().hash_password(password.as_bytes(), &salt)?;
Ok(hash.to_string())
}
fn verify_password(password: &str, hash: &str) -> bool {
use argon2::{Argon2, PasswordHash, PasswordVerifier};
let Ok(parsed) = PasswordHash::new(hash) else {
return false;
};
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok()
}
const SESSION_DURATION_DAYS: i64 = 7;
fn error_json(status: StatusCode, msg: &str) -> impl IntoResponse {
(status, Json(ErrorResponse { error: msg.to_string() }))
}
fn extract_bearer_token(headers: &HeaderMap) -> Option<String> {
headers
.get("authorization")?
.to_str()
.ok()
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.to_string())
}
// --- router ---
pub fn auth_router() -> Router<PgPool> {
Router::new()
.route("/sign-up", post(sign_up))
.route("/sign-in", post(sign_in))
.route("/session", get(get_session))
.route("/sign-out", post(sign_out))
}
// --- handlers ---
async fn sign_up(
State(pool): State<PgPool>,
Json(req): Json<SignUpRequest>,
) -> impl IntoResponse {
if req.email.is_empty() || req.password.len() < 8 {
return error_json(StatusCode::BAD_REQUEST, "invalid email or password (min 8 chars)").into_response();
}
// Always hash password to prevent timing-based email enumeration
let password_hash = match hash_password(&req.password) {
Ok(h) => h,
Err(_) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
};
let user_id = Uuid::new_v4().to_string();
let token = generate_token();
let now = Utc::now();
let expires_at = now + Duration::days(SESSION_DURATION_DAYS);
let mut tx = match pool.begin().await {
Ok(tx) => tx,
Err(_) => return error_json(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response(),
};
let insert_result = sqlx::query(
"INSERT INTO users (id, email, name, email_verified, created_at, updated_at) VALUES ($1, $2, $3, false, $4, $4)"
)
.bind(&user_id).bind(&req.email).bind(&req.name).bind(now)
.execute(&mut *tx).await;
if let Err(e) = insert_result {
// Unique constraint violation (duplicate email) — return fake success
// to prevent email enumeration. The dummy token won't resolve to a session.
let msg = e.to_string();
if msg.contains("unique") || msg.contains("duplicate") {
return Json(AuthResponse {
user: UserResponse { id: Uuid::new_v4().to_string(), email: req.email, name: req.name },
token: generate_token(),
}).into_response();
}
return error_json(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
}
let _ = sqlx::query(
"INSERT INTO accounts (id, user_id, provider_id, password_hash, created_at, updated_at) VALUES ($1, $2, 'credential', $3, $4, $4)"
)
.bind(Uuid::new_v4().to_string()).bind(&user_id).bind(&password_hash).bind(now)
.execute(&mut *tx).await;
let _ = sqlx::query(
"INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)"
)
.bind(Uuid::new_v4().to_string()).bind(&user_id).bind(&token).bind(expires_at).bind(now)
.execute(&mut *tx).await;
if tx.commit().await.is_err() {
return error_json(StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response();
}
Json(AuthResponse {
user: UserResponse { id: user_id, email: req.email, name: req.name },
token,
}).into_response()
}
async fn sign_in(
State(pool): State<PgPool>,
Json(req): Json<SignInRequest>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, (String, String, Option<String>, Option<String>)>(
"SELECT u.id, u.email, u.name, a.password_hash FROM users u JOIN accounts a ON a.user_id = u.id WHERE u.email = $1 AND a.provider_id = 'credential'"
)
.bind(&req.email)
.fetch_optional(&pool)
.await
.unwrap_or(None);
let Some((user_id, email, name, Some(password_hash))) = row else {
return error_json(StatusCode::UNAUTHORIZED, "invalid credentials").into_response();
};
if !verify_password(&req.password, &password_hash) {
return error_json(StatusCode::UNAUTHORIZED, "invalid credentials").into_response();
}
let token = generate_token();
let now = Utc::now();
let _ = sqlx::query(
"INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)"
)
.bind(Uuid::new_v4().to_string()).bind(&user_id).bind(&token)
.bind(now + Duration::days(SESSION_DURATION_DAYS)).bind(now)
.execute(&pool).await;
Json(AuthResponse {
user: UserResponse { id: user_id, email, name },
token,
}).into_response()
}
async fn get_session(
State(pool): State<PgPool>,
headers: HeaderMap,
) -> impl IntoResponse {
let Some(token) = extract_bearer_token(&headers) else {
return error_json(StatusCode::UNAUTHORIZED, "unauthorized").into_response();
};
let row = sqlx::query_as::<_, (String, String, Option<String>, DateTime<Utc>)>(
"SELECT u.id, u.email, u.name, s.expires_at FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token = $1"
)
.bind(&token)
.fetch_optional(&pool)
.await
.unwrap_or(None);
let Some((user_id, email, name, expires_at)) = row else {
return error_json(StatusCode::UNAUTHORIZED, "unauthorized").into_response();
};
if expires_at < Utc::now() {
return error_json(StatusCode::UNAUTHORIZED, "unauthorized").into_response();
}
Json(SessionResponse {
user: UserResponse { id: user_id, email, name },
expires_at,
}).into_response()
}
async fn sign_out(
State(pool): State<PgPool>,
headers: HeaderMap,
) -> impl IntoResponse {
if let Some(token) = extract_bearer_token(&headers) {
let _ = sqlx::query("DELETE FROM sessions WHERE token = $1")
.bind(&token)
.execute(&pool)
.await;
}
Json(serde_json::json!({"success": true}))
}
// Reference: Express + Prisma + PostgreSQL
// This shows the complete auth implementation pattern for Express.
// --- prisma/schema.prisma ---
// model User {
// id String @id @default(uuid())
// email String @unique
// name String?
// emailVerified Boolean @default(false)
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// accounts Account[]
// sessions Session[]
// }
//
// model Session {
// id String @id @default(uuid())
// userId String
// token String @unique
// expiresAt DateTime
// createdAt DateTime @default(now())
// user User @relation(fields: [userId], references: [id])
// }
//
// model Account {
// id String @id @default(uuid())
// userId String
// providerId String
// passwordHash String?
// createdAt DateTime @default(now())
// updatedAt DateTime @updatedAt
// user User @relation(fields: [userId], references: [id])
// }
// --- src/routes/auth.ts ---
import { Router, Request, Response } from "express";
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
import crypto from "node:crypto";
const prisma = new PrismaClient();
const router = Router();
router.post("/sign-up", async (req: Request, res: Response) => {
const { email, password, name } = req.body;
if (!email || !password || password.length < 8) {
return res.status(400).json({ error: "Invalid email or password (min 8 chars)" });
}
// Always hash the password to prevent timing-based email enumeration
const passwordHash = await bcrypt.hash(password, 12);
const sessionToken = crypto.randomUUID();
try {
const user = await prisma.user.create({
data: {
email,
name,
accounts: {
create: { providerId: "credential", passwordHash },
},
sessions: {
create: {
token: sessionToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
},
},
select: { id: true, email: true, name: true },
});
return res.status(200).json({ user, token: sessionToken });
} catch (err: unknown) {
// Unique constraint violation (duplicate email) — return fake success
// to prevent email enumeration. The dummy token won't resolve to a session.
if (
err instanceof Error &&
(err.message.includes("Unique constraint") || err.message.includes("duplicate"))
) {
return res.status(200).json({
user: { id: crypto.randomUUID(), email, name: name ?? null },
token: crypto.randomUUID(),
});
}
throw err;
}
});
router.post("/sign-in", async (req: Request, res: Response) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({
where: { email },
include: { accounts: { where: { providerId: "credential" } } },
});
if (!user || !user.accounts[0]?.passwordHash) {
return res.status(401).json({ error: "Invalid credentials" });
}
const valid = await bcrypt.compare(password, user.accounts[0].passwordHash);
if (!valid) {
return res.status(401).json({ error: "Invalid credentials" });
}
const sessionToken = crypto.randomUUID();
await prisma.session.create({
data: {
userId: user.id,
token: sessionToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return res.json({
user: { id: user.id, email: user.email, name: user.name },
token: sessionToken,
});
});
router.get("/session", async (req: Request, res: Response) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return res.status(401).json({ error: "Unauthorized" });
}
const session = await prisma.session.findUnique({
where: { token },
include: { user: { select: { id: true, email: true, name: true } } },
});
if (!session || session.expiresAt < new Date()) {
return res.status(401).json({ error: "Unauthorized" });
}
return res.json({ user: session.user, expiresAt: session.expiresAt });
});
router.post("/sign-out", async (req: Request, res: Response) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
await prisma.session.deleteMany({ where: { token } });
}
return res.json({ success: true });
});
export default router;
# Reference: FastAPI + SQLAlchemy + PostgreSQL
# This shows the complete auth implementation pattern for Python/FastAPI.
# --- models.py ---
import uuid
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, func
from sqlalchemy.orm import DeclarativeBase, relationship
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
email = Column(String, unique=True, nullable=False)
name = Column(String, nullable=True)
email_verified = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
accounts = relationship("Account", back_populates="user")
sessions = relationship("Session", back_populates="user")
class Session(Base):
__tablename__ = "sessions"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String, ForeignKey("users.id"), nullable=False)
token = Column(String, unique=True, nullable=False)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
user = relationship("User", back_populates="sessions")
class Account(Base):
__tablename__ = "accounts"
id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id = Column(String, ForeignKey("users.id"), nullable=False)
provider_id = Column(String, nullable=False)
password_hash = Column(String, nullable=True)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
user = relationship("User", back_populates="accounts")
# --- schemas.py ---
from pydantic import BaseModel, EmailStr
class SignUpRequest(BaseModel):
email: EmailStr
password: str
name: str | None = None
class SignInRequest(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: str
email: str
name: str | None
model_config = {"from_attributes": True}
class AuthResponse(BaseModel):
user: UserResponse
token: str
class SessionResponse(BaseModel):
user: UserResponse
expires_at: datetime
# --- routes.py ---
import secrets
from datetime import datetime, timedelta, timezone
import bcrypt
from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(prefix="/api/auth")
SESSION_DURATION = timedelta(days=7)
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(12)).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
@router.post("/sign-up", response_model=AuthResponse)
async def sign_up(req: SignUpRequest, db: AsyncSession = Depends(get_db)):
if len(req.password) < 8:
raise HTTPException(status_code=400, detail="Password must be at least 8 characters")
# Always hash password to prevent timing-based email enumeration
hashed = hash_password(req.password)
user = User(email=req.email, name=req.name)
account = Account(
user_id=user.id,
provider_id="credential",
password_hash=hashed,
)
token = secrets.token_hex(32)
session = Session(
user_id=user.id,
token=token,
expires_at=datetime.now(timezone.utc) + SESSION_DURATION,
)
try:
db.add_all([user, account, session])
await db.commit()
await db.refresh(user)
except Exception:
await db.rollback()
# Unique constraint violation (duplicate email) — return fake success
# to prevent email enumeration. The dummy token won't resolve to a session.
return AuthResponse(
user=UserResponse(id=str(uuid.uuid4()), email=req.email, name=req.name),
token=secrets.token_hex(32),
)
return AuthResponse(
user=UserResponse.model_validate(user),
token=token,
)
@router.post("/sign-in", response_model=AuthResponse)
async def sign_in(req: SignInRequest, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User).where(User.email == req.email))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="Invalid credentials")
result = await db.execute(
select(Account).where(
Account.user_id == user.id,
Account.provider_id == "credential",
)
)
account = result.scalar_one_or_none()
if not account or not account.password_hash:
raise HTTPException(status_code=401, detail="Invalid credentials")
if not verify_password(req.password, account.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = secrets.token_hex(32)
session = Session(
user_id=user.id,
token=token,
expires_at=datetime.now(timezone.utc) + SESSION_DURATION,
)
db.add(session)
await db.commit()
return AuthResponse(
user=UserResponse.model_validate(user),
token=token,
)
@router.get("/session", response_model=SessionResponse)
async def get_session(
authorization: str = Header(...),
db: AsyncSession = Depends(get_db),
):
token = authorization.removeprefix("Bearer ").strip()
if not token:
raise HTTPException(status_code=401, detail="Unauthorized")
result = await db.execute(select(Session).where(Session.token == token))
session = result.scalar_one_or_none()
if not session or session.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="Unauthorized")
result = await db.execute(select(User).where(User.id == session.user_id))
user = result.scalar_one()
return SessionResponse(
user=UserResponse.model_validate(user),
expires_at=session.expires_at,
)
@router.post("/sign-out")
async def sign_out(
authorization: str = Header(default=""),
db: AsyncSession = Depends(get_db),
):
token = authorization.removeprefix("Bearer ").strip()
if token:
result = await db.execute(select(Session).where(Session.token == token))
session = result.scalar_one_or_none()
if session:
await db.delete(session)
await db.commit()
return {"success": True}
API Keys
Generate and manage API keys for programmatic access.
Schema Additions
ApiKey
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| userId | string | foreign key -> User, not null |
| name | string | not null |
| keyHash | string | unique, not null (SHA-256 of key) |
| prefix | string | not null (first 8 chars of key) |
| scopes | string | nullable (JSON array of scopes) |
| enabled | boolean | default true |
| expiresAt | datetime | nullable |
| lastUsedAt | datetime | nullable |
| createdAt | datetime | default now |
| updatedAt | datetime | auto-update |
Key Format
API keys follow the format: {prefix}_{secret}
- Prefix: a short identifier (e.g.
fd,sk,pk) followed by underscore and 8 random chars for lookup - Secret: 32 bytes crypto-random, base62 encoded
- Full key example:
fd_x7K9mP2v_L5nQ8wR3tY6uI1oP4sD7fG - The full key is shown ONCE at creation — only the hash is stored
Shared Utilities
The hash function and key generation logic should be extracted into a shared utility module. Both the key management endpoints and the authentication middleware need to hash keys — duplicating this logic leads to drift. Extract into a single module (e.g. api-key-utils) that exports:
hashApiKey(key)— SHA-256 hashgenerateApiKey()— returns{ fullKey, prefix }- Constants:
API_KEY_PREFIX,MAX_KEYS_PER_USER
Endpoints
POST /api/auth/api-keys
- Requires valid session (Bearer token)
- Body:
{ name, scopes?, expiresAt? } - Generate key, store SHA-256 hash + prefix
- Return
{ id, name, key, prefix, scopes, expiresAt, createdAt } keyis the full plaintext key — shown only at creation
GET /api/auth/api-keys
- Requires valid session (Bearer token)
- Return all API keys for the user (without key or hash)
- Each entry:
{ id, name, prefix, scopes, enabled, expiresAt, lastUsedAt, createdAt }
DELETE /api/auth/api-keys/:keyId
- Requires valid session (Bearer token)
- Delete the specified key if it belongs to the current user
- Return 200
PATCH /api/auth/api-keys/:keyId _(optional)_
- Requires valid session (Bearer token)
- Body:
{ enabled?, name?, scopes?, expiresAt? } - Update the specified key. The
enabledfield allows disabling a key without deleting it (useful for incident response). - Return updated key metadata
API Key Authentication (middleware)
- Check for
Authorization: Bearer {prefix}_...header orX-API-Keyheader - Extract prefix from the key
- Look up matching ApiKey records by prefix
- Hash the provided key with SHA-256 and compare to stored keyHash
- If valid, not expired, and enabled: set the authenticated user from userId, update lastUsedAt
- If invalid, expired, or disabled: return 401
Implementation Rules
- Never store the plaintext API key — only store the SHA-256 hash
- The prefix is stored separately for efficient lookups (avoids hashing every request against all keys)
- Use SHA-256 (not bcrypt) for API key hashing — keys are high-entropy so brute force is not practical, and lookup speed matters
- Update
lastUsedAton each successful authentication (fire-and-forget to avoid latency) - Scopes are optional — if present, they restrict what the key can access
- A user should have a max of 25 active API keys
- API key auth should work alongside session auth (check both)
- Extract hash/generation into a shared utility — do not duplicate across router and middleware
Best Practices (Industry Consensus)
- Key format: `prefix_secret` following Stripe's well-established pattern (
sk_live_...,sk_test_...). The prefix encodes environment and key type, making keys visually identifiable and enabling secret-scanning tools (e.g., GitHub secret scanning) to detect leaked keys by pattern. - Use SHA-256 for hashing, not bcrypt. API keys are high-entropy (256-bit random), so brute-force is not practical. SHA-256 provides O(1) lookup speed, while bcrypt's intentional slowness would create unacceptable latency on every API request. This is the approach used by Stripe, Laravel Sanctum, better-auth, and Supabase.
- Prefix stored in plaintext for O(1) lookup. On authentication, extract the prefix, query matching rows, then SHA-256 the full key and compare. This avoids hashing against every key in the database.
- Alternative lookup: `{id}|{token}` format. Laravel Sanctum embeds the key's database ID in the token (
{id}|{secret}), enabling single-row lookup by PK before hash comparison. This is more efficient than prefix-based multi-row scan at scale. Consider this if key volume grows large. - Show the full key only once at creation. After creation, only the prefix and metadata are retrievable. Stripe, GitHub, and all major providers follow this pattern. Prompt the user to copy it immediately.
- Max 25 keys per user. Prevents key sprawl and limits blast radius. Stripe and GitHub both impose per-user/per-org limits on active tokens.
- Scopes for least-privilege access. Each key should declare what it can access (e.g.,
read:users,write:billing). Reject requests outside the key's scopes with 403. Laravel Sanctum calls these "abilities" and enforces them viatokenCan(). - Track `lastUsedAt` for auditing. Update on each successful authentication. Surface this in the key listing UI so users can identify and revoke unused keys. Stripe shows last-used timestamps in the dashboard.
- Include an `enabled` field for soft revocation. Both djangorestframework-api-key (
revokedboolean) and better-auth (enabledboolean) support disabling keys without deleting them. This preserves audit trail and allows re-enabling if a key was disabled in error during incident response. - Include `updatedAt` for audit. Track when key metadata was last modified. Most auth libraries include this field.
- Avoid prefix collisions with common words. Choose a prefix that won't be mistaken for profanity or common abbreviations. For example, prefer
fd_overfk_. Stripe usessk_/pk_/rk_; GitHub usesghp_/github_pat_.
Captcha
Bot protection on authentication endpoints using CAPTCHA verification.
Schema Additions
None — captcha is stateless and verified server-side via provider API.
Configuration
The implementation should accept a captcha configuration:
provider: "recaptcha" | "hcaptcha" | "turnstile"secretKey: server-side verification keysiteKey: client-side key (returned in config endpoint)endpoints: which endpoints require captcha (default: sign-up, sign-in)
Endpoints
GET /api/auth/captcha/config
- Returns
{ provider, siteKey }so the client can render the captcha widget - No authentication required
Middleware: Captcha Verification Rather than a separate endpoint, add captcha verification as middleware on protected endpoints.
Protected endpoints receive an additional field:
captchaTokenin the request body
Verification: 1. Extract captchaToken from request body 2. Call the provider's verification API:
- reCAPTCHA:
POST https://www.google.com/recaptcha/api/siteverify - hCaptcha:
POST https://hcaptcha.com/siteverify - Turnstile:
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
3. Send { secret, response: captchaToken } (plus remoteip optionally) 4. If verification fails: return 400 { error: "captcha_failed" } 5. If verification passes: proceed with the endpoint logic
Implementation Rules
- Captcha verification is server-side only — never trust the client
- Make captcha optional per-endpoint via configuration
- Provider API calls should have a timeout (5 seconds)
- If the provider API is unreachable, decide based on config: fail-open or fail-closed (default: fail-closed)
- Log captcha failures for monitoring but do not expose provider details to the client
- The captchaToken field should be stripped from the body before passing to the endpoint handler
Best Practices (Industry Consensus)
- Provider comparison:
- reCAPTCHA v3 (Google) — invisible, score-based (0.0–1.0); widely adopted but raises privacy concerns due to Google tracking
- hCaptcha — privacy-focused alternative, API-compatible with reCAPTCHA v2; used by Cloudflare previously
- Turnstile (Cloudflare) — invisible, non-interactive JS challenges; WCAG 2.2 AAA compliant; no user friction in most cases; free tier available
- Invisible captcha preferred for UX: reCAPTCHA v3 and Turnstile both run without user interaction — prefer these over challenge-based v2 widgets
- Server-side only: never trust client-side captcha results; always validate tokens via the provider's siteverify API — unverified tokens may be invalid, expired, or already redeemed
- Timeout on provider API: set a 5-second timeout on verification calls; default to fail-closed (reject the request) if the provider is unreachable
- Make captcha configurable per-endpoint: not all endpoints need captcha — sign-up and sign-in are high-value targets; session refresh is not
- Turnstile as a drop-in replacement: Cloudflare provides migration guides from reCAPTCHA and hCaptcha with minimal code changes
- Score thresholds (reCAPTCHA v3): tune the score threshold per action — 0.5 is a reasonable default; lower thresholds for sensitive actions like sign-up
Sources: Cloudflare Turnstile Docs, reCAPTCHA v3 Docs, hCaptcha Developer Guide
Email OTP
Passwordless authentication via one-time codes sent to email.
Schema Additions
EmailVerificationCode
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| userId | string | foreign key -> User, nullable (for sign-up flows) |
| string | not null | |
| code | string | not null (6-digit numeric) |
| expiresAt | datetime | not null (default: 10 minutes) |
| createdAt | datetime | default now |
Endpoints
POST /api/auth/email-otp/send
- Body:
{ email } - Generate a 6-digit numeric code (crypto-random)
- Store code with 10-minute expiry
- Send code via email (use the project's email service)
- Return 200 (always — do not reveal whether email exists)
- Rate limit: max 3 requests per email per 10 minutes
POST /api/auth/email-otp/verify
- Body:
{ email, code } - Look up the most recent unexpired code for this email
- If valid: create or find User, create Session, delete code, return token + user
- If invalid or expired: return 401 with generic error
- Delete code after successful verification (single use)
Implementation Rules
- Codes MUST be 6 digits, zero-padded (e.g., "003847")
- Generate with crypto-random, not Math.random
- Each code is single-use — delete after verification
- Delete all previous codes for the same email when generating a new one
- Constant-time comparison for code verification
- Do not reveal in error messages whether the email exists
- If the user does not exist, create a new User + Account (providerId: "email-otp") on successful verification
Best Practices (Industry Consensus)
Code Format and Entropy
- Use 6-digit numeric codes — provides ~20 bits of entropy, meeting the OWASP ASVS
minimum (OWASP ASVS V2.8). Longer alphanumeric codes (e.g., 8 chars) improve entropy but hurt usability on mobile; 6 digits is the dominant industry choice.
- Generate codes with a cryptographically secure RNG (
crypto.randomInt, notMath.random).
Expiry
- 10 minutes is the OWASP-recommended maximum for out-of-band codes (OWASP Cheat Sheet).
- Supabase defaults to 60 minutes but allows configuration; Auth.js magic-link tokens
default to 24 hours. For OTP (manual entry), 5-10 minutes is the safe range — shorter windows reduce brute-force exposure.
Attempt Limits
- 3-5 failed attempts per code, then invalidate it and require a new send
(OWASP recommends 3). This caps brute-force probability to ~0.3% for a 6-digit code.
- Optionally apply a temporary lockout (30-60 s) after exhausting attempts.
Rate Limiting (Send Endpoint)
- Max 3 sends per email per 10-minute window to prevent OTP flooding / email bombing.
- Also rate-limit by IP (e.g., 10 sends per IP per 10 min) to block distributed abuse.
- Enforce a minimum resend interval (e.g., 60 seconds) per email — Supabase enforces this.
- Consider CAPTCHA on repeated triggers from the same IP or email.
Storage and Comparison
- Hash OTP codes at rest — Supabase stores hashed tokens; OWASP ASVS V2.7.3 requires
the verifier to retain "only a hashed version of the authentication code." While a 6-digit keyspace is small, hashing still raises the bar for opportunistic DB access.
- Use a fast hash (SHA-256 with a per-row salt) rather than bcrypt — OTPs are short-lived
and low-entropy, so slow hashing adds latency without meaningful brute-force resistance.
- Constant-time comparison (
crypto.timingSafeEqual) to prevent timing side-channels
(OWASP Authentication Cheat Sheet).
Cleanup
- Delete all previous codes for the same email when generating a new one.
- Purge expired codes via a periodic cron job or lazy cleanup (check-and-delete on the
next request for that email). Avoid unbounded table growth.
Response Privacy
- Never reveal whether an email exists — always return 200 on send, generic errors on
verify (OWASP Authentication Cheat Sheet). This matches NIST SP 800-63B guidance on minimizing oracle attacks against user enumeration.
NIST Caveat
- NIST SP 800-63B explicitly states email "SHALL NOT be used for out-of-band
authentication" at AAL2+ because it cannot prove device possession. Email OTP is acceptable for low-assurance sign-in / passwordless convenience but should not be the sole factor for sensitive operations.
Sources
KV Cache
A general-purpose key-value cache with TTL (time-to-live) support, used as the storage backbone for rate limiting and other features that need temporary, expiring data (OTP attempts, email verification tokens, lockout counters, etc.).
Why a KV Cache Abstraction
Auth features repeatedly need the same primitive: "store a value by key, expire it after N seconds." Without a shared abstraction, every feature reinvents this — a Map with setTimeout for rate limiting, another Map for OTP attempts, a database table for lockouts. A single KV cache interface keeps things DRY and lets the user swap storage backends (in-memory → Redis → database) in one place.
Interface
The KV cache exposes three operations. Implementations must be async since database/Redis backends are inherently async.
KVCache {
get(key: string) → Promise<string | null>
set(key: string, value: string, ttlSeconds: number) → Promise<void>
delete(key: string) → Promise<void>
}- `get(key)` — Returns the stored value, or
nullif the key doesn't exist or has expired. - `set(key, value, ttlSeconds)` — Stores the value with a TTL. If the key already exists, overwrites it and resets the TTL. A
ttlSecondsof0means no expiration (use sparingly). - `delete(key)` — Removes the key immediately. No-op if the key doesn't exist.
Values are always strings. Callers serialize/deserialize as needed (e.g., JSON.stringify for structured data). This keeps the interface minimal and avoids type complexity across languages.
Storage Backends
1. In-Memory (Default)
Use a language-native map/dictionary with TTL tracking. This is the zero-dependency default — no external services, no database tables.
Implementation pattern:
- Store entries as
{ value: string, expiresAt: number }(epoch milliseconds) - On
get, checkexpiresAtagainst current time — returnnullif expired - Lazy cleanup: don't bother with background timers or sweeps. Expired entries get cleaned up on next
getorsetfor the same key. For long-running servers, optionally sweep every N minutes to prevent unbounded memory growth.
Tradeoffs:
- Resets on server restart (acceptable for rate limiting — attackers just get a fresh window)
- Not shared across multiple server instances (fine for single-process deployments)
- Memory grows with number of unique keys (bounded by TTL — entries expire and get cleaned up)
2. Database
Use a dedicated table in the project's existing database. Good for multi-instance deployments where in-memory isn't shared.
Schema:
KVEntry
| Field | Type | Constraints |
|---|---|---|
| key | string | primary key |
| value | string | not null |
| expiresAt | datetime | not null (indexed) |
Implementation pattern:
get: SELECT where key matches AND expiresAt > now. Returnnullif no row.set: UPSERT (insert or update on conflict) with the new value and expiresAt.delete: DELETE where key matches.- Cleanup: Periodically delete rows where
expiresAt < now. This can be a cron job, a background task, or done lazily on write operations (e.g., delete expired rows in the same transaction as the upsert, but only every Nth write to avoid overhead).
Tradeoffs:
- Shared across all server instances
- Adds a database query per cache operation (acceptable for auth — low request volume relative to app traffic)
- Requires a migration to create the table
3. Custom Storage
Allow the user to provide their own implementation — typically Redis, Memcached, or a managed KV service (Cloudflare KV, Vercel KV, Upstash Redis, etc.).
Pattern: Accept a configuration object that implements the get/set/delete interface. The user wires it up to their preferred backend.
// Pseudocode — adapt to language idioms
createKVCache({
get: async (key) => await redis.get(key),
set: async (key, value, ttl) => await redis.set(key, value, { ex: ttl }),
delete: async (key) => await redis.del(key),
})Key Namespacing
To avoid collisions between features sharing the same KV store, prefix keys by feature:
| Feature | Key pattern | Example |
|---|---|---|
| Rate limiting | rl:{endpoint}:{identifier} | rl:sign-in:192.168.1.1:user@ex.com |
| OTP attempts | otp-attempt:{target} | otp-attempt:user@example.com |
| Email verify | email-verify:{token} | email-verify:abc123 |
| Lockout | lockout:{identifier} | lockout:192.168.1.1 |
Features are responsible for constructing their own keys. The KV cache itself is agnostic to the key format.
Implementation Rules
- Always async. Even the in-memory backend should use async signatures for interface consistency — it lets users swap backends without changing callsites.
- TTL is mandatory on `set`. There is no "store forever" default. Callers must specify a TTL. This prevents accidental memory/storage leaks.
- Values are strings. Serialize complex data with
JSON.stringify/ equivalent. Don't add generics or type parameters to the interface — keep it dead simple. - Thread/concurrency safety. The in-memory backend must handle concurrent access correctly (not a concern in single-threaded JS, but important in Go/Rust/Python with threads). Use a mutex/lock or concurrent data structure.
- No distributed locking. The KV cache is not a distributed lock. Don't try to build one on top of it. For rate limiting, approximate counts are fine — a few extra requests slipping through during a race is acceptable.
- Create the KV cache as a standalone module/file. Don't inline it into the rate limiter or any specific feature. It should be importable by any feature that needs it.
- Default to in-memory. If the user doesn't configure a backend, use in-memory. Don't require setup for the simplest case.
Configuration
The KV cache is configured once and passed (or made available) to features that need it:
// Pseudocode
const kvCache = createKVCache({
storage: "memory" | "database" | { get, set, delete }
})
// Then used by features:
const rateLimiter = createRateLimiter({ kvCache, ... })For the database backend, reuse the project's existing database connection — don't create a separate connection pool.
Best Practices
- Keep TTLs short for security data. Rate limit windows: 1–60 minutes. OTP codes: 5–10 minutes. Don't cache auth data for hours.
- Don't cache sensitive secrets. Session tokens, passwords, and encryption keys should not go through the KV cache. It's for counters, temporary tokens, and flags.
- Monitor memory in production. For in-memory backends under high traffic, keep an eye on memory usage. If keys accumulate faster than they expire, add a periodic sweep or switch to Redis/database.
- Graceful degradation. If the KV backend is unavailable (Redis down, database unreachable), decide per-feature: rate limiting should fail-open (allow the request) to avoid blocking legitimate users. OTP verification should fail-closed (reject) to maintain security.
Reference Implementations
These open-source projects implement KV cache/storage abstractions with TTL support. Study their interface designs when implementing — our get/set/delete interface is intentionally minimal, but these show how production systems handle the same problem at scale.
Multi-Backend KV Abstractions (most relevant to our design)
| Project | Lang | Stars | Interface Pattern | TTL Handling | Storage Backends |
|---|---|---|---|---|---|
| unstorage | TS | ~2.6k | getItem/setItem/removeItem with driver mounting | Via StorageMeta.ttl — driver-dependent (Redis handles natively, others via metadata) | 34+ drivers: Memory, Redis, Upstash, Cloudflare KV/R2, Vercel Blob, S3, MongoDB, PlanetScale, Deno KV, etc. |
| Keyv | TS | ~3.1k | get/set(key, val, ttl?)/delete/has with KeyvStorageAdapter interface | Per-call TTL in ms; values wrapped in { value, expires } envelopes; checked on get() | 9 official: Redis, PostgreSQL, MySQL, MongoDB, SQLite, DynamoDB, Etcd, Memcache, Valkey |
| cache-manager | TS | ~2.0k | get/set/del/wrap (cache-aside) | Per-call ms + dynamic TTL via (value) => number function | Via Keyv adapters (inherits all backends) |
Key file pointers:
- unstorage:
src/types.ts(Driver interface withhasItem/getItem/setItem/removeItem),src/drivers/(34+ driver implementations) - Keyv:
core/keyv/src/types/adapters.ts(KeyvStorageAdapterinterface),core/keyv/src/keyv.ts(main class) - cache-manager:
packages/cache-manager/src/index.ts(Cacheinterface withwrappattern)
High-Performance In-Memory Caches
These are single-backend (in-memory only) but show how to implement efficient TTL expiration, which is relevant for the in-memory backend of our KV cache.
| Project | Lang | Stars | TTL Mechanism | Notes |
|---|---|---|---|---|
| Ristretto | Go | ~6.8k | SetWithTTL(key, val, cost, duration) — per-item expiration timestamps, cleanup ticker | TinyLFU admission + Sampled LFU eviction; sharded concurrent hashmap |
| FreeCache | Go | ~5.4k | Set(key, val, expireSeconds) — checked on Get() | Zero-GC design using pre-allocated ring buffers per shard (256 shards) |
| cachetools | Python | ~2.7k | TTLCache(maxsize, ttl) — entries timestamped at insertion, lazy expiration via linked list | Implements Python's MutableMapping; expire(time) walks list to remove stale entries |
| diskcache | Python | ~2.9k | set(key, val, expire=secs) — SQLite-backed persistent cache | Faster than Redis for single-machine; supports LRU/LFU eviction |
| moka | Rust | ~2.5k | Builder: time_to_live(dur), time_to_idle(dur), per-entry Expiry trait | Inspired by Java's Caffeine; lazy expiration since v0.12 (no background threads) |
| cached | Rust | ~2.0k | TimedCache::with_lifespan(dur) stores (Instant, V) tuples; IOCached trait for Redis/disk | Cached<K,V> trait (in-memory), IOCached<K,V> trait (external backends with cache_get/cache_set/cache_remove) |
Key file pointers:
- Ristretto:
cache.go(public API),ttl.go(expiration internals) - cachetools:
src/cachetools/__init__.py(TTLCachewith linked-list expiry) - moka:
src/sync/cache.rs(sync cache),src/future/cache.rs(async cache) - cached:
src/lib.rs(Cached/IOCached/CachedAsynctraits),src/stores/timed.rs(TTL store)
Storage Interface Patterns Across Ecosystems
The minimum viable interface for a KV cache with TTL (what we implement):
get(key) → value | null // Read; return null if expired
set(key, value, ttl) → void // Write with expiration
delete(key) → void // Remove immediatelyComparison with production systems:
| Our Interface | unstorage | Keyv | Ristretto (Go) | cached (Rust) |
|---|---|---|---|---|
get(key) | getItem(key) | get(key) | Get(key) | cache_get(k) |
set(key, val, ttl) | setItem(key, val) + meta | set(key, val, ttl) | SetWithTTL(k, v, cost, dur) | cache_set(k, v) + lifespan |
delete(key) | removeItem(key) | delete(key) | Del(key) | cache_remove(k) |
Magic Link
Passwordless authentication via emailed one-time links.
Schema Additions
MagicLinkToken
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| string | not null | |
| token | string | unique, not null (crypto-random) |
| expiresAt | datetime | not null (default: 15 minutes) |
| createdAt | datetime | default now |
Endpoints
POST /api/auth/magic-link/send
- Body:
{ email, callbackUrl? } - Generate a crypto-random token (min 32 bytes, URL-safe base64)
- Store token with 15-minute expiry
- Send email with link:
{callbackUrl}?token={token}(or a default callback) - Return 200 (always — do not reveal whether email exists)
- Rate limit: max 3 requests per email per 15 minutes
POST /api/auth/magic-link/verify
- Body:
{ token } - Look up token, verify not expired
- If valid: create or find User, create Session, delete token, return token + user
- If invalid or expired: return 401 with generic error
- Delete token after successful verification (single use)
Implementation Rules
- Tokens must be crypto-random (min 32 bytes), URL-safe base64 encoded
- Each token is single-use — delete after verification
- Delete all previous tokens for the same email when generating a new one
- Do not reveal in error messages whether the email exists
- If the user does not exist, create a new User + Account (providerId: "magic-link") on successful verification
- The callback URL should be validated against an allowlist to prevent open redirect
Best Practices (Industry Consensus)
Derived from Slack, Supabase, Auth.js/NextAuth, and OWASP guidelines.
Token Generation & Storage
- Min 32 bytes crypto-random, URL-safe base64. Slack uses RS256-signed JWTs; Supabase and Auth.js both use random hex/base64 tokens. 32 bytes (256 bits) is the common floor across all three.
- Hash tokens at rest. Store a SHA-256 hash in the database, not the raw token. If the database leaks, raw tokens remain unusable. Auth.js hashes verification tokens by default.
Expiry
- 10-15 minutes recommended. Supabase defaults to 1 hour (configurable, max 24h); Slack uses shorter-lived tokens. OWASP advises keeping lifetime low to limit brute-force and interception windows. 15 minutes balances usability and security.
Single Use
- Always delete (or invalidate) after verification. Every major provider enforces this. Also delete all prior tokens for the same email when issuing a new one to prevent token accumulation.
Open Redirect Prevention
- Validate the callback URL against a strict allowlist. This is a critical OWASP item. Never redirect to an arbitrary user-supplied URL. Compare scheme + host + port against configured origins.
Rate Limiting
- Max 3 requests per email per 15 minutes. Supabase enforces one request per 60 seconds by default. Rate limiting prevents enumeration attacks and email bombing. Return 200 regardless of whether the email exists.
Email Content
- Clear subject line (e.g., "Your sign-in link for {app}").
- Visible, clickable link (not hidden behind a button only).
- "If you didn't request this, you can safely ignore this email" disclaimer.
- Transmit links only over TLS (HTTPS URLs).
Cross-Device Support
- Magic links should work even if opened in a different browser or device than the one that initiated the request. Achieve this with stateless verification: the token alone (not a session cookie) must be sufficient to complete sign-in. Bind the token to the email, not to a browser session.
Multi-Session
Allow multiple concurrent sessions per user with session listing and selective revocation.
Schema Additions
Add to Session table:
| Field | Type | Constraints |
|---|---|---|
| userAgent | string | nullable |
| ipAddress | string | nullable |
These fields help users identify their sessions (e.g., "Chrome on macOS").
Endpoints
GET /api/auth/sessions
- Requires valid session (Bearer token)
- Return all active (non-expired) sessions for the current user
- Each session includes:
{ id, createdAt, expiresAt, userAgent, ipAddress, current } current: truefor the session matching the request token- Do NOT return session tokens — only metadata
DELETE /api/auth/sessions/:sessionId
- Requires valid session (Bearer token)
- Delete the specified session if it belongs to the current user
- Return 404 if the session doesn't exist or doesn't belong to the user
- Cannot delete the current session (use sign-out instead) — return 400
- Return 200 on success
DELETE /api/auth/sessions
- Requires valid session (Bearer token)
- Delete all sessions for the current user EXCEPT the current one
- Return
{ revoked: {count} }with the number of sessions deleted
Implementation Rules
- Store userAgent and ipAddress on session creation (from request headers)
- Never expose session tokens in the list endpoint — only IDs and metadata
- The "current" flag is determined by comparing session IDs, not tokens
- When deleting a single session, verify ownership (userId matches)
- The "delete all" endpoint preserves the current session for safety
- Session listing should only return non-expired sessions
- Order sessions by createdAt descending (most recent first)
Best Practices (Industry Consensus)
- Store device fingerprint (userAgent + IP) for identification. GitHub and Google both display browser name, OS, and approximate location derived from IP so users can recognize their own sessions and spot unauthorized access.
- Never expose session tokens in the list endpoint — only metadata. Return id, createdAt, expiresAt, userAgent, ipAddress, and a
currentboolean. Leaking tokens in API responses is a common vulnerability (OWASP Session Management Cheat Sheet). - Session list should be paginated for users with many devices or long-lived sessions. Default page size of 20-50 is typical; return a total count.
- "Current" detection uses session ID comparison, not the raw token. Match the session ID associated with the request's bearer token, never compare tokens directly in application logic.
- Revoke-all should keep the current session as a safety net so the user does not lock themselves out. Both GitHub and Google follow this pattern. Return the count of revoked sessions.
- Consider session anomaly detection. Flag sessions where the IP or userAgent changes dramatically mid-session (possible token theft). Google prompts re-authentication in such cases.
- Set absolute and idle timeouts. OWASP recommends both: an absolute max lifetime (e.g., 30 days) and an idle timeout (e.g., 24 hours of inactivity) to limit exposure of stolen tokens.
Organization / Teams
Multi-tenant support with roles, invitations, and role-based access control (RBAC).
Schema Additions
Organization
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| name | string | not null |
| slug | string | unique, not null |
| createdAt | datetime | default now |
| updatedAt | datetime | auto-update |
OrganizationMember
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| organizationId | string | foreign key -> Organization, not null |
| userId | string | foreign key -> User, not null |
| role | string | not null (default: "member") |
| createdAt | datetime | default now |
Unique constraint on (organizationId, userId).
OrganizationInvitation
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| organizationId | string | foreign key -> Organization, not null |
| string | not null | |
| role | string | not null (default: "member") |
| token | string | unique, not null |
| expiresAt | datetime | not null (default: 7 days) |
| createdAt | datetime | default now |
Default Roles
| Role | Permissions |
|---|---|
| owner | All permissions, can delete org, transfer ownership |
| admin | Manage members, manage invitations, update org |
| member | Read org, read members |
Endpoints
POST /api/auth/org
- Requires valid session
- Body:
{ name, slug? } - Create organization, add creator as "owner"
- Auto-generate slug from name if not provided
- Return organization + membership
GET /api/auth/org/:slugOrId
- Requires valid session + membership in the org
- Return organization details + current user's role
POST /api/auth/org/:slugOrId/invite
- Requires valid session + admin/owner role
- Body:
{ email, role? } - Create invitation with crypto-random token, send email
- Return 200
POST /api/auth/org/invite/accept
- Requires valid session
- Body:
{ token } - Verify token not expired, create membership, delete invitation
- Return organization + membership
GET /api/auth/org/:slugOrId/members
- Requires valid session + membership
- Return list of members with roles
PATCH /api/auth/org/:slugOrId/members/:userId
- Requires valid session + admin/owner role
- Body:
{ role } - Cannot change own role, cannot demote the last owner
- Return updated member
DELETE /api/auth/org/:slugOrId/members/:userId
- Requires valid session + admin/owner role (or self for leaving)
- Cannot remove the last owner
- Return 200
Implementation Rules
- Slugs: lowercase, alphanumeric + hyphens, 3-48 chars
- Role hierarchy: owner > admin > member
- Users can only modify roles below their own level
- There must always be at least one owner
- Invitation tokens are crypto-random (32 bytes), single-use
- Invitations expire after 7 days by default
- A user can belong to multiple organizations
Best Practices (Industry Consensus)
- Three-role minimum: owner > admin > member. GitHub, Clerk.dev, and WorkOS all use at least this hierarchy. Owners have destructive powers (delete org, billing), admins manage people, members have read access. Custom roles can extend this but the base three are essential.
- Always maintain at least one owner to prevent org lockout. Block demotion or removal of the last owner at the API level. GitHub enforces this strictly — an org cannot exist without an owner.
- Slug format: lowercase alphanumeric + hyphens, 3-48 chars. Must match
^[a-z0-9][a-z0-9-]{1,46}[a-z0-9]$. No leading/trailing hyphens, no consecutive hyphens. Used in URLs and API paths, so must be URL-safe. - Invitation tokens: 32 bytes crypto-random, 7-day expiry, single-use. Delete or mark as consumed after acceptance. Re-inviting the same email should invalidate the prior token.
- Audit logging for membership changes. Record who invited, accepted, changed roles, or removed members with timestamps. GitHub provides a detailed audit log for all org-level actions. This is critical for compliance (SOC 2, ISO 27001).
- Least-privilege by default. New members should get the lowest role ("member") unless explicitly elevated. Invitation role should be capped at the inviter's own role level.
Passkey (WebAuthn/FIDO2)
Passwordless authentication using platform authenticators (Touch ID, Windows Hello, Face ID) and cross-platform security keys (YubiKey, etc.). Based on W3C WebAuthn Level 3 and FIDO2/CTAP 2.2.
Schema Additions
Passkey
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| userId | string | foreign key -> User, not null |
| credentialId | string | unique, indexed, not null (base64url) |
| publicKey | bytes | not null (COSE public key, store as raw bytes/BYTEA/BLOB) |
| counter | bigint | not null, default 0 |
| deviceType | string | not null ("singleDevice" or "multiDevice") |
| backedUp | boolean | not null, default false |
| transports | string | nullable (JSON array: "internal", "usb", "ble", "nfc", "hybrid") |
| aaguid | string | nullable (authenticator model identifier) |
| name | string | nullable (user-given label, e.g. "MacBook Touch ID") |
| lastUsedAt | datetime | nullable |
| createdAt | datetime | default now |
PasskeyChallenge (ephemeral — can use cache/Redis instead of a table)
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| userId | string | nullable (null for auth flows) |
| challenge | string | not null (base64url) |
| type | string | not null ("registration" or "authentication") |
| expiresAt | datetime | not null (5 minutes from now) |
Endpoints
POST /api/auth/passkey/register/options
Generate WebAuthn registration options. Requires an existing session (user must be signed in).
- Auth: Bearer token (valid session required)
- Response 200:
PublicKeyCredentialCreationOptionsJSON
Server must: 1. Generate a crypto-random challenge (min 32 bytes), base64url-encode it 2. Build PublicKeyCredentialCreationOptions:
{
rp: { id: <configurable, default: domain>, name: <app name> },
user: {
id: <base64url of user's unique webauthn ID — NOT the user table PK>,
name: <user email or username>,
displayName: <user display name>
},
challenge: <base64url challenge>,
pubKeyCredParams: [
{ type: "public-key", alg: -7 }, // ES256 (preferred)
{ type: "public-key", alg: -257 } // RS256 (broad compat)
],
timeout: 300000, // 5 minutes
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
attestation: "none",
excludeCredentials: [
// All user's existing passkeys, to prevent re-registration
{ id: <credentialId>, type: "public-key", transports: [...] }
]
}3. Store challenge in PasskeyChallenge (or cache) with 5-minute expiry and type: "registration" 4. Return the options as JSON
POST /api/auth/passkey/register/verify
Verify WebAuthn registration (attestation) response and store the new passkey.
- Auth: Bearer token (valid session required)
- Body:
{ credential: RegistrationResponseJSON } - Response 200:
{ id, name, deviceType, backedUp, transports, createdAt } - Response 400: invalid/expired challenge, verification failed
Server must: 1. Retrieve the stored challenge for this user (type: "registration") 2. Verify the attestation response:
clientDataJSON.type==="webauthn.create"clientDataJSON.challengematches stored challengeclientDataJSON.originmatches expected origin(s)- RP ID hash in authenticator data matches expected RP ID
- User presence (UP) flag is set
- Extract public key, credential ID, counter, device type, backed-up flag
3. Check credential ID is not already registered (prevent duplicate) 4. Store new Passkey record with all extracted fields 5. Delete the used challenge 6. Return passkey metadata (never return the public key to the client)
POST /api/auth/passkey/authenticate/options
Generate WebAuthn authentication options. No session required — this is passwordless.
- Auth: none
- Body:
{ email? }(optional — omit for discoverable credential flow) - Response 200:
PublicKeyCredentialRequestOptionsJSON
Server must: 1. Generate crypto-random challenge (min 32 bytes) 2. If email provided, look up user's passkeys for allowCredentials 3. If no email, leave allowCredentials empty (discoverable credential / conditional UI flow) 4. Build PublicKeyCredentialRequestOptions:
{
challenge: <base64url challenge>,
rpId: <RP ID>,
timeout: 300000,
userVerification: "preferred",
allowCredentials: [
// Empty for discoverable, or list user's passkeys:
{ id: <credentialId>, type: "public-key", transports: [...] }
]
}5. Store challenge with 5-minute expiry and type: "authentication" 6. Return the options as JSON
POST /api/auth/passkey/authenticate/verify
Verify WebAuthn authentication (assertion) response and sign the user in.
- Auth: none
- Body:
{ credential: AuthenticationResponseJSON } - Response 200:
{ user: { id, email, name }, token } - Response 401: invalid credential, expired challenge, counter mismatch
Server must: 1. Extract credential ID from the response 2. Look up the Passkey record by credential ID 3. Retrieve the stored challenge (type: "authentication") 4. Verify the assertion response:
clientDataJSON.type==="webauthn.get"clientDataJSON.challengematches stored challengeclientDataJSON.originmatches expected origin(s)- RP ID hash matches
- User presence (UP) flag is set
- Signature is valid against stored public key
- Counter > stored counter (detects cloned authenticators)
5. Update the passkey's counter and lastUsedAt 6. Delete the used challenge 7. Create a new Session and return token + user
DELETE /api/auth/passkey/:passkeyId
Remove a registered passkey. Requires valid session.
- Auth: Bearer token
- Response 200:
{ success: true } - Response 403: passkey does not belong to authenticated user
- Response 400: cannot delete last passkey if user has no password (would lock them out)
GET /api/auth/passkey
List all passkeys for the authenticated user. Requires valid session.
- Auth: Bearer token
- Response 200:
{ passkeys: [{ id, name, deviceType, backedUp, transports, lastUsedAt, createdAt }] }
Never return publicKey or credentialId to the client in listing responses.
Implementation Rules
Libraries (use these, do NOT implement WebAuthn crypto yourself)
| Language | Library | Notes |
|---|---|---|
| JS/TS | @simplewebauthn/server | Most popular, well-maintained |
| Python | py_webauthn | Spec-compliant, async support |
| Go | github.com/go-webauthn/webauthn | Standard Go library |
| Rust | webauthn-rs | Type-safe, well-tested |
| Java/Kotlin | com.yubico:webauthn-server-core | From Yubico, reference impl |
| Ruby | webauthn-ruby | ActiveRecord integration |
| C#/.NET | Fido2NetLib | FIDO2 certified |
Algorithms
- MUST support ES256 (alg:
-7) — ECDSA with SHA-256 on P-256 curve. This is the most universally supported. - SHOULD support RS256 (alg:
-257) — RSASSA-PKCS1-v1_5 with SHA-256. Needed for older Windows Hello and some security keys. - MAY support EdDSA (alg:
-8) — Ed25519. Exclude on Node.js <18 or Firefox ≤118. - List them in preference order:
[{ alg: -7 }, { alg: -257 }]
RP (Relying Party) Configuration
- RP ID = registrable domain (e.g.
example.com), not the full origin - Must be configurable (environment variable or config file)
- Origin =
https://<rpId>— server validates this fromclientDataJSON - For localhost development, browsers allow
http://localhostas a special case
Challenges
- Crypto-random, minimum 32 bytes, base64url-encoded
- 5-minute expiry (configurable)
- Single-use: delete after verification (prevents replay)
- Store server-side only (cache, DB, or session)
Credential Storage
publicKey: store as raw bytes (BYTEA/BLOB), NOT base64 string — avoids re-encoding on every verificationcredentialId: base64url string, must be indexed for fast lookup during authenticationcounter: use bigint — some authenticators use large counter valuestransports: store as JSON array string, return duringallowCredentialsto help the browser pick the right transport
Counter Verification
- On each authentication, assert
response.counter > stored.counter - If counter goes backwards or stays at 0 when stored > 0, the authenticator may be cloned — reject and alert
- Some authenticators (especially synced passkeys) always return counter = 0; handle this by only failing if stored counter was > 0
Security
- Registration requires an existing session — user must prove identity first (password, magic link, etc.)
- Authentication is passwordless — no prior session needed
- Attestation: use
"none"for consumer apps. Only use"direct"or"enterprise"when compliance requires device provenance. - Never expose `publicKey` or raw `credentialId` in API list responses — only return metadata
- RP ID validation prevents phishing — the browser enforces origin matches
Discoverable Credentials (Resident Keys)
- Set
residentKey: "preferred"(not"required") — some older authenticators don't support it - Discoverable credentials enable autofill / conditional UI: user doesn't need to type their email
- The
user.idin registration options should be a random opaque identifier (NOT email, NOT user table PK) — this is thewebauthnUserIDreturned during authentication inuserHandle - Store this
webauthnUserIDon the user record to map assertion responses back to users
Multi-Device / Backup Awareness
deviceType: "singleDevice"= credential lives on one device only (e.g. hardware security key)deviceType: "multiDevice"= credential can sync across devices (e.g. Apple/Google passkey)backedUp: true= credential has been synced to cloud- Store these flags to give users visibility into their credential security posture
Sources: W3C WebAuthn Level 3, FIDO Alliance CTAP 2.2, SimpleWebAuthn, passkeys.dev
Password Breach Check
Check passwords against the Have I Been Pwned (HIBP) breached password database.
Schema Additions
None — this is a validation step during sign-up and password change.
Integration Point
Add password breach checking as a validation step in:
- POST /api/auth/sign-up — before creating the user
- Password change — before updating the password (if implemented)
How It Works (k-Anonymity API)
1. Hash the password with SHA-1 2. Take the first 5 characters of the hex hash (the "prefix") 3. Call GET https://api.pwnedpasswords.com/range/{prefix} 4. The API returns a list of hash suffixes and their breach counts 5. Check if the remaining hash suffix appears in the response 6. If found: the password has been breached
Behavior
- If the password is breached: return 400 with
{ error: "password_compromised", message: "This password has appeared in a data breach. Please choose a different password." } - If the HIBP API is unreachable: allow the password (fail-open) but log a warning
- Do NOT send the full password hash to any external service — only the 5-char prefix
Implementation Rules
- Use SHA-1 for the hash (this is what HIBP requires — it's NOT used for password storage)
- Only send the first 5 characters of the hash to the API (k-anonymity)
- Comparison of hash suffixes should be case-insensitive
- Set a timeout on the HIBP API call (3 seconds)
- Fail-open: if the API is unreachable, allow the password but log a warning
- This check runs BEFORE password hashing (bcrypt/argon2) — it operates on the plaintext
- Make this feature toggleable via configuration
- Cache API responses briefly (5 minutes) to reduce external calls for the same prefix
Best Practices (Industry Consensus)
- NIST 800-63B requires it: verifiers SHALL compare prospective passwords against a list of known compromised values — breached password checking is not optional for NIST compliance
- k-anonymity preserves privacy: only the first 5 characters of the SHA-1 hex hash are sent to the API; the full password or full hash never leaves the server
- API is free and reliable: the Pwned Passwords range search API requires no authentication or API key; it is backed by Cloudflare's CDN for high availability and low latency
- Padded responses: HIBP supports
Add-Padding: trueheader, ensuring all responses are 800–1000 entries regardless of actual matches — this prevents response-size analysis by network observers - Fail-open if API unreachable: do not block user registration if the HIBP API is down; allow the password but log a warning for monitoring
- Cache prefix responses briefly (5 minutes TTL) to reduce redundant API calls for the same prefix
- Check runs on plaintext BEFORE hashing: the breach check operates on the raw password, before bcrypt/argon2 hashing — SHA-1 is used only for the HIBP lookup, never for storage
- Also block common passwords: NIST recommends rejecting passwords from commonly-used lists (e.g., "password", "123456") in addition to breach lists
- Do not disclose match count: tell the user the password appeared in a breach, but do not reveal how many times — this avoids leaking information
Sources: NIST SP 800-63B, HIBP Pwned Passwords, Troy Hunt on k-Anonymity, OWASP Authentication Cheat Sheet
Phone Number Authentication
SMS-based OTP authentication using phone numbers.
Schema Additions
Add to User table:
| Field | Type | Constraints |
|---|---|---|
| phoneNumber | string | nullable, unique |
| phoneVerified | boolean | default false |
PhoneVerificationCode
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| phoneNumber | string | not null |
| code | string | not null (6-digit numeric) |
| expiresAt | datetime | not null (default: 10 minutes) |
| createdAt | datetime | default now |
Endpoints
POST /api/auth/phone/send
- Body:
{ phoneNumber } - Validate phone number format (E.164: +country code + number)
- Generate a 6-digit numeric code (crypto-random)
- Store code with 10-minute expiry
- Send code via SMS (use the project's SMS service)
- Return 200 (always)
- Rate limit: max 3 requests per phone per 10 minutes
POST /api/auth/phone/verify
- Body:
{ phoneNumber, code } - Look up the most recent unexpired code for this phone number
- If valid: create or find User (by phone), create Session, delete code, return token + user
- If invalid or expired: return 401 with generic error
- Delete code after successful verification (single use)
Implementation Rules
- Phone numbers must be stored in E.164 format (+1234567890)
- Codes MUST be 6 digits, zero-padded
- Generate with crypto-random, not Math.random
- Each code is single-use — delete after verification
- Delete all previous codes for the same phone when generating a new one
- Constant-time comparison for code verification
- If the user does not exist, create a new User + Account (providerId: "phone") on successful verification
- Set phoneVerified to true after successful verification
Best Practices (Industry Consensus)
- NIST SP 800-63B classifies SMS as a "restricted" authenticator. It is not prohibited, but agencies/apps using it must offer an alternative, inform users of the risks (SIM swap, interception), and maintain a migration plan toward phishing-resistant methods. Still widely supported because of its ubiquity.
- E.164 format validation is critical. Reject any phone number that does not match
^\+[1-9]\d{1,14}$before processing. Normalize on intake to avoid duplicate accounts from formatting differences. - Rate limit: max 3 SMS per phone number per 10 minutes to prevent SMS pumping fraud, where attackers trigger mass sends to premium-rate numbers for revenue share. This also serves as a cost control since every SMS has a per-message cost.
- International considerations: Some countries block short codes or have carrier-level filtering. Support alphanumeric sender IDs where required, and consider geo-rate-limits (stricter limits for high-fraud regions).
- Cost awareness: Each SMS costs $0.01-$0.05+ depending on country. Rate limiting is both a security and financial control. Consider CAPTCHA or device attestation before sending.
- Code parameters: 6 digits, 10-minute expiry, single use. Delete all prior codes for the same number when issuing a new one. Use constant-time comparison to prevent timing attacks.
Rate Limiting
Throttle authentication endpoints to prevent brute-force attacks, credential stuffing, and flood-style abuse. Uses the KV cache abstraction (references/features/kv-cache.md) as its storage backend.
How It Works
Rate limiting tracks request counts per key (IP address, email, token, or combination) within a time window. When a client exceeds the limit, the server returns 429 Too Many Requests with a Retry-After header telling the client when to try again.
The rate limiter is implemented as middleware that runs before the endpoint handler. It uses the KV cache to store counters, so the storage backend (in-memory, database, Redis) is configurable without changing the rate limiting logic itself.
Schema Additions
None — rate limiting state is stored in the KV cache, not in dedicated tables. If the user chose "database" as their KV cache backend, the shared KVEntry table (defined in kv-cache.md) handles storage.
Algorithm: Fixed Window Counter
Use a fixed-window counter. It's the simplest approach and sufficient for auth endpoints (which handle low-to-moderate traffic compared to data APIs).
How it works: 1. Build the rate limit key from the request (see Key Composition below) 2. GET the current counter from the KV cache using key rl:{endpoint}:{identifier} 3. If no entry exists (or expired): SET counter to "1" with TTL = window size → allow request 4. If entry exists and count < max: SET counter to count + 1 with the same remaining TTL → allow request 5. If entry exists and count >= max: reject with 429
Remaining TTL matters. When incrementing, don't reset the TTL to the full window — that would create a sliding window. Read the expiry from the existing entry and preserve it. If your KV backend doesn't expose remaining TTL, store the window start timestamp in the value alongside the count (e.g., JSON.stringify({ count, windowStart })), and compute the remaining TTL as windowSize - (now - windowStart).
Default Limits
| Endpoint | Window | Max Requests | Key |
|---|---|---|---|
POST /api/auth/sign-in | 15 min | 5 | IP + email |
POST /api/auth/sign-up | 1 hour | 10 | IP |
POST /api/auth/sign-out | 1 min | 10 | session token |
POST /api/auth/*/send | 10 min | 3 | target (email or phone) |
POST /api/auth/*/verify | 15 min | 5 | target (email or phone) |
GET /api/auth/session | — | — | Not rate limited (read-only, token-protected) |
These are defaults. The skill should generate them as configurable constants (environment variables or a config object) so the user can tune without editing middleware code.
Key Composition
The rate limit key determines what is being throttled. Use the pattern rl:{endpoint}:{identifier}:
- Sign-in:
rl:sign-in:{ip}:{email}— keyed on both IP and email. This prevents credential stuffing (many passwords for one email) while still allowing different users from the same IP (e.g., corporate NAT, shared wifi). - Sign-up:
rl:sign-up:{ip}— keyed on IP only. Email isn't useful here because attackers use different emails. - Sign-out:
rl:sign-out:{token}— keyed on session token. Prevents abuse of the sign-out endpoint. - OTP/magic-link send:
rl:send:{target}— keyed on the target (email or phone). Prevents spamming a single recipient. - OTP/magic-link verify:
rl:verify:{target}— keyed on target. Prevents brute-forcing short codes.
IP Address Extraction
Extract the client IP from request headers in this order: 1. X-Forwarded-For (first value — the client IP before proxies) 2. X-Real-IP 3. Direct connection IP (socket remote address)
Normalize IPv6: Convert IPv4-mapped IPv6 addresses (like ::ffff:192.168.1.1) to their IPv4 form. This prevents bypass attacks where the same client appears as two different IPs.
Middleware Implementation
The rate limiter should be structured as middleware that can be applied to individual routes or groups of routes.
Pseudocode:
function rateLimitMiddleware(kvCache, config) {
return async function(request, next) {
// 1. Build the key
const key = buildRateLimitKey(request, config.endpoint)
// 2. Check current count
const entry = await kvCache.get(key)
if (entry === null) {
// First request in this window
await kvCache.set(key, JSON.stringify({ count: 1, windowStart: now() }), config.windowSeconds)
return next(request)
}
const { count, windowStart } = JSON.parse(entry)
if (count >= config.max) {
// Over limit
const retryAfter = config.windowSeconds - secondsSince(windowStart)
return respond(429, {
error: "rate_limited",
message: "Too many requests. Please try again later.",
retryAfter: Math.max(retryAfter, 1)
}, { "Retry-After": String(Math.max(retryAfter, 1)) })
}
// Increment — preserve the original window expiry
const remainingTtl = config.windowSeconds - secondsSince(windowStart)
await kvCache.set(key, JSON.stringify({ count: count + 1, windowStart }), Math.max(remainingTtl, 1))
return next(request)
}
}Response on Rate Limit
- Status:
429 Too Many Requests - Headers:
Retry-After: {seconds until window resets} - Body:
{
"error": "rate_limited",
"message": "Too many requests. Please try again later.",
"retryAfter": 42
}The retryAfter value in the body mirrors the Retry-After header for convenience — clients can use either.
Custom Rules
Allow per-endpoint overrides of the default limits. The generated code should support a configuration like:
rateLimitRules: {
"sign-in": { window: 900, max: 5 }, // 15 min, 5 requests
"sign-up": { window: 3600, max: 10 }, // 1 hour, 10 requests
"sign-out": { window: 60, max: 10 },
// Add custom rules for any endpoint:
"two-factor/challenge": { window: 600, max: 3 },
}Setting an endpoint to false or null disables rate limiting for that endpoint. This is useful for internal/trusted endpoints.
Implementation Rules
- Use the KV cache — read
references/features/kv-cache.mdfor the storage interface. Don't create a separate storage mechanism for rate limiting. If the user already selected KV cache features, reuse the same instance. - Create the rate limiter as reusable middleware, not inline in each route handler. Each route applies the middleware with its specific config (window, max, key builder).
- Always include `Retry-After` header on 429 responses. This is required by HTTP spec (RFC 6585) and expected by well-behaved clients.
- Make limits configurable. Use environment variables or a config object — don't hardcode window sizes and max counts into the middleware.
- Don't rate limit `GET /session` — it's read-only and already token-protected. Rate limiting it would add latency to every authenticated page load.
- Fail open. If the KV cache is unavailable (Redis down, database timeout), allow the request through. Blocking legitimate users is worse than temporarily losing rate limiting. Log the error for monitoring.
- Don't use distributed locking. A few extra requests sneaking through during a race condition between cache read and write is fine. Auth rate limits are approximate by nature.
- IP behind proxies. Document that the user must configure their reverse proxy (nginx, Cloudflare, etc.) to set
X-Forwarded-Forcorrectly. IfX-Forwarded-Foris absent, fall back to the connection IP — but warn that rate limiting may not work correctly behind a proxy without this header.
Best Practices (Industry Consensus)
- Fixed window is sufficient for auth endpoints. The simplicity outweighs the edge-case burst at window boundaries. Sliding window is better for high-throughput data APIs, but auth endpoints don't see that traffic pattern.
- Key on IP + email for sign-in. This is the standard approach to prevent credential stuffing while allowing multiple users from the same network (corporate NAT, university wifi).
- Treat password reset as a login endpoint in terms of rate limiting. OWASP guidance: password reset is functionally equivalent to authentication and should have the same protections.
- Limit OTP validation attempts. Cap verification tries (e.g., 5 per 15 min per target) to prevent brute-forcing short codes. A 6-digit OTP has only ~20 bits of entropy — without rate limiting, it can be brute-forced in seconds.
- Progressive delays for repeated failures. Consider exponential backoff (doubling the wait after each failure) for the same key. This slows automated attacks without permanently locking legitimate users. Implementation: track failure count in the KV cache alongside the rate limit counter.
- Return consistent error shapes. The 429 response body should match the project's error format for other endpoints (same
errorfield name, same structure). Don't introduce a different error format just for rate limiting. - Log rate limit hits. Log when a client is rate-limited (IP, endpoint, count) for security monitoring. Don't log the full request body — it may contain passwords.
- Normalize IPv6. Convert
::ffff:x.x.x.xto plain IPv4. Without this, the same client can trivially bypass IP-based limits by switching address formats.
Reference Implementations
These open-source projects demonstrate rate limiting patterns across languages and ecosystems. Study their storage interfaces and algorithm choices when implementing.
Node.js / TypeScript
| Project | Stars | Algorithm | Storage Backends | Key Files |
|---|---|---|---|---|
| express-rate-limit | ~3.2k | Fixed window (dual-map rotation) | In-memory (built-in); Redis, Memcached, MongoDB, PostgreSQL via community stores | source/memory-store.ts (store impl), source/types.ts (Store interface: increment(key)) |
| rate-limiter-flexible | ~3.5k | Enhanced fixed window (atomic increments) | Memory, Redis, PostgreSQL, MySQL, MongoDB, Memcached, DynamoDB, SQLite, Prisma, Drizzle | lib/RateLimiterStoreAbstract.js (abstract store: _upsert, _get, _delete), lib/RateLimiterRedis.js, lib/RateLimiterPostgres.js |
| @upstash/ratelimit | ~2.0k | Fixed window, sliding window, token bucket (all via Lua scripts) | Upstash Redis (HTTP-based, serverless) | src/lua-scripts/single.ts (Lua scripts for all 3 algorithms), src/single.ts |
| better-auth | ~8k | Fixed window | In-memory, database, secondary storage, custom get/set | docs/content/docs/concepts/rate-limit.mdx (design), custom storage via rateLimit.customStorage |
Go
| Project | Stars | Algorithm | Storage Backends | Key Files |
|---|---|---|---|---|
| golang.org/x/time/rate | stdlib | Token bucket | In-memory only | rate/rate.go (Limiter struct: Allow(), Reserve(), Wait()) |
| ulule/limiter | ~2.1k | Fixed window | Redis, in-memory | store.go (Store interface: Get, Peek, Reset, Increment), drivers/store/redis/, drivers/middleware/stdlib/ |
| throttled | ~1.6k | GCRA (Generic Cell Rate Algorithm) | In-memory, Redis (redigo, go-redis v8/v9) | rate.go, store.go (storage interface), store/memstore/, store/goredisstore.v9/ |
| sethvargo/go-limiter | ~715 | Token bucket | In-memory | store.go (Store interface: Take, Get, Set, Burst, Close) |
| Supabase Auth | ~2.4k | Token bucket (via tollbooth) | In-memory only | internal/api/middleware.go (rate limit middleware), internal/conf/configuration.go (per-endpoint limits: email 30/hr, SMS 30/hr, verify 30/hr) |
Python
| Project | Stars | Algorithm | Storage Backends | Key Files |
|---|---|---|---|---|
| limits | ~614 | Fixed window, moving window, sliding window counter | Memory, Redis (standalone/Cluster/Sentinel), Memcached, MongoDB | limits/strategies.py (all 3 algorithms), limits/storage/base.py (storage interface), limits/storage/redis.py |
| slowapi | ~1.9k | Delegates to limits library | Inherits from limits | slowapi/extension.py, slowapi/middleware.py (Starlette middleware) |
Rust
| Project | Stars | Algorithm | Storage Backends | Key Files |
|---|---|---|---|---|
| governor | ~898 | GCRA | In-memory (DashMap) | governor/src/gcra.rs (GCRA impl), governor/src/state/keyed.rs (per-key state) |
| tower rate_limit | ~4.2k | Fixed window | In-memory only | tower/src/limit/rate/service.rs (RateLimit service with until/rem tracking) |
Auth Platforms
| Platform | Approach | Notes |
|---|---|---|
| Supabase Auth | In-memory token bucket (tollbooth) | No Redis; each instance handles its own traffic. Per-endpoint limits: email 30/hr, SMS 30/hr, OTP 30/hr, token refresh 150/hr |
| Unkey (~5.2k stars) | Sliding window with distributed replication | internal/services/ratelimit/window.go (duration-aligned windows), janitor.go (cleanup), replay.go (cross-node consistency) |
| better-auth | Fixed window, pluggable storage | 60s window / 100 req default; supports in-memory, database, custom get/set |
Sources: OWASP API Security — Broken Authentication, OWASP API Security — Unrestricted Resource Consumption, Cloudflare Rate Limiting Best Practices, better-auth Rate Limiting
Two-Factor Authentication (2FA)
TOTP-based second factor with backup codes.
Schema Additions
TwoFactor
| Field | Type | Constraints |
|---|---|---|
| id | string | primary key |
| userId | string | foreign key -> User, unique, not null |
| secret | string | not null (encrypted TOTP secret) |
| backupCodes | string | not null (JSON array of hashed codes) |
| enabled | boolean | default false |
| createdAt | datetime | default now |
| updatedAt | datetime | auto-update |
Endpoints
POST /api/auth/two-factor/enable
- Requires valid session (Bearer token)
- Generate TOTP secret (base32 encoded, 20 bytes)
- Generate 10 backup codes (8 chars each, crypto-random alphanumeric)
- Store secret and hashed backup codes (not yet enabled)
- Return
{ secret, uri, backupCodes, qrCode? } uriis the otpauth:// URI for QR code scanningbackupCodesare shown ONCE — not retrievable later
POST /api/auth/two-factor/verify
- Requires valid session (Bearer token)
- Body:
{ code }(6-digit TOTP code) - Verify TOTP code against stored secret (allow ±1 time step window)
- If valid: set
enabled = true, return 200 - If invalid: return 401
POST /api/auth/two-factor/disable
- Requires valid session (Bearer token)
- Body:
{ code }(current TOTP code to confirm) - Verify code, then delete TwoFactor record
- Return 200
POST /api/auth/two-factor/challenge
- Called during sign-in when 2FA is enabled
- Body:
{ code, trustDevice? }plus session token from initial sign-in - Verify TOTP code OR backup code
- If backup code used: remove it from the list (single use)
- If valid: upgrade session to fully authenticated, return token + user
- If invalid: return 401
Sign-In Flow Changes
When a user with 2FA enabled signs in: 1. Verify email + password as normal 2. Create a session but mark it as twoFactorVerified: false 3. Return { twoFactorRequired: true, token } (token for the challenge step only) 4. Client must call /two-factor/challenge with a TOTP code to complete sign-in 5. Only after challenge is the session fully authenticated
Add to Session table:
| Field | Type | Constraints |
|---|---|---|
| twoFactorVerified | boolean | default true |
Set twoFactorVerified = false on sign-in for 2FA users; set to true after challenge. The session endpoint must reject sessions where twoFactorVerified = false.
Implementation Rules
- Use a TOTP library (e.g.,
otpauth/otplibfor JS,pyotpfor Python,pquerna/otpfor Go) - TOTP parameters: SHA-1, 6 digits, 30-second period (RFC 6238 defaults)
- Allow ±1 time step window to account for clock drift
- Encrypt the TOTP secret at rest (use the app's encryption key)
- Backup codes: generate 10, hash with bcrypt before storing, each is single-use
- Never expose the TOTP secret after initial setup
- The enable flow is: generate → user scans QR → user enters code to verify → enabled
Best Practices (Industry Consensus)
Derived from RFC 6238, RFC 4226, NIST SP 800-63B, and observed implementations at GitHub and Bitwarden.
TOTP Defaults (RFC 6238)
- Algorithm: HMAC-SHA-1 — the universal default; SHA-256/SHA-512 are allowed
by the spec but not supported by all authenticator apps.
- Digits: 6.
- Period: 30 seconds.
- Time window: Accept ±1 step (i.e., current, previous, and next period) to
tolerate clock drift and entry delay. GitHub, Bitwarden, and most services use this same tolerance.
Shared Secret
- Minimum length: 128 bits (16 bytes) per RFC 4226; recommended 160 bits
(20 bytes). Use 20 bytes.
- Encoding: Base32 (standard for
otpauth://URIs and QR codes). - Storage: Encrypt at rest with an application-level key. NIST SP 800-63B
requires symmetric keys to be "strongly protected against compromise."
- Never expose the secret after the initial setup flow.
Backup / Recovery Codes
- Generate 8–16 single-use codes at enable time.
- GitHub provides 16 codes in
xxxxx-xxxxxalphanumeric format. - A common alternative is 8–10 codes of 8 alphanumeric characters each.
- Hash each code (bcrypt or similar) before storing; show plaintext only once.
- Consuming a code removes it permanently.
- Provide a "regenerate codes" action that invalidates all previous codes.
Recovery When 2FA Device Is Lost
- Primary path: backup codes (all major services).
- Optional additional paths: verified email with identity confirmation,
pre-registered SSH keys or passkeys (GitHub), or account recovery request with manual review. Choose based on your threat model.
Anti-Replay
- NIST SP 800-63B: "verifiers SHALL accept a given time-based OTP only once
during the validity period."
- Track the last successfully used time step per user. Reject any code whose
time step is ≤ the stored value.
Partial Session (2FA Challenge Flow)
- On password verification for a 2FA-enabled account, issue a short-lived token
that is scoped exclusively to the /two-factor/challenge endpoint.
- All other endpoints must reject this token.
- Expire the partial session quickly (e.g., 5 minutes).
Rate Limiting
- NIST SP 800-63B requires rate limiting on OTP verification, especially when
the code is fewer than 64 bits (6-digit TOTP = ~20 bits).
- Recommended: lock the challenge after 5–10 consecutive failures with an
exponential backoff or temporary account lock.
Username Authentication
Sign in with username as an alternative to email.
Schema Additions
Add to User table:
| Field | Type | Constraints |
|---|---|---|
| username | string | unique, nullable |
Username is nullable because existing email-only users won't have one.
Endpoint Changes
POST /api/auth/sign-up — add optional username field:
- Body:
{ email, password, name?, username? } - Validate username: 3-32 chars, alphanumeric + underscores only, case-insensitive
- Store username in lowercase
- Return 409 if username already taken (same as email conflict)
POST /api/auth/sign-in — accept username OR email:
- Body:
{ email?, username?, password } - At least one of
emailorusernamemust be provided - Look up user by the provided identifier
- Return 401 with generic error if not found (do not reveal which field failed)
PATCH /api/auth/user/username
- Requires valid session (Bearer token)
- Body:
{ username } - Validate username format (same rules as sign-up)
- Update username, return 200 with updated user
- Return 409 if username already taken
Implementation Rules
- Usernames are case-insensitive — always store and compare in lowercase
- Allowed characters: a-z, 0-9, underscore (_)
- Length: 3-32 characters
- Reserved usernames: block "admin", "root", "system", "null", "undefined", "api", "auth" etc.
- Sign-in should work with either email or username — determine which by checking for "@"
- Generic error messages on sign-in — do not reveal whether the username exists
- Username changes should be rate-limited (e.g., once per 24 hours)
Best Practices (Industry Consensus)
- Case handling: GitHub, Discord, and most platforms store usernames case-insensitively. Store a lowercase canonical form for lookups, but optionally preserve display casing separately
- Reserved words: Block platform-sensitive slugs (
admin,root,system,api,auth,www,mail,support,help,null,undefined,login,signup). GitHub maintains a list of ~100 reserved names - Uniqueness enforcement: Use a unique index on the lowercase form at the database level, not just application validation
- Homograph protection: Consider blocking confusable characters (e.g., Cyrillic "а" vs Latin "a") or restrict to ASCII. GitHub and most platforms restrict to
[a-zA-Z0-9-] - Change policy: GitHub allows username changes but the old username becomes available to others after a grace period. Rate-limit changes (1 per 24 hours) and consider a 14-day reclaim window
- Enumeration prevention: The sign-in endpoint must return the same error for wrong username vs wrong password (OWASP). A separate "check username availability" endpoint for sign-up is acceptable but should be rate-limited
Sources: GitHub Username Policy, OWASP Authentication Cheat Sheet
// Reference: Go + Chi router + PostgreSQL (database/sql + pgx)
// This shows the complete auth implementation pattern for Go.
package auth
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
// --- models ---
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name *string `json:"name,omitempty"`
EmailVerified bool `json:"email_verified"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
type Account struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ProviderID string `json:"provider_id"`
PasswordHash *string `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// --- request/response types ---
type SignUpRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name *string `json:"name,omitempty"`
}
type SignInRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type AuthResponse struct {
User User `json:"user"`
Token string `json:"token"`
}
type SessionResponse struct {
User User `json:"user"`
ExpiresAt time.Time `json:"expires_at"`
}
// --- handler ---
type AuthHandler struct {
db *sql.DB
}
func NewAuthHandler(db *sql.DB) *AuthHandler {
return &AuthHandler{db: db}
}
func (h *AuthHandler) Routes() chi.Router {
r := chi.NewRouter()
r.Post("/sign-up", h.SignUp)
r.Post("/sign-in", h.SignIn)
r.Get("/session", h.GetSession)
r.Post("/sign-out", h.SignOut)
return r
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
const sessionDuration = 7 * 24 * time.Hour
func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
var req SignUpRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
if req.Email == "" || len(req.Password) < 8 {
http.Error(w, `{"error":"invalid email or password (min 8 chars)"}`, http.StatusBadRequest)
return
}
// Always hash password to prevent timing-based email enumeration
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
userID := uuid.New().String()
token := generateToken()
now := time.Now()
tx, err := h.db.BeginTx(r.Context(), nil)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
defer tx.Rollback()
_, err = tx.ExecContext(r.Context(),
"INSERT INTO users (id, email, name, email_verified, created_at, updated_at) VALUES ($1, $2, $3, false, $4, $4)",
userID, req.Email, req.Name, now,
)
if err != nil {
// Unique constraint violation (duplicate email) — return fake success
// to prevent email enumeration. The dummy token won't resolve to a session.
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "duplicate") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(AuthResponse{
User: User{ID: uuid.New().String(), Email: req.Email, Name: req.Name, CreatedAt: now, UpdatedAt: now},
Token: generateToken(),
})
return
}
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
hashStr := string(hash)
_, err = tx.ExecContext(r.Context(),
"INSERT INTO accounts (id, user_id, provider_id, password_hash, created_at, updated_at) VALUES ($1, $2, 'credential', $3, $4, $4)",
uuid.New().String(), userID, hashStr, now,
)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
_, err = tx.ExecContext(r.Context(),
"INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)",
uuid.New().String(), userID, token, now.Add(sessionDuration), now,
)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(AuthResponse{
User: User{ID: userID, Email: req.Email, Name: req.Name, CreatedAt: now, UpdatedAt: now},
Token: token,
})
}
func (h *AuthHandler) SignIn(w http.ResponseWriter, r *http.Request) {
var req SignInRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid request body"}`, http.StatusBadRequest)
return
}
var user User
var passwordHash sql.NullString
err := h.db.QueryRowContext(r.Context(),
`SELECT u.id, u.email, u.name, u.email_verified, u.created_at, u.updated_at, a.password_hash
FROM users u JOIN accounts a ON a.user_id = u.id
WHERE u.email = $1 AND a.provider_id = 'credential'`, req.Email,
).Scan(&user.ID, &user.Email, &user.Name, &user.EmailVerified, &user.CreatedAt, &user.UpdatedAt, &passwordHash)
if err != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
if !passwordHash.Valid || bcrypt.CompareHashAndPassword([]byte(passwordHash.String), []byte(req.Password)) != nil {
http.Error(w, `{"error":"invalid credentials"}`, http.StatusUnauthorized)
return
}
token := generateToken()
now := time.Now()
_, err = h.db.ExecContext(r.Context(),
"INSERT INTO sessions (id, user_id, token, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)",
uuid.New().String(), user.ID, token, now.Add(sessionDuration), now,
)
if err != nil {
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(AuthResponse{User: user, Token: token})
}
func (h *AuthHandler) GetSession(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token == "" {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var user User
var expiresAt time.Time
err := h.db.QueryRowContext(r.Context(),
`SELECT u.id, u.email, u.name, u.email_verified, u.created_at, u.updated_at, s.expires_at
FROM sessions s JOIN users u ON u.id = s.user_id
WHERE s.token = $1`, token,
).Scan(&user.ID, &user.Email, &user.Name, &user.EmailVerified, &user.CreatedAt, &user.UpdatedAt, &expiresAt)
if err != nil || expiresAt.Before(time.Now()) {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(SessionResponse{User: user, ExpiresAt: expiresAt})
}
func (h *AuthHandler) SignOut(w http.ResponseWriter, r *http.Request) {
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
if token != "" {
h.db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token = $1", token)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"success":true}`))
}
// Reference: Next.js App Router + Drizzle ORM + PostgreSQL
// This shows the complete auth implementation pattern for Next.js.
// --- schema.ts ---
import { pgTable, text, boolean, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: text("id").primaryKey(),
email: text("email").unique().notNull(),
name: text("name"),
emailVerified: boolean("email_verified").default(false).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const sessions = pgTable("sessions", {
id: text("id").primaryKey(),
userId: text("user_id")
.references(() => users.id)
.notNull(),
token: text("token").unique().notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const accounts = pgTable("accounts", {
id: text("id").primaryKey(),
userId: text("user_id")
.references(() => users.id)
.notNull(),
providerId: text("provider_id").notNull(),
passwordHash: text("password_hash"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// --- app/api/auth/sign-up/route.ts ---
import { db } from "@/lib/db";
import { users, accounts, sessions } from "@/lib/schema";
import { eq } from "drizzle-orm";
import { hash } from "bcryptjs";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const { email, password, name } = await request.json();
if (!email || !password || password.length < 8) {
return NextResponse.json(
{ error: "Invalid email or password (min 8 chars)" },
{ status: 400 }
);
}
// Always hash the password to prevent timing-based email enumeration
const userId = crypto.randomUUID();
const sessionToken = crypto.randomUUID();
const passwordHash = await hash(password, 12);
try {
await db.transaction(async (tx) => {
await tx.insert(users).values({
id: userId,
email,
name: name ?? null,
});
await tx.insert(accounts).values({
id: crypto.randomUUID(),
userId,
providerId: "credential",
passwordHash,
});
await tx.insert(sessions).values({
id: crypto.randomUUID(),
userId,
token: sessionToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
});
} catch (err: unknown) {
// Unique constraint violation (duplicate email) — return fake success
// to prevent email enumeration. The dummy token won't resolve to a session.
if (
err instanceof Error &&
(err.message.includes("unique") || err.message.includes("duplicate"))
) {
return NextResponse.json({
user: { id: crypto.randomUUID(), email, name: name ?? null },
token: crypto.randomUUID(),
});
}
throw err;
}
return NextResponse.json({
user: { id: userId, email, name: name ?? null },
token: sessionToken,
});
}
// --- app/api/auth/sign-in/route.ts ---
import { compare } from "bcryptjs";
export async function POST(request: Request) {
const { email, password } = await request.json();
const user = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (user.length === 0) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}
const account = await db
.select()
.from(accounts)
.where(eq(accounts.userId, user[0].id))
.limit(1);
if (!account[0]?.passwordHash) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}
const valid = await compare(password, account[0].passwordHash);
if (!valid) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}
const sessionToken = crypto.randomUUID();
await db.insert(sessions).values({
id: crypto.randomUUID(),
userId: user[0].id,
token: sessionToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
});
return NextResponse.json({
user: { id: user[0].id, email: user[0].email, name: user[0].name },
token: sessionToken,
});
}
// --- app/api/auth/session/route.ts ---
export async function GET(request: Request) {
const token =
request.headers.get("authorization")?.replace("Bearer ", "") ?? null;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const session = await db
.select()
.from(sessions)
.where(eq(sessions.token, token))
.limit(1);
if (session.length === 0 || session[0].expiresAt < new Date()) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db
.select()
.from(users)
.where(eq(users.id, session[0].userId))
.limit(1);
return NextResponse.json({
user: { id: user[0].id, email: user[0].email, name: user[0].name },
expiresAt: session[0].expiresAt,
});
}
// --- app/api/auth/sign-out/route.ts ---
export async function POST(request: Request) {
const token =
request.headers.get("authorization")?.replace("Bearer ", "") ?? null;
if (token) {
await db.delete(sessions).where(eq(sessions.token, token));
}
return NextResponse.json({ success: true });
}
Pitfall: API routes must catch database errors
All API route handlers that touch the database must wrap DB calls in try/catch and return a JSON error response on failure. Never let a database error (e.g., missing table, connection failure) propagate as an unhandled exception — many frameworks will return the raw error text, and the client will fail to parse it as JSON.
// BAD — raw DB error leaks to client as non-JSON text
export async function GET(request: Request) {
const session = await findSessionByToken(token); // throws if table missing
// ...
}
// GOOD — always returns JSON
export async function GET(request: Request) {
try {
const session = await findSessionByToken(token);
// ...
} catch {
return jsonResponse({ error: "Internal server error" }, 500);
}
}This applies to every route: sign-up, sign-in, session, sign-out, and all feature routes (passkey, OTP, etc.).
Pitfall: API key hash/generation must not be duplicated
The API key feature has two consumers of the hash function: the key management endpoints (create) and the authentication middleware (verify). If the hash function is copy-pasted into both files, they can drift — e.g., one gets updated to a new algorithm while the other doesn't, silently breaking authentication.
Extract all shared logic into a single utility module:
// BAD — duplicated across router and middleware
// api-key-router.ts
async function hashApiKey(key: string) {
/* SHA-256 */
}
// api-key-auth.ts
async function hashApiKey(key: string) {
/* SHA-256 — same code, will drift */
}
// GOOD — single source of truth
// api-key-utils.ts
export async function hashApiKey(key: string) {
/* SHA-256 */
}
export function generateApiKey() {
/* ... */
}
export const API_KEY_PREFIX = "...";
// api-key-router.ts
import { hashApiKey, generateApiKey } from "./api-key-utils";
// api-key-auth.ts
import { hashApiKey, API_KEY_PREFIX } from "./api-key-utils";This also applies to the prefix constant and key generation function. Any change to the key format must take effect in both creation and verification simultaneously.
Pitfall: Shared auth helpers must not throw on DB failure
Helper functions like getAuthenticatedUser() that query the database should catch errors and return null instead of propagating. This prevents a database outage from crashing every authenticated route.
// BAD — one DB hiccup crashes all authenticated routes
export async function getAuthenticatedUser(request: Request) {
const session = await findSessionByToken(token); // throws
// ...
}
// GOOD — graceful degradation
export async function getAuthenticatedUser(request: Request) {
try {
const session = await findSessionByToken(token);
if (!session || session.expiresAt < new Date()) return null;
return findUserById(session.userId);
} catch {
return null;
}
}Pitfall: Client-side session check must handle non-JSON responses
The AuthProvider's session refresh should parse the response body defensively — read as text first, then try JSON.parse — so that a server error returning non-JSON text doesn't crash the entire app on page load.
// BAD — crashes if server returns non-JSON (e.g. raw error string)
const res = await fetch("/api/auth/session", { headers });
if (res.ok) {
const data = await res.json(); // throws on non-JSON
setUser(data.user);
}
// GOOD — safe parsing
const res = await fetch("/api/auth/session", { headers });
if (res.ok) {
const text = await res.text();
try {
const data = JSON.parse(text);
setUser(data.user);
} catch {
clearToken();
setUser(null);
}
}Pitfall: OAuth redirect must not use request.url as base URL
In containerized deployments (Docker, Kubernetes), the server often binds on 0.0.0.0 (e.g., ENV HOSTNAME="0.0.0.0" in a Dockerfile). This causes request.url inside route handlers to resolve to http://0.0.0.0:3000/... instead of the public domain. Any NextResponse.redirect(new URL("/path", request.url)) will redirect users to 0.0.0.0.
Derive the redirect base from an environment variable (APP_URL, NEXTAUTH_URL, etc.) instead.
// BAD — request.url resolves to http://0.0.0.0:3000 in containers
export async function GET(request: NextRequest) {
// ...
return NextResponse.redirect(new URL("/", request.url));
}
// GOOD — use a configured base URL for all redirects
export async function GET(request: NextRequest) {
const baseUrl =
process.env.APP_URL ?? "http://localhost:3000";
// ...
return NextResponse.redirect(new URL("/", baseUrl));
}This applies to all OAuth callback routes (Google, GitHub, etc.) and any auth route that issues redirects. Using request.url is only safe for reading query parameters — never as a redirect base in production.