How to Set Up Authentication: Picking an Approach That Won't Trap You
Authentication is the decision most likely to be made in the first afternoon of a project and regretted in the second year. Not because the options are bad — most of them work — but because auth quietly becomes load-bearing. Your user IDs end up as foreign keys in every table. Your session model determines whether you can server-render. Your provider's idea of a "user" determines what you can charge for. Swapping it later means a data migration where the primary key of every row is the thing you're changing.
So the useful question is not "which auth is best." It's "which of these am I willing to still be running in three years, and what does leaving cost?" This guide covers the four approaches that actually get used, what each one really costs, and the specific ways each one goes wrong.
Choose on exit cost, not setup time
Every option here gets you a working login in under a day. That is not the differentiator, and optimizing for it is how people end up trapped. The questions that actually separate these approaches are about what happens at month 18.
- ›Do you own the user table? If your users live only in a vendor's database, every future feature that joins against a user is an API call instead of a JOIN.
- ›What does the pricing curve look like at 10,000 monthly actives? Several managed providers are free until they are abruptly not.
- ›Do you need sessions server-side? If you server-render, token-only auth (common in SPA-first providers) means either a round trip per request or a second session layer.
- ›Is enterprise SSO on the roadmap? SAML support is the single biggest reason teams migrate off self-rolled auth, and retrofitting it is far worse than starting with it.
- ›How many identity providers do you actually need? "Sign in with Google" is an afternoon. Google + Apple + Microsoft + magic link + SAML is a product surface.
The approaches
Backend-as-a-service auth (Supabase, Firebase, Appwrite, Nhost, PocketBase)
// best for: Teams who want auth and a database from the same vendor, and who plan to use row-level authorization.
- Supabase
- Firebase
- Appwrite
- Nhost
- PocketBase
- Convex
- Xano
The distinguishing feature here isn't the login flow — it's that the auth system and the database understand each other. In Supabase, the signed-in user's ID is available inside Postgres as `auth.uid()`, which means your authorization rules live in the database as row-level security policies rather than in application middleware. Firebase does the equivalent through security rules on Firestore. That coupling is genuinely powerful: it's the difference between every query being safe by default and every query being safe if the developer remembered.
The cost is that the coupling runs both ways. Supabase Auth writes into an `auth.users` table you don't control the schema of, and you're expected to keep your own `profiles` table in sync with it via a trigger. Firebase Auth doesn't give you a queryable user table at all — listing users means paginating an admin API, so "show me all users who signed up last week" becomes a job rather than a query.
-- Supabase: authorization lives in the database, not middleware.
-- A missed `.eq('user_id', ...)` in application code can't leak rows.
create policy "own rows only"
on public.documents
for select
using (auth.uid() = user_id);The gotcha
Supabase's `auth.users` table is in a schema you can't migrate, so profile data goes in your own table kept in sync by a trigger on insert. Teams routinely skip the trigger, then discover months later that users who signed up during a window have no profile row and every join silently drops them.
Dedicated managed auth (Auth0, Clerk, WorkOS, Stytch)
// best for: Products that will need enterprise SSO, or teams who want auth to be someone else's on-call rotation.
- Auth0
- Clerk
- WorkOS
- Stytch
These do one thing, and the thing they do includes the parts that are genuinely hard: SAML, SCIM provisioning, MFA enrollment, device management, breach detection, and the compliance paperwork enterprise buyers ask for. If a customer is ever going to say "we need Okta login," starting here saves a migration that otherwise takes a quarter.
Clerk and Auth0 differ meaningfully in shape. Clerk ships React components and is opinionated about the frontend, which makes it fast if you're building a React app and awkward if you aren't. Auth0 is protocol-first — it hands you OIDC and gets out of the way, which is why it shows up in polyglot stacks. WorkOS is narrower on purpose: it's mostly the enterprise layer, and it expects you to already have your own auth for self-serve users.
What you're really buying is the identity provider integrations staying working. OAuth providers change their consent screens and token formats, and someone else absorbing that is worth real money.
The gotcha
Pricing on these is per monthly-active-user and steps hard at tier boundaries — Auth0's jump from free to paid has ended more side projects than any technical limitation. Model your cost at 10x your current users before committing, and check specifically whether machine-to-machine tokens count as users, because on some plans they do.
Framework-native (NextAuth/Auth.js, Django auth, Rails Devise, Laravel Breeze)
// best for: Server-rendered apps where you want to own the user table without writing crypto.
- Next.js
- Django
- Ruby on Rails
- Laravel
- Express.js
- NestJS
- SvelteKit
This is the most underrated option for anyone already committed to a full-stack framework. Django's auth has been in production since 2005, handles password hashing and session management correctly, and stores users in your database where you can query them. Rails' Devise is similar. Auth.js (formerly NextAuth) gives you OAuth provider handling and session management while leaving the user records in your own Postgres.
The big structural advantage is that sessions are server-side by default. When your authorization check is a database lookup rather than a JWT signature verification, revoking access is immediate — you delete the session row. With stateless tokens, a compromised token stays valid until it expires, and "log out everywhere" requires a denylist, which is a session store with extra steps.
You're taking on more than the managed options: password reset emails, rate limiting on login, and MFA are yours to build or bolt on.
# Django: the user is a row in your database, so this is a JOIN,
# not a paginated call to a vendor's admin API.
recent = User.objects.filter(
date_joined__gte=timezone.now() - timedelta(days=7)
).select_related("profile").annotate(order_count=Count("orders"))The gotcha
Auth.js in database-session mode and JWT mode behave differently in ways that surface late. JWT mode can't revoke a session before expiry, and the default 30-day maxAge means a user who "logs out" on a shared machine may still have a valid token. Pick database sessions unless you have a specific reason not to.
Rolling it yourself
// best for: Almost nobody, and specifically not because the alternatives seemed expensive.
- Express.js
- FastAPI
- Flask
- Go
- Rust
Worth stating plainly, because it's still common: the password hashing is the easy part. Use argon2id or bcrypt and that piece is done in an hour. What sinks self-rolled auth is everything around it — timing-safe comparison on token lookup, session fixation on privilege change, secure cookie flags that differ across environments, password reset tokens that need single-use semantics and short expiry, rate limiting that can't be bypassed by rotating IPs, and email enumeration through response-time differences on the login endpoint.
There is one legitimate case: you have an unusual identity model that doesn't fit a provider's idea of a user — per-tenant identity, hardware-bound credentials, or an existing identity system you must federate with. That's a real reason. "Auth0 got expensive" is not, because your engineering time costs more than the tier you were trying to avoid.
The gotcha
Password reset is where self-rolled auth actually fails, not login. A reset token that isn't invalidated on use, or that's compared with `==` instead of a constant-time comparison, is a full account takeover — and it won't show up in any test you thought to write.
Common pitfalls
Using the provider's user ID as your primary key
It feels natural to make `auth0|abc123` the primary key on your users table. It welds you to that vendor: migrating means rewriting every foreign key in the database. Use your own UUID as the primary key and store the provider ID as an indexed, nullable column. That one decision is the difference between a migration taking a week and taking a quarter.
Trusting the JWT without verifying the signature
Decoding a JWT and reading its claims is not authentication — anyone can craft a token with any claims they like. You have to verify the signature against the issuer's public keys, and check `iss`, `aud`, and `exp`. Libraries that expose both `decode()` and `verify()` are responsible for a genuinely alarming number of production vulnerabilities, because the wrong one is shorter to type.
Checking authorization only on the client
Hiding the admin button is not access control. Every protected endpoint has to re-check on the server, on every request, because the client is a suggestion. This is the single most common finding in security reviews of vibecoded apps: the UI is gated correctly and the API is wide open.
No plan for email change
If email is the account identifier and a user changes it, what happens to their OAuth links, their pending invites, and their audit log? Most teams discover they have no answer the first time a customer asks. Decide early whether email is an identifier or an attribute.
Questions people actually ask
Is Supabase Auth good enough for production?
Yes, and it's used in production by companies well past the point where 'is it good enough' is the question. The real consideration isn't reliability, it's that your users live in a table you don't control the schema of, and that enterprise SSO is a paid add-on. If neither is a problem for your product, it's a strong default.
JWT or session cookies?
Session cookies unless you have a specific reason otherwise. The main argument for JWTs — avoiding a database lookup per request — is solving a problem most applications don't have, and it costs you the ability to revoke access immediately. If you're already hitting the database to load the user's data anyway, the session lookup is free.
How much should I budget for auth?
For managed providers, model it at 10x your current monthly actives and check the tier boundaries — the curve is stepped, not linear. For self-hosted or framework-native, the cost is engineering time on password reset, rate limiting, and MFA, which is typically one to three weeks of work you'll do once and then maintain.
Can I migrate off my auth provider later?
Yes, if you own the user table and used your own primary keys. Password hashes are the sticking point: most providers won't export them, so migration usually means a forced reset or a dual-read period where you verify against the old provider once and rehash locally. Plan for the dual-read approach — forced resets lose users.