# Why 'Just Wire Up Auth' Is a Project Plan Trap

> Why authentication is more than just a login form and why underestimating it leads to delays
> By Dave · 2026-07-18
> Source: https://otf-kit.dev/blog/auth-is-not-a-weekend-project

A clean signup → verify → login → reset loop is one of the most satisfying things in web work. You click once, the email arrives, the token rotates, the session refreshes silently, and you move on. You never think about it — and the reason you never think about it is because somebody, three sprints ago, in a fugue state at 1am, already debugged the rate-limit headers and the trailing-slash-hating callback URL and the email deliverability dance with the transactional provider.

That somebody is the unsung hero of every product that ships. And "just wire up auth" is the second-worst-estimated line in any project plan.

## The login form is the demo, not the feature

Every starter ships a login form. The login form is two hours of work and one chapter of any auth tutorial. It is also the smallest piece. When a PM writes "auth" in a JIRA ticket, they are indexing on the visible input box and the green "logged in" state. The actual feature surface for a real product is closer to eleven distinct flows, and the only reason they feel small is that nobody draws them on a whiteboard.

The login form is what you demo. The reset flow is what you get paged about at 3am.

## The iceberg: what's under "auth"



![the auth iceberg — the login form is above the waterline, eleven production-critical flows](https://cdn.otf-kit.dev/blog/auth-is-not-a-weekend-project/inline-1.png)



Eleven flows. Each one has a happy path that takes an afternoon and a sad path that takes a week. The afternoon is the visible part. The week is the iceberg — and it eats the schedule every time, because nobody allocates sprint points for the sad path until the sad path is on fire.

## Where the time actually goes

The happy path of password reset is roughly:

```ts
// 1. user requests reset
// 2. generate token, hash it, store with expiry
// 3. send email with link containing the unhashed token
// 4. user clicks, server hashes the token from the URL, looks up the row
// 5. user enters a new password, hashed, saved
// 6. invalidate all existing sessions for that user
```

That is a Saturday. The week that follows is:

- The token in the email needs to be **single-use** — a clever user clicks it twice, the second click must not succeed.
- The token needs to be **rotated** on every use, including failed lookups, to avoid timing attacks.
- The email is going to be **delivered from a domain you haven't warmed up**. Gmail is going to put it in spam. You need SPF, DKIM, DMARC, and a sending reputation you do not have on day one.
- The reset form needs **CSRF protection**, but you have already issued a CSRF cookie at the login form — and the reset URL is in an email, not your app, so the cookie path does not apply.
- The user has **two tabs open**. One is on the reset form, the other is signed in. You reset the password — what happens to the second tab?
- The user **never clicks the link at all**, because the email took eight minutes to arrive and they have already lost interest. You need a "resend the email" button that itself is rate-limited.
- A user requests **forty resets in ten minutes** from your endpoint. You need rate limiting that does not break legitimate retries and does not lock out a shared IP behind a corporate proxy.
- The new password needs to be **checked against the breach corpus**, because the one from 2019 has every password your users have ever used.

That's not paranoia. Every one of those is a real postmortem from a real product.

## A worked example: the half nobody writes down

A rate limiter that survives real use looks like this:

```ts
type Bucket = { tokens: number; updatedAt: number }
const buckets = new Map<string, Bucket>()

function take(key: string, max: number, refillPerSec: number) {
  const now = Date.now()
  const b = buckets.get(key) ?? { tokens: max, updatedAt: now }
  const elapsed = (now - b.updatedAt) / 1000
  b.tokens = Math.min(max, b.tokens + elapsed * refillPerSec)
  b.updatedAt = now
  if (b.tokens < 1) return false
  b.tokens -= 1
  buckets.set(key, b)
  return true
}

// /api/auth/reset: 5/hour per email, 20/hour per IP, sliding window
app.post('/api/auth/reset', (req) => {
  if (!take(`ip:${req.ip}:reset`, 20, 20 / 3600)) return new Response(null, { status: 429 })
  if (!take(`email:${req.body.email}:reset`, 5, 5 / 3600)) return new Response(null, { status: 429 })
  // ... generate token, send email
})
```

That block is three tries, two Stack Overflow answers, and one bug fix per line. The next person to write it from scratch will spend a day on it. The twenty-fourth person to write it from scratch will copy it from the first three and not notice the bucket map leaks memory in a long-running process, because they did not write a long-running test.

## What "done" means: the pre-ship checklist

The work is not done when the login form posts and the green dot appears. It is done when a real user with a real email address can:

- sign up, get the verify email in their inbox, click it, land logged in
- log out from a phone and stay logged in on a laptop
- request a reset, get the email, change the password, be signed in on the new device, and have every other session revoked
- request forty resets in a row and get rate-limited, but five legitimate retries in an hour still succeed
- lose their second factor and recover through a second-factor-out-of-band flow
- see every auth event — sign-in, password change, MFA challenge, device added — in an audit log their admin can query

Eleven items. Each one has failed in production somewhere. The fastest teams are not the ones who skip the list; they are the ones who banked it once and stopped debugging it twice.

## The kit difference: shipping day one instead of week three



![hand-rolled auth in week three vs kit-shipped auth on day one](https://cdn.otf-kit.dev/blog/auth-is-not-a-weekend-project/inline-2.png)



A kit's job is to bank the iceberg as solved code, not as a Trello column. The SaaS Dashboard ships with auth, billing, DB, and Stripe wired against the same conventions — which means every one of the eleven flows above already has a route handler, a migration, a token table, a rate limiter, and an email template. You open a ticket and the thing already there is the thing you wanted.

Two details matter more than the rest. First, every shipped kit comes with `CLAUDE.md`, a `.cursorrules`, and twenty-plus tested `ai/prompts/` — so when you hand the project to Claude Code or Cursor, the agent extends the existing auth surface instead of regenerating it from scratch and skipping the rate limit. Second, the kit has a 24-item design checklist enforced by a script before anything ships. That is not romantic — it is a callback URL, a token rotation, and an audit log row that bit someone in a prior version, codified.

What day one looks like with the kit:

```bash
# one command scaffolds the dashboard locally; auth flows run against
# a local Postgres; emails print to the terminal until you wire a provider
npm create otf@latest -- --template saas-dashboard
cd my-app && pnpm dev
```

What day one looks like without it: a Trello column called "auth" with eleven tickets and a calendar invite for week three to revisit the rate limiter.

## What a durable layer underneath the churn looks like

The thing the frame does not change is the iceberg. Models come and go — Cursor, Claude Code, a next agent nobody has heard of yet. The flow "user clicks reset link → server validates one-time token → password is rotated and all sessions invalidated" is the same flow in 2024 and 2026 and 2030. The rate limiter above is the same rate limiter.

The angle worth banking once is not the agent — it is the route handler that ships with the SaaS Dashboard, the migration that already created the token table, the email template that already passes spam filters, the audit log row that is already being written. When a new agent lands and you point it at the codebase, the durable layer is what it has to live with — and that is the part worth solving once, not once per agent.

A login form is two hours. Email verification, password reset, session refresh, server-side logout, rate limiting, MFA enrollment, account recovery, magic links, audit logging — that is the iceberg, and it eats the project plan every time. The fastest teams aren't the ones who skip it; they're the ones who don't have to debug it twice. Bank the iceberg once. Then build the product.