Authentication Is Harder Than You Think
Every project plan I've ever written has a line item: "Authentication — 2 days."
Every project retrospective has a note: "Auth took 2 weeks."
I've built auth systems 4 times now. Each time, I underestimate it. Here's why, and what I finally learned.
The Iceberg
What you think auth is:
- Login form
- Store a token
- Check if token is valid
- Done
What auth actually is:
- Login form (email/password + OAuth + magic links + MFA?)
- Password hashing (bcrypt, argon2, what cost factor?)
- Session management (JWT vs session cookie vs both?)
- Token refresh (silent refresh, rotation, revocation)
- CSRF protection (same-site cookies, double-submit token)
- Rate limiting (on login, on registration, on password reset)
- Password reset flow (token generation, expiry, single-use)
- Email verification (token, resend logic, what if they change email?)
- Account lockout (how many attempts? What's the unlock flow?)
- Role-based access (admin vs user vs moderator)
- API key management (for programmatic access)
- Session invalidation on password change
- "Remember me" vs "this session only"
- Login from new device notification
- Audit logging (who logged in, when, from where)
That's 15+ features. At 1-2 days each, you're looking at a month.
What I Do Now: Use Supabase Auth and Extend
After building custom auth twice and hating my life both times, I now start with Supabase Auth (or Clerk, or Auth.js). It handles:
- Email/password with bcrypt
- OAuth providers (Google, GitHub, Discord)
- JWT tokens with refresh
- Email verification
- Password reset
- Session management
- Rate limiting
That's 80% of auth, handled by people who think about auth full-time. I focus on the 20% that's specific to my app:
```typescript // My auth layer is thin — it extends Supabase Auth with app-specific logic async function handleLogin(email: string, password: string) { // Supabase handles the actual authentication const { data, error } = await supabase.auth.signInWithPassword({ email, password });
if (error) { // My addition: audit logging await logAuthEvent('login_failed', { email, reason: error.message }); throw error; }
// My addition: check subscription status const subscription = await getActiveSubscription(data.user.id); if (!subscription) { // Redirect to pricing, not error return { user: data.user, redirect: '/pricing' }; }
// My addition: update last login await db.profiles.update({ where: { userId: data.user.id }, data: { lastLoginAt: new Date(), loginCount: { increment: 1 } } });
await logAuthEvent('login_success', { userId: data.user.id }); return { user: data.user, subscription }; } ```
The Mistakes I Made (So You Don't Have To)
Mistake 1: JWT tokens in localStorage. localStorage is accessible to any JavaScript on the page. If you have an XSS vulnerability, the attacker gets your auth token. Use httpOnly cookies instead.
Mistake 2: Not rotating refresh tokens. If a refresh token is stolen, the attacker has access forever. Rotate refresh tokens on every use — when a refresh token is used, issue a new one and invalidate the old one.
Mistake 3: Forgetting to invalidate sessions on password change. If a user changes their password because they think they've been compromised, all existing sessions should be killed. I shipped without this once. It defeats the purpose of changing your password.
Mistake 4: Password reset tokens that don't expire. My first implementation had reset tokens that were valid forever. That means if someone's email is compromised a year from now, old reset links still work. Set a 1-hour expiry.
The Decision Framework
| Situation | Use |
|---|---|
| Building an MVP | Supabase Auth / Clerk (don't build auth) |
| SaaS with subscriptions | Supabase Auth + custom subscription logic |
| Enterprise with SSO | Auth0 or WorkOS (don't even try to build SAML) |
| API-only service | API keys + rate limiting (simpler than JWT) |
| Never | Rolling your own crypto, password hashing, or token generation |
The Bottom Line
Auth is infrastructure, not a feature. Users don't care about your auth — they care about getting into the app. Spend as little time as possible on auth mechanics and as much time as possible on what happens after login.
The best auth system is one you didn't build.