I am writing this series because I have built authentication from scratch six times, and every time I got it about 80 percent right and discovered the last 20 percent at 2am when a user said something weird happened.
The point is not to talk you into rolling your own auth. Most of the time you should not. The point is that if you are going to, or if you are trying to read the source of a library that does it for you, you should see all the moving parts laid out. Once you see them, you can decide whether to maintain them yourself or let kavachOS do it.
What we are building
A Next.js 15 app with Postgres. Email and password, magic link, Google and GitHub OAuth, passkeys, rate limiting, session rotation, password reset, email verification, and an agent token system for AI scripts that call your API on behalf of users.
By the end of the series you will have either:
- Built the whole thing yourself and know exactly what every line does
- Looked at the "and the same thing in kavachOS" sections and decided your weekend is worth more than an auth rewrite
Both are fine. I do both depending on the project.
The series in one diagram
+-------------------+
| Browser / App |
+---------+---------+
|
| HTTPS
v
+----------------------------+
| Next.js app (App Router) |
| /app/auth/* UI pages |
| /api/auth/* endpoints |
+------+---------------+-----+
| |
session | | outbound email
cookie | v
| +---------+
| | Resend |
v +---------+
+--------+---------+
| Postgres |
| users |
| sessions |
| oauth_accounts |
| reset_tokens |
| magic_tokens |
| verify_tokens |
| passkeys |
| agent_tokens |
+--------+---------+
|
| read via
v
+------------------+
| Redis / KV |
| rate limits |
| active session |
| lookups |
+------------------+
That is the whole thing. Nine tables, one cache, two network dependencies (Postgres and email), one frontend. Every article in the series fills in one box.
What each article covers
| # | Article | What ships |
|---|---|---|
| 01 | You are here | Architecture, schema preview, series map |
| 02 | Database schema | SQL for all 8 tables, index choices, the drizzle schema |
| 03 | Register user | Signup form, password rules, hash choice, email verification trigger |
| 04 | Login | Form, session cookie, CSRF token, remember me, timing defense |
| 05 | Password reset | Token generation, one time use, session rotation on success |
| 06 | Email verification | Sending, verifying, re-sending, bounce handling |
| 07 | Magic link login | Passwordless token flow with 10 minute expiry |
| 08 | OAuth (Google, GitHub) | PKCE, state, callback validation, account linking |
| 09 | Passkeys | WebAuthn registration and assertion with a password fallback |
| 10 | Rate limiting | Login attempts, email enumeration defense, CAPTCHA gating |
| 11 | Agent tokens / MCP OAuth | Minting scoped tokens for scripts and AI agents |
| 12 | Deploy | Cloudflare Workers with D1 and Durable Objects |
The tables at a glance
I will spend article 02 on each of these. If you are following with kavachOS rather than building by hand, you do not write the DDL: pnpm kavachos migrate creates and maintains all of these for you. Article 02 still exists because it is worth knowing what is in your database, whichever library you use.
Here is the preview so you have the shape in your head:
users sessions
+--------------+ +------------------+
| id |<-----+ | id |
| email | +-----| user_id |
| password_hash| | token_hash |
| email_verif. | | expires_at |
| created_at | | last_used_at |
+--------------+ +------------------+
oauth_accounts password_reset_tokens
+--------------+ +------------------+
| id | | id |
| user_id |------+ +---| user_id |
| provider | | | | token_hash |
| provider_uid | | | | expires_at |
| access_token | | | | used_at |
+--------------+ | | +------------------+
| |
magic_link_tokens | | email_verification_tokens
+--------------+ | | +------------------+
| id | | | | id |
| user_id |------+ +---| user_id |
| email | | | | token_hash |
| token_hash | | | | expires_at |
| expires_at | | | | used_at |
+--------------+ | | +------------------+
| |
passkeys | | agent_tokens
+--------------+ | | +------------------+
| id | | | | id |
| user_id |------+ +---| user_id |
| credential_id| | token_hash |
| public_key | | permissions[] |
| counter | | expires_at |
+--------------+ +------------------+
If you squint, it is 8 tables with one join key. That is on purpose. Boring schemas age well.
The security properties the system has to maintain
Every article will reference this list. Tape it to your monitor.
-
No account enumeration.
/login,/forgot-password,/registerreturn the same response for "user exists" and "user does not exist". Timing differences on those endpoints should be under 50ms. -
No token reuse. Every reset link, magic link, and verification link is one time use. The check is a
used_at IS NULLclause, not an application-level flag. - Short expiries. Reset and magic link tokens expire in 15 minutes. Email verification in 24 hours. Sessions can run long (30 days) because they are revocable.
- Hashed token storage. Every token in the database is the SHA-256 of the raw value. The raw value exists only in the email or URL.
- Session rotation on privilege change. Password reset, email change, and OAuth unlink all invalidate every existing session for that user.
- Rate limits on every unauthenticated endpoint. Login, forgot-password, register, magic link request, verification resend.
- CSRF on session-carrying endpoints. Double submit cookie or SameSite=Lax + custom header.
-
Structured audit logs.
auth.register.success,auth.login.failure,auth.session.rotated, and so on. You will want these the first time a user claims they did not do something.
The trade-off you are making by doing this yourself
Writing this code is not that hard. Maintaining it is. Every one of these articles represents 2 to 6 hours of initial implementation plus a long tail of reading advisories, upgrading libraries, and handling weird bugs.
The rough math:
DIY auth
initial build: 60 hours of focused work
annual maintenance: 30 to 80 hours
incident cost: unknown, but nonzero
Managed (Auth0, Clerk)
initial build: 4 hours
annual cost: $1,000 to $50,000 depending on MAU
incident cost: their team handles it
Open source library (kavachOS, Better Auth)
initial build: 4 to 8 hours
annual maintenance: 5 to 15 hours (upgrades)
incident cost: you handle it, but the code is readable
If you are a solo dev with a side project, the library path is usually right. If you are at a company with a security team that wants to own the code, DIY is reasonable. If you are between those, a managed service buys you time.
I use kavachOS because I want to own the code but not write it. Your answer will depend on your context.
How to follow along
Pick one of these three paths:
Path A: read along, do not build. You will still get value from seeing how the pieces fit.
Path B: build with me from scratch. Clone the starter repo at github.com/kavachos/nextjs-auth-from-scratch. Each article has a matching branch.
Path C: build with kavachOS and skim the DIY parts. Run:
pnpm create next-app@latest my-auth-app --typescript --app --tailwind
cd my-auth-app
pnpm add kavachos @kavachos/nextjs
Then follow the "kavachOS version" section at the bottom of each article.
What you need installed
Before article 02, have these ready:
node --version # 20 or higher
pnpm --version # 9 or higher
psql --version # 15 or higher, or a Neon/Supabase URL
A Resend account (or any SMTP provider) for email. An Upstash Redis instance or Cloudflare KV namespace for rate limits. That is it.
Why I am writing this as a series instead of one mega post
Two reasons.
One, auth is broken up into real sub-problems that each deserve their own treatment. Jamming all of it into one 40,000 word post means nobody reads it.
Two, I want to write for 12 days straight. Forcing publication at this cadence means I cannot perfect anything, which is good. The first draft of a system is more honest than the polished one.
Next up
Article 02: the database schema. Every table, every index, every column nobody thinks about until they get a support ticket at midnight. We will also look at why text beats varchar on Postgres, why id should be a bigserial and not a uuid in most cases, and how to make your email column case-insensitive without hating yourself.
See you tomorrow.
Comment with the article you would most like me to skip ahead to. If enough people ask for the same one, I will reorder the series.
Tags: #authentication #webdev #nextjs #tutorial
Top comments (0)