DEV Community

Cover image for I've built auth six times. Here's the system I would build today
GDS K S
GDS K S

Posted on

I've built auth six times. Here's the system I would build today

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:

  1. Built the whole thing yourself and know exactly what every line does
  2. 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         |
                  +------------------+
Enter fullscreen mode Exit fullscreen mode

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       |
+--------------+            +------------------+
Enter fullscreen mode Exit fullscreen mode

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.

  1. No account enumeration. /login, /forgot-password, /register return the same response for "user exists" and "user does not exist". Timing differences on those endpoints should be under 50ms.
  2. No token reuse. Every reset link, magic link, and verification link is one time use. The check is a used_at IS NULL clause, not an application-level flag.
  3. 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.
  4. 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.
  5. Session rotation on privilege change. Password reset, email change, and OAuth unlink all invalidate every existing session for that user.
  6. Rate limits on every unauthenticated endpoint. Login, forgot-password, register, magic link request, verification resend.
  7. CSRF on session-carrying endpoints. Double submit cookie or SameSite=Lax + custom header.
  8. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)