| name | create-auth |
| description | Scaffold signin and signup authentication endpoints for a project. Use when the user wants to add authentication, create login/register flows, or set up auth from scratch. |
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:
- Look for framework config files (e.g.,
next.config.*, package.json, go.mod, Cargo.toml, pyproject.toml, build.gradle*, pom.xml)
- Look for existing database/ORM setup (e.g.,
prisma/schema.prisma, drizzle.config.*, alembic/, diesel.toml, ormconfig.*)
- 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"
- MCP Server — "OAuth 2.1 + discovery endpoints so Model Context Protocol clients (Claude Desktop, mcp-inspector, Cursor) can authenticate. If selected, ask a follow-up: Resource Server only (delegate to an existing IdP) vs Self-hosted Authorization Server (issue your own tokens). See
references/features/mcp-server.md for the two modes."
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.
Dependencies:
- 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.
- If the user selects MCP Server in Mode B (Self-hosted Authorization Server), strongly recommend turning on Rate Limiting as well — the public
/oauth/register, /oauth/authorize, and /oauth/token endpoints need it. Confirm with the user before generating; if they decline, leave a TODO comment at each endpoint pointing at the rate-limiting feature.
| 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 |
| MCP Server | references/features/mcp-server.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::string in C++, String in Rust/Java, string in 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 |
| email | string | unique, not null (store the canonical form — see sign-up) |
| name | string | nullable |
| image | 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 |
| ipAddress | string | nullable |
| userAgent | string | nullable |
| createdAt | datetime | default now |
ipAddress and userAgent power the "signed-in devices" list (see the Multi-Session feature) and security-notification emails.
Account
| Field | Type | Constraints |
|---|
| id | string | primary key |
| userId | string | foreign key -> User, not null |
| providerId | string | not null (e.g. "credential") |
| accountId | string | not null (provider-side user id; = userId for "credential") |
| passwordHash | string | nullable |
| createdAt | datetime | default now |
| updatedAt | datetime | auto-update |
Add a unique constraint on (providerId, accountId). Account lookups must always filter by that full tuple — never by accountId alone — so that OAuth providers added later cannot collide across ID spaces (see references/pitfalls/oauth-account-linking.md).
VerificationToken
| Field | Type | Constraints |
|---|
| id | string | primary key |
| userId | string | foreign key -> User, not null |
| purpose | string | not null ("verify-email" or "password-reset") |
| email | string | not null (the address the token was issued for, lowercased) |
| tokenHash | string | unique, not null (SHA-256 of the raw token) |
| expiresAt | datetime | not null |
| consumedAt | datetime | nullable (set atomically on use) |
| createdAt | datetime | default now |
One table serves both flows; purpose keeps them apart and MUST be matched at redemption, or a reset link is redeemable as an email confirmation. Store only the hash — the raw token exists solely inside the emailed link. email records what the token proves, so a later address change cannot inherit the proof.
Endpoints
POST /api/auth/sign-up
- Body:
{ email, password, name? }
- Canonicalize the email before validating it (NFKC where the stdlib has it, then trim, then lowercase), then validate the canonical string and store exactly that string — see
references/pitfalls/email-case-normalization.md
- Validate the canonical string against a strict shape, not a permissive one: a lowercase RFC 5322 dot-atom local part, dot-separated
[a-z0-9-] domain labels, exactly one @, ≤ 254 chars — no quotes, comments, angle brackets, commas, spaces, or non-ASCII (internationalized addresses only if the project deliberately supports them). Anything a mail library could re-parse into a different recipient — a(b)@x, "a"@x, x<attacker@evil>, a,b@x — is rejected rather than stored. Then validate 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", accountId: the new user's id) + Session
- Record
ipAddress and userAgent on the session (User-Agent header; client IP from the trusted proxy header when deployed behind one, otherwise the socket address)
- Only when the insert actually created a user, issue a
"verify-email" token and send the confirmation link (see POST /api/auth/verify-email/send) — nothing else in a password-only build ever sets emailVerified, and an account that never proves an identifier is reaped. The duplicate-email path below creates no user, so it sends nothing and leaks nothing
- Return session token and user (without password)
- Email enumeration protection: If the email already exists, return the same
200 OK status 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 }
- Canonicalize the email through the same helper sign-up uses (NFKC, trim, lowercase) before lookup
- Look up user by email, verify password hash
- Create new Session (record
ipAddress and userAgent as in sign-up)
- 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
- Set
Cache-Control: no-store once at the top of the handler — not at each return, and on the 401 as well as the 200. Otherwise the browser disk-caches this GET and keeps replaying "signed in" with the cached profile after the session has expired server-side.
POST /api/auth/sign-out
- Read session token
- Delete session from database
- Return 200
POST /api/auth/verify-email/send
- Body:
{ email } (canonicalize through the sign-up helper before lookup). Sign-up calls this internally on success; the route itself exists for resends
- Delete the user's outstanding
"verify-email" tokens, then issue one: ≥32 bytes crypto-random, store only its SHA-256, expiresAt ≤ 24 h
- Email the link, built on a configured public base URL — never on the request's
Host / X-Forwarded-Host, which the caller controls (reset-link poisoning; see references/pitfalls/oauth-redirect-request-url.md). Return 200 always, whether or not the address has an account — this endpoint must not reveal which addresses are registered
- Rate limit per address and per IP (3 per hour is reasonable) — it sends mail on demand
POST /api/auth/verify-email/confirm
- Body:
{ token }
- Hash the presented token, look up by
tokenHash, and require purpose = "verify-email", unexpired and unconsumed
- Consume atomically — one conditional write gated on
consumedAt IS NULL, affected-rows checked (see references/pitfalls/single-use-token-race.md)
- Flip
emailVerified with a conditional write naming the token's email: UPDATE users SET email_verified = true WHERE id = $1 AND email = $2. Bind the address, and keep the flag itself out of this guard — this write records a proof and authorizes nothing destructive (see references/pitfalls/async-proof-value-binding.md). Zero rows means the address changed after the link was issued: discard the proof
- Exempt from the credential strip. This link was issued by the very sign-up that set the password, so it confirms that password rather than adopting a stranger's — unlike a magic link, which anyone may request for any address (see
references/pitfalls/pre-account-hijack-strip.md)
- Return 200 (generic error on an invalid, expired, consumed, or wrong-
purpose token)
POST /api/auth/password-reset/request
- Body:
{ email } (canonicalize through the sign-up helper before lookup)
- Same contract as
verify-email/send: delete outstanding "password-reset" tokens, issue one with expiresAt ≤ 30 min, return 200 always, rate limit per address and per IP
POST /api/auth/password-reset/confirm
- Body:
{ token, password }
- Validate the new password before touching the token. A rejected password must not burn the link — only failures that happen after the consume are unrecoverable (see
references/pitfalls/single-use-token-race.md)
- Hash the presented token, look up by
tokenHash, require purpose = "password-reset", unexpired and unconsumed, then consume it atomically
- If the row's
emailVerified is still false, this reset is the first proof of mailbox control it has ever had: claim and strip it in the same transaction (see references/pitfalls/pre-account-hijack-strip.md) — everything on a never-verified row is unproven, including a planted passkey. Then write the new password into a freshly created credential Account, since the strip deleted the old one
- Otherwise update the existing credential Account's
passwordHash in place
- Revoke every session for that user — a reset is the remedy for a compromised account, so the attacker's session must not survive it. Do not mint a new one; require a fresh sign-in
- 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
- Canonicalize emails at the boundary, then validate the canonical string: every email arriving in any request (core endpoints and feature endpoints alike, and an address arriving in an IdP profile or claim) goes through one helper — NFKC-normalize where the standard library offers it (
String.prototype.normalize, unicodedata.normalize, java.text.Normalizer), trim, lowercase — before any validation, lookup, or insert, and only the canonical form is stored. Validation runs on that string, never on the raw input: an address holding a fullwidth @ (U+FF20) passes a single-@ check on the raw bytes, then a mail library that normalizes recipients on its own sees two separators and delivers the sign-in link somewhere else (CWE-180, validate-before-canonicalize; Auth.js shipped it as GHSA-7rqj-j65f-68wh). The string you validated is the string you store and the string you hand to the mailer — which is also why the shape check is strict (lowercase dot-atom, see sign-up): a mailer that re-parses a(b)@x or x<attacker@evil> into a different recipient breaks that promise even after perfect canonicalization. Reject non-ASCII outright unless the project deliberately supports internationalized addresses — that is what closes the homoglyph hole in a language without stdlib NFKC (Go, Rust). Never compensate at query time with LOWER()/ILIKE
- Never synthesize a routable email: if a sign-up path has no email (Phone Number), prefer making the
email column nullable. If it must stay non-null, mint the placeholder under the RFC 6761 reserved TLD, namespaced by source — — never a domain anyone can receive mail at. A placeholder is never itself a proven identifier: leave false permanently, never send mail to it, and let carry that account's proof. The step in Magic Link and Email OTP must skip placeholder rows — otherwise a magic link requested for a guessed placeholder address signs the attacker in as that user. Trading a placeholder for a real address requires a full verification cycle.
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 return null, never throw or a truthy error | 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 |
| MCP tokens must be audience-bound | references/pitfalls/mcp-token-audience.md |
| MCP must not pass tokens upstream | references/pitfalls/mcp-token-passthrough.md |
MCP 401 / recoverable 403 need resource_metadata | references/pitfalls/mcp-www-authenticate.md |
MCP .well-known must mount at root | references/pitfalls/mcp-discovery-mounting.md |
| Single-use tokens consume atomically | references/pitfalls/single-use-token-race.md |
| Emails canonicalize, then validate | references/pitfalls/email-case-normalization.md |
| Set-Cookie must survive error paths | references/pitfalls/set-cookie-on-error.md |
| OAuth links key on provider+account id | references/pitfalls/oauth-account-linking.md |
| Passwordless sign-in strips credentials | references/pitfalls/pre-account-hijack-strip.md |
| Async proofs bind to the value proven | references/pitfalls/async-proof-value-binding.md |
| NULL owner must deny, not skip the gate | references/pitfalls/nullable-owner-gate.md |
| Authorize the value the handler acts on |
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.