Supabase Auth vs Lucia for Self-Hosted Auth: Real DX and Lock-In Test
You're starting a new project and you need auth. It has to work, it has to be secure, and you'd rather not spend the next two weeks on it. Two names keep coming up: Supabase Auth and Lucia. They're solving the same problem from completely opposite directions, and choosing wrong will cost you time later.
Supabase Auth is a managed auth service baked into the Supabase platform. Lucia is a TypeScript auth library that runs wherever your server runs and stores sessions however you want. The DX gap between them is real, and so is the lock-in risk.
What You'll Learn
- How Supabase Auth and Lucia differ architecturally, not just in features
- Which one gets you to a working login flow faster, and what corners get cut
- How session handling and token lifecycle compare in practice
- What actual lock-in looks like if you need to migrate later
- When each tool is the right call and when it becomes a liability
Prerequisites
This comparison assumes you're building a server-rendered or API-backed app in Node.js or a TypeScript-first framework like Next.js, SvelteKit, or Astro. You should be comfortable with cookies, JWTs, and the general idea of OAuth flows. No deep auth expertise needed, but this isn't a beginner's guide to login forms.
How Supabase Auth Works Under the Hood
Supabase Auth is a hosted fork of GoTrue, originally built by Netlify. When you spin up a Supabase project, you get a GoTrue instance running at https://your-project.supabase.co/auth/v1. Your app talks to it via the @supabase/supabase-js client or direct HTTP calls.
Auth state lives in Supabase's managed Postgres instance, in a dedicated auth schema. User records, sessions, and OAuth tokens all go there. Your application tables live in the public schema, and you link them to auth.users via foreign keys or database triggers.
The SDK handles a lot of the plumbing for you. Calling supabase.auth.signInWithPassword() sends credentials to GoTrue, which returns a JWT access token (short-lived) and a refresh token stored in a cookie or local storage depending on your setup. The JS client auto-refreshes the access token in the background.
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY
)
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'hunter2'
})
console.log(data.session.access_token) // JWT valid ~1 hour
Row Level Security (RLS) policies in Supabase use auth.uid() to scope data to the logged-in user. This is powerful when it works, but it means your authorization model is tightly coupled to Supabase's auth schema.
How Lucia Works Under the Hood
Lucia is a session management library, not an auth service. It does not handle password hashing, OAuth exchanges, or email verification on its own. What it does is give you a typed, framework-agnostic API for creating, validating, and invalidating sessions, backed by a database adapter you choose.
You bring your own user table. You bring your own password hashing (typically oslo/password with Argon2 or bcrypt). You write the OAuth callback handler using a helper like Arctic (built by the same author). Lucia writes session records to your database through an adapter β there are official adapters for Drizzle, Prisma, MongoDB, and raw SQL drivers.
import { Lucia } from 'lucia'
import { DrizzleSQLiteAdapter } from '@lucia-auth/adapter-drizzle'
import { db, sessionTable, userTable } from './db'
const adapter = new DrizzleSQLiteAdapter(db, sessionTable, userTable)
export const lucia = new Lucia(adapter, {
sessionCookie: {
attributes: {
secure: process.env.NODE_ENV === 'production'
}
},
getUserAttributes: (attributes) => ({
email: attributes.email
})
})
Session tokens are opaque strings stored in a cookie. No JWTs by default. When a request comes in, you call lucia.validateSession(sessionId), which hits your database and returns the session and user if valid. This is a deliberate choice: Lucia's author, Pilcrow, has written extensively about why opaque sessions are easier to revoke than JWTs in most application contexts.
Setup Speed: First Working Login in Under 30 Minutes
This is where Supabase Auth has a clear advantage. If you already have a Supabase project, adding email/password auth is genuinely about 15 minutes of work. Install the client, call signInWithPassword, read session from the response, and you're done for a prototype.
Lucia takes longer even for a simple username/password flow. You need to: create a session table, set up an adapter, implement the sign-up handler (password hashing, insert user, create session), implement the sign-in handler (look up user, verify password, create session), and write the session validation middleware. That's probably 45β90 minutes the first time, even following the docs closely.
The Lucia documentation is thorough and the examples are good, but the library is intentionally low-level. If you've read the Clerk vs Auth0 comparison for indie developers, you'll recognize the pattern: more control always means more setup time. Lucia sits even further toward the DIY end of that spectrum than Auth0.
For prototypes and MVPs where you want auth to not block you, Supabase Auth wins on raw setup speed. For production systems where you need to own every byte of the session lifecycle, that 45 minutes of Lucia setup pays dividends later.
Session Handling and Token Lifecycle
Supabase Auth uses JWTs. Access tokens expire in one hour by default (configurable). The JS client refreshes them automatically using the refresh token. On the server side, you validate the JWT against Supabase's JWKS endpoint or use the Supabase server client which does this for you.
The immediate problem with JWTs in Supabase is revocation. If a user changes their password or you need to force-logout a session, the existing access token remains valid until it expires. Supabase mitigates this with their auth.sessions table and the ability to revoke refresh tokens, but a compromised short-lived JWT can still be used for up to an hour. For most apps this is an acceptable tradeoff. For anything handling sensitive data, it's a real design consideration.
Lucia uses opaque session tokens stored in your database. Invalidation is instant: delete the row, the session is dead on the next request. You can invalidate all of a user's sessions with a single delete query. There's no token refresh cycle to manage because there's no token β just a session ID that maps to a database row with an expiry timestamp.
The tradeoff is a database read on every authenticated request. If you're running at scale, that adds up. Lucia's documentation suggests caching strategies for this, but you have to implement them. Supabase Auth sidesteps this at the cost of revocation granularity.
Provider Support and Social Login
Supabase Auth ships with built-in OAuth support for a long list of providers: Google, GitHub, GitLab, Discord, Twitter/X, Apple, Facebook, and more. You enable them in the Supabase dashboard, paste in your OAuth app credentials, and call supabase.auth.signInWithOAuth({ provider: 'github' }). The redirect URL, callback handling, and token exchange are all managed by GoTrue.
With Lucia, you use Arctic for OAuth. Arctic is a provider library that gives you typed methods for generating authorization URLs and exchanging codes for tokens, but you write the callback route yourself. Here's what a GitHub OAuth callback looks like:
import { github } from './auth' // Arctic GitHub instance
import { lucia } from './lucia'
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
// Validate state, exchange code for tokens
const tokens = await github.validateAuthorizationCode(code)
const githubUser = await fetchGitHubUser(tokens.accessToken)
// Upsert user in your DB, create Lucia session
const userId = await upsertUser(githubUser)
const session = await lucia.createSession(userId, {})
const sessionCookie = lucia.createSessionCookie(session.id)
return new Response(null, {
status: 302,
headers: {
Location: '/dashboard',
'Set-Cookie': sessionCookie.serialize()
}
})
}
This is more code, but you own every step. You decide what user data to persist, how to handle existing accounts with the same email, and what to do with the OAuth access token afterward. Supabase makes some of those decisions for you, which is great until you need to override them.
The Lock-In Question: What Happens When You Want to Leave?
This is the real question for any managed service. With Supabase Auth, your users live in the auth.users table in Supabase's managed Postgres. You can export that data β Supabase lets you run SQL against the auth schema β but passwords are hashed with bcrypt and the hash format is GoTrue-specific. Most other auth systems use the same bcrypt format, so a direct import is usually possible, but it requires testing.
The bigger lock-in is behavioral. If you've built RLS policies that use auth.uid() throughout your database, those policies are coupled to GoTrue's JWT claims. Migrating to a different auth provider means rewriting your RLS policies and potentially restructuring how user identity flows through your app. For a large codebase, that's a multi-day effort.
This is worth reading alongside the Supabase vs PlanetScale production limits comparison β the database coupling compounds if you're also relying on Supabase for storage and realtime. Auth lock-in doesn't exist in isolation; it's part of a broader platform dependency you're taking on.
Lucia has no lock-in problem in the traditional sense. It's a library running in your process, talking to your database. Switching to a different session strategy means replacing the Lucia import, not migrating a cloud service. Your user table is yours, your session table is yours, and your hashing logic is yours. The exit cost is essentially a refactor, not a migration.
The honest counterpoint: if you self-host Supabase (which is fully supported via Docker), the lock-in concern shifts. You're running GoTrue on your own infrastructure, and the auth schema is just a Postgres schema you control. Self-hosting changes the calculus significantly, though it adds operational overhead. The true cost of self-hosting on Hetzner vs DigitalOcean is worth factoring in if you're evaluating that path.
Common Pitfalls with Both Approaches
Supabase Auth pitfalls
RLS misconfiguration is silent and dangerous. If you forget to enable RLS on a table, all authenticated users can read all rows. Supabase won't warn you by default. Always audit your policies in production before going live.
The anon key is public, the service role key is not. Many tutorials use the service role key in client-side code for convenience during development. That key bypasses RLS entirely. Never expose it in a browser environment.
JWT expiry causes user-visible errors if you don't handle refresh properly. On server-rendered routes, you need to use the server client (@supabase/ssr) and configure cookie-based session storage carefully. The client-side auto-refresh doesn't help you on the server.
Lucia pitfalls
Forgetting to validate sessions on every protected route. Lucia doesn't provide middleware automatically. You have to call lucia.validateSession() in your framework's request handler or middleware. Missing one route leaves it unprotected.
Not extending session expiry on activity. By default, sessions expire at a fixed time. For a good UX, you typically want to extend the session on each request if it's more than halfway through its lifetime. Lucia supports this but you have to implement the logic yourself.
Password reset flow is entirely your responsibility. You need to generate a secure token, store it with an expiry, send the email (via a service like those compared in the Resend vs Postmark transactional email comparison), and validate it on the reset form. None of this is provided. Budget time for it.
Lucia v3 is a significant API break from v2. If you're reading older tutorials or Stack Overflow answers, check the version. The adapter API and several core methods changed between major versions.
Wrapping Up: Which One Should You Actually Pick?
There's no universally correct answer, but the decision is usually simpler than it looks once you're honest about your constraints.
Pick Supabase Auth if: you're already on Supabase, you need social login working in a day, you're building a consumer product where fast iteration matters more than owning every session detail, or your team doesn't want to maintain auth infrastructure. The managed experience is genuinely good and the free tier is generous enough to validate an idea before you hit any limits.
Pick Lucia if: you're self-hosting everything, you need precise session revocation, you're building on a database or ORM that Supabase doesn't support, or you have compliance requirements that mean you can't send user data to a third-party auth service. Lucia is also the right call if you want to deeply understand your auth layer β the library forces you to, in the best way.
If you're evaluating the broader infrastructure picture β and you should be β this kind of platform dependency question comes up in other tool choices too. The Trigger.dev vs Inngest background job comparison covers similar managed-vs-library tradeoffs for a different part of the stack.
Concrete next steps:
- If you're leaning toward Supabase Auth, read the RLS documentation fully before writing your first policy β not after.
- If you're leaning toward Lucia, clone the official SvelteKit or Next.js example and run it locally before starting your project. The docs are good but seeing a working app is faster.
- Whatever you choose, document your session invalidation strategy now, not when you need to force-logout a compromised account.
- If you're on Supabase, test your RLS policies with a second user account before launch. It's the fastest way to catch data leaks.
- If lock-in is a genuine concern, prototype the migration path on a branch before committing to Supabase Auth in production.
Frequently Asked Questions
Can I use Lucia with a Supabase Postgres database?
Yes. Lucia has a Drizzle adapter and a raw SQL adapter, both of which work fine with a Postgres connection string pointing at your Supabase database. You lose the tight GoTrue integration and RLS auth helper, but your session table sits right next to your application data.
What happens to Supabase Auth users if I cancel my Supabase subscription?
Supabase gives you access to export your data before a project is paused or deleted. User records in the auth schema can be exported via SQL, and bcrypt password hashes are portable to most auth systems. The harder part is rewriting any RLS policies that depend on GoTrue's JWT claims.
Is Lucia production-ready for a high-traffic application?
Lucia is used in production by many teams, but every authenticated request requires a database read to validate the session. At high traffic volumes you'll want to add a caching layer in front of that query, which Lucia's docs discuss but don't implement for you. It's production-ready if you plan for that read overhead.
Does Supabase Auth support multi-factor authentication?
Yes, Supabase Auth supports TOTP-based MFA (authenticator apps like Google Authenticator) and is expanding MFA options over time. You enable it per-user via the SDK and the dashboard, and it hooks into the existing session flow without additional infrastructure.
How long does it take to migrate from Supabase Auth to Lucia?
For a small app with a handful of routes, expect one to three days of focused work: exporting users, creating the Lucia session table, rewriting sign-in and session validation handlers, and updating protected routes. The main effort is replacing RLS policies with application-level authorization checks if you relied heavily on auth.uid() in your database policies.
π€ Share this article
Sign in to saveRelated Articles
Comments (0)
No comments yet. Be the first!