Here's the unpopular take: if your AI agent told you to use Auth0 or Clerk, your AI agent is wrong.
I've shipped auth systems for SaaS products, internal portals, and a half-finished Instagram automation tool I'm building right now. The mistakes I see other devs (and AI agents) make are the same every time. Vendor lock-in. Email-based login as a default. JWTs because some YouTube tutorial said they're "modern". Storing email as the user's primary identifier. SMS 2FA. All of it — wrong.
This is a guide for people who actually want to understand auth, not just paste a Clerk SDK and call it a day. If you're building a SaaS, a portal, or anything that handles user data — read this before you ship.
tl;dr
Stop using third-party auth providers like Auth0 and Clerk — you can build your own OAuth in a weekend and own your users' data. Minimize login options, prefer OAuth flows over email/password, store stable identifiers (not emails), and skip JWTs in favor of database-backed tokens until you're doing 20,000+ requests per second. If you must do email/password, hash on the frontend, rate-limit everything, and use authenticator apps over SMS for 2FA.
Stop Outsourcing Your Most Critical System
Auth is the front door of your business. You wouldn't outsource the lock on your front door to a company that charges you per visitor and has been broken into before. So why do this with auth?
The pitch from Auth0, Clerk, and the rest is "we handle the hard parts." Sure — and they also:
- Lock you in. Migrating off these platforms is brutal once you have thousands of users
- Charge on monthly active users — a metric that punishes you for growing
- Get hacked. Auth0 and Okta have both had breaches that exposed customer data
- Hold your users' data hostage. You don't actually control the source of truth
Building your own OAuth flow is genuinely easy. A users table, a sessions table, a /login route, a /callback route. That's it. I built one for a client portal in about 6 hours. Did the cost math afterward — they would've paid Auth0 around $240/month at their user count. For one afternoon of work.
If you're building APIs that need to be secured properly, I covered the broader picture in Securing Your API: Auth Strategies for REST vs GraphQL. The principles port over.
Fewer Login Options, Less Pain
There's a weird obsession with offering every login provider on earth. Email, Google, GitHub, Facebook, Apple, Microsoft, passkeys, magic links — pick one. Maybe two.
Every login option you add does three things: increases your code complexity, expands your attack surface, and confuses non-technical users who can't remember which one they used last time.
Here's how I think about it now:
- B2B SaaS targeting Google Workspace users? → "Sign in with Google". Done.
- Dev tool? → "Sign in with GitHub". Done.
- Instagram automation app like the one I'm building? → "Sign in with Instagram". The user already needs the account.
- Consumer app with no obvious primary identity provider? → email/password, but with serious guardrails (more on that below)
I started my Instagram tool with both Google login and email/password "for flexibility." Pulled both out two days later. Why have two systems to maintain when one matches the workflow?
OAuth Beats Email Almost Every Time
Email is one of the oldest, weakest forms of identity verification on the internet. You can grab a temporary inbox in 5 seconds. You can spin up Gmail accounts with fake info if you know what you're doing. Spammers and bots have been gaming email-based registration for 20+ years.
OAuth flows from major providers solve this for you — for free.
Try registering for Gmail with fake details. Google will demand your phone number. Try doing it 50 times in a row. Google will block your IP. Google has spent billions fighting spam at a scale you'll never approach. When somebody comes through "Sign in with Google", you're inheriting all that verification work.
But — and this matters — make sure you're doing OAuth properly, not just allowing @gmail.com email addresses. Those are different things.
Real OAuth = the full redirect flow + token exchange + verified user info from Google's API. Fake "Google login" = letting people register with any
@gmail.comemail address.
The second one is useless. There are sites right now offering temporary gmail.com-receiving inboxes. Don't fall for it.
Verify the verification flag
A few years back, I had "Sign in with GitHub" on a project. Worked fine — until I realized GitHub was happily letting unverified accounts through the OAuth flow. There's a verified boolean in the response payload that nobody talks about in tutorials. If you don't check it, you're letting people sign up with email addresses they don't actually own.
Read the OAuth response. All of it. Every provider has a verified_email or equivalent field. Check it. Don't trust the user payload blindly.
Store Stable IDs, Not Emails
This one bites people late.
Imagine you build "Sign in with Instagram." You key your users table on the email Instagram returns. Six months later, the user updates their Instagram email. They sign in. Your system creates a new user — because the email is different — and they're locked out of their original account.
Email is not a stable identifier on Instagram. Or GitHub. Or Twitter. Or LinkedIn.
The stable identifier is the provider's internal user ID:
- Google → the
subfield (a string of digits) - Instagram → the Instagram user ID
- GitHub → the numeric
idfield - Apple → the Apple user ID (which they specifically warn you not to confuse with email)
Your schema should look something like:
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255), -- display only, can change
created_at TIMESTAMP
);
CREATE TABLE oauth_identities (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT REFERENCES users(id),
provider VARCHAR(50), -- 'google', 'instagram', 'github'
provider_user_id VARCHAR(255), -- the stable ID
email VARCHAR(255), -- email at time of last sign-in
email_verified BOOLEAN,
UNIQUE (provider, provider_user_id)
);
This schema also makes account linking trivial — if a user has both Google and Instagram, and Google's verified email matches Instagram's email, you can link them under one users.id. No more "you already have an account, please sign in with the original method" friction. For more on schema design that holds up over time, see Building Scalable APIs.
Email + Password: Do It Right or Skip It
If you absolutely need email/password — fine. But you can't ship it raw. Here's the minimum:
- Hash on the frontend, then again on the backend. bcrypt and Argon2 are CPU-killers. A Node.js server can fall over at a few thousand login requests per second because hashing blocks the event loop. Compute the hash client-side, send it to the server, hash it again with a server-side salt. Nothing leaks, you survive a traffic spike.
- Rate limit every endpoint. Login. Register. Password reset. Resend verification. All of them. Without rate limits on /register and /reset, you'll either get crushed by botnets or wake up to a $4,000 SendGrid bill.
- CAPTCHA on email/password login. Yes, friction. That's the point. If users can avoid the CAPTCHA by clicking "Sign in with Google" instead, even better — you've nudged them toward the safer path.
- Allow-list email domains where you can. Restricting to
gmail.com,outlook.com, business domains, etc. won't stop sophisticated attackers, but it filters out 90% of script-kiddie spam. (For consumer apps, this is often impractical. For B2B, it's a no-brainer.) - Verify the email before granting full access. Send a confirmation link. Don't let unverified accounts do anything that costs you money.
For login form UX specifically, HTML Best Practices for Login and Signup Forms covers the front-end side — autocomplete attributes, password manager hints, accessibility.
Stop Using JWTs to Skip the Database
This is the take that's going to upset some of you. I don't care.
JWTs have a real, narrow use case: stateless auth at GitHub-scale where every database lookup costs serious money. For your SaaS with 5,000 users and 30 requests per second? JWTs are an over-engineered footgun.
The case people make for JWTs:
"It avoids a database call on every authenticated request."
Cool. So does Redis. So does an in-memory cache. So does literally any cache. And here's the thing — that "avoided" database call?
SELECT user_id FROM auth_tokens WHERE token = $1 AND expires_at > NOW();
Indexed. Sub-millisecond. Your API will make 5 more queries that are 100x more expensive in the same request. For more on making sure those queries are actually fast, see How to Optimize SQL Queries to Run Faster.
What you give up by using JWTs:
- Instant logout. Can't revoke a JWT without maintaining... a blacklist in a database. Which requires a DB call. Defeats the entire point.
- Token rotation. Painful with JWTs.
- Storing extra session data. With DB tokens, you can join to anything. With JWTs, you're stuffing data into the payload and bloating every request header.
- Stolen token recovery. If a JWT leaks, your only option is to redeploy with a new secret (invalidating every user) or maintain a blacklist (defeats the purpose, again).
Use DB-backed session tokens until you genuinely need to scale past 20-30k req/s. By then, you'll have a senior infra team and they can rebuild the auth layer with a clearer picture of the actual load.
For business owners reading this who don't write code — the principle here aligns with broader Zero Trust Architecture thinking. Verify every request, store the keys somewhere you control.
Pick the Right 2FA — Not the Convenient One
If you're adding 2FA, don't use SMS.
SMS-based 2FA is broken in three places at once:
→ SIM-swapping attacks. Someone calls a carrier, social engineers a SIM transfer, and now they receive your user's codes. → Cost. SMS is expensive at scale, especially internationally. The Twilio bill on a global SaaS will surprise you. → Reliability. SMS delivery is genuinely unreliable in some regions. Users get locked out of their accounts because Etisalat dropped a code.
Use a TOTP authenticator app — Google Authenticator, Authy, 1Password, whatever. The library to support them is 50 lines of code. They work offline. They're not tied to a carrier. They're free.
For high-stakes B2B accounts, layer hardware keys (YubiKey, Titan) on top. WebAuthn handles the implementation. For consumer apps, TOTP is plenty.
What you lose with SMS auth and what real breaches look like — read about the Tea App Data Breach for what happens when this stuff isn't taken seriously.
What About Passkeys?
Honest answer: passkeys are great if your users are technical. If you're building a dev tool, ship passkeys. If you're building anything for non-technical users — my parents have no idea what a passkey is.
If you've already got "Sign in with Google" enabled and Google itself supports passkeys (it does), you're indirectly offering passkey auth to anyone who wants it. You don't need to roll your own.
Passkeys are optional. Don't let WebAuthn enthusiasts on Hacker News bully you into shipping a feature your users won't use.
The Test-Everything-Or-Lose-Everything Rule
I'll keep this short. Auth code is the one part of your app where bugs cost real money and real reputation. The 2025 npm supply chain hack (massive ecosystem-wide attack via compromised packages) showed how one weak link in the chain can compromise thousands of apps overnight.
Test every edge case. Expired tokens. Tampered tokens. OAuth callbacks with missing fields. Email/password with SQL injection attempts. Race conditions on concurrent logins. The boring tests are the ones that save you when an attacker actually shows up.
If you're using Claude Code or similar AI tooling for tests, my Claude Code Workflow Guide covers how to structure test generation properly so you actually catch the edge cases instead of getting happy-path noise.
FAQ
Should I build my own authentication system or use Auth0?
Build your own. Third-party auth providers like Auth0 and Clerk introduce vendor lock-in, charge you on weird metrics like monthly active users, and have been breached in the past — meaning your users' data becomes vulnerable through no fault of yours. A basic OAuth flow with database-backed tokens takes a weekend to build, costs nothing extra to run, and gives you full control over your data and login experience.
Is JWT better than database session tokens for authentication?
For 99% of apps, no. JWTs were popularized as a way to avoid database calls during auth checks, but a single indexed token lookup takes under 1 millisecond and your typical API already makes 5+ database calls anyway. JWTs can't be revoked instantly — if a token leaks, you're stuck blacklisting it (which requires a DB call, defeating the point). Use DB-backed tokens until you hit 20,000+ requests per second.
What's wrong with email and password authentication?
Email-based login increases your attack surface in three ways: spam registrations from temporary email services, password-reset endpoint abuse that runs up your email bill, and weak password reuse across breached sites. If your app already requires a Google or Instagram account, just use that OAuth flow as authentication — those providers spend millions verifying accounts that you'd otherwise have to verify yourself.
Should I store the user's email or a stable ID from the OAuth provider?
Always store the stable ID. A user can change the email on their Instagram, GitHub, or Facebook account at any time — if you've keyed your database on email, you'll lock them out of their own account. Use the provider's permanent user ID (Google sub, Instagram user ID, GitHub ID) as the source of truth. Store email as a secondary attribute for display and account linking only.
Is SMS-based two-factor authentication secure?
No. SMS 2FA is vulnerable to SIM-swapping attacks where attackers convince a carrier to transfer a victim's phone number to a new SIM. SMS delivery is also unreliable and expensive at global scale. Use TOTP-based authenticator apps like Google Authenticator, Authy, or 1Password — they cost nothing, work offline, and aren't tied to a carrier that can be socially engineered.
What to Do Now
If you're starting a new project:
- Pick one primary OAuth provider that matches your app's use case
- Set up a
userstable andoauth_identitiestable — store the stable ID, not email - Build a simple session table with token, user_id, expires_at — skip the JWT
- Add rate limits on every auth endpoint (use a middleware library, this isn't custom work)
- Add TOTP 2FA from day one if you're handling anything sensitive
- Test every edge case before you ship — auth bugs are not the place to learn in production
If you've already shipped auth and now you're sweating reading this — start with the highest-leverage fix: replace JWTs with DB-backed session tokens, and make sure you're storing stable provider IDs (not emails) as the source of truth. Migrate users gradually. Don't try to fix everything at once.
The goal isn't perfect auth on day one. It's auth that doesn't lock your users out, doesn't get bypassed by a botnet, and doesn't tie you to a vendor that hikes prices the moment you start growing.
Your auth system is the part of your stack that nobody notices when it works — and the only thing anyone will talk about when it breaks. Build it like you mean it.