← Blog
Guides13 min read

Self-Hosted Authentication in Next.js: A Complete Guide

What self-hosted authentication means, what it costs to run, and the Auth.js and Prisma code that ships in a free Next.js starter kit.

Self-hosted authentication means the user record and the session live in a database you operate, and your app never asks a third party who is signed in. In a Next.js application that is not a large architecture. It is a library, a database adapter and a table.

Here is the entire idea in five lines. Anywhere on the server, auth() reads the session, and the user it points at is a row you can join against:

import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"

const session = await auth()
const user = await prisma.user.findUnique({
  where: { id: session?.user.id },
  include: { subscription: true, projects: true },
})

That include is the part a hosted provider cannot hand you. When identity lives in someone else's system, the subscription and the projects are here and the user is there, so every question that spans the two turns into an API call plus a join written by hand.

This guide covers what that choice buys, what it costs to run, and the code behind it. Every snippet is from the free open source starter kit this site is built on, running in production on Next.js 16 and Auth.js v5.

What self-hosted authentication actually means

The phrase gets used for two different things, and the difference decides most of the work.

The first is running an identity server: a separate service such as Keycloak, Ory, FusionAuth, Zitadel or Authentik, deployed on your own infrastructure, which your applications talk to over OIDC. You own the data and the uptime. You also own a second service, its database, its upgrades and its configuration language.

The second is keeping authentication inside your application as a library: Auth.js or Better Auth in the Node ecosystem. There is no extra service. Sessions and users are tables in the database your app already has, and sign-in is a route in your app.

Both are self-hosted auth in the sense that matters legally and commercially, which is that no vendor holds your users. They are very different in operations, and most articles on this topic only describe the first one, because the companies writing them sell the first one.

What self-hosting does not mean is writing cryptography. Nobody sensible implements OAuth flows, token signing or password hashing by hand. A library does the protocol work. Self-hosted refers to where the data sits and who makes the decision, not to how much of the wheel you reinvent.

Self-hosted authentication in Next.js: the browser reaches your app, your app reads the session and the user row from your own database, with no vendor call in the request path

What you get when the user table is yours

One database, one join

This is the benefit that shows up daily and gets mentioned least. Your users, their subscriptions, their projects and their audit trail are in the same Postgres. "Show me every account on the Pro plan that has not created a project in thirty days" is one query. With identity behind an API it is a paginated fetch, an in-memory join and a cache you now maintain.

Cost stops tracking your user count

Managed providers generally price per monthly active user. That is a bill that grows exactly as the product succeeds, charged on the act of signing in. Self-hosted auth costs a row in a table and the compute you were already paying for. The maintenance is real, and we get to it below, but it is roughly flat as you grow instead of linear on your best month.

There is no migration waiting for you

Authentication is the hardest thing to change after launch because it touches every request and holds every user. When it is a library in your repository, changing your mind is a refactor you schedule. When it is a vendor, changing your mind is a migration project with a password problem in the middle of it, since hashes are usually not exportable in a form another system can verify.

Data requests you can actually answer

Under GDPR a user can ask for their data, and ask you to delete it. When the account is a row next to everything else, export and deletion are queries you write and a cascade you already declared. When identity is rented, part of the answer lives in a third party's system, and every request becomes a coordination task with a subprocessor.

Identity server or library: which shape fits

Neither shape is better in the abstract. They answer different questions, and picking the wrong one is where teams lose months.

Identity server (Keycloak, Ory, FusionAuth, Zitadel)Auth library in your app (Auth.js, Better Auth)
What you deploya second service and its database, next to your appnothing extra: your app and its database
Where users livethe identity server's schemayour schema, next to billing and product data
Protocolsfull OIDC and often SAML federationOAuth clients, email, passwords, TOTP on top
Multiple apps sharing one loginthe reason it existspossible, but you are building it yourself
Operational surfaceupgrades, backups, availability of a service that can lock everyone outthe app you already operate
Enterprise SSO for large customersusually included or bought as a modulea real project, or a separate vendor
Time to first sign-inhours to days, plus a Docker deploymentminutes

The short rule: if several applications must share one login, or if enterprise federation is on your roadmap this year, run an identity server. If you are one application that needs users, sessions and roles, a library keeps the whole system smaller, and smaller is what a small team can actually operate.

The rest of this guide follows the second path, because it is the one a Next.js SaaS usually needs.

What it looks like in code

The database is the adapter

One adapter line puts users, accounts, sessions and verification tokens in your schema. The Prisma adapter for Auth.js writes them for you:

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  // JWT sessions are required for the credentials provider to work.
  // OAuth and magic links still persist users and accounts through the adapter.
  session: { strategy: "jwt" },
  providers: [Google, GitHub, magicLinkProvider, credentialsProvider],
})

The trade-off in that comment is worth understanding before you copy it. Database sessions are a row you can delete, which makes revocation trivial and costs a query per request. JWT sessions are a signed cookie, which costs nothing to read and cannot be deleted, so revocation needs the extra step shown further down. Email and password sign-in forces the JWT strategy, so most real applications end up there.

Providers are a list, and features are conditional

Sign-in methods are entries in an array, which means a deployment can ship with the ones it has configured and hide the rest:

providers: [
  Google({ clientId: ..., allowDangerousEmailAccountLinking: true }),
  GitHub({ clientId: ..., allowDangerousEmailAccountLinking: true }),
  // Magic links need an email provider. Without a key, the form is hidden
  // and the provider is never registered.
  ...(process.env.RESEND_API_KEY ? [magicLinkProvider] : []),
  credentialsProvider,
]

That allowDangerousEmailAccountLinking flag has an alarming name for a good reason. It lets someone who signed up with Google sign in later with GitHub on the same address, which is safe only because both providers verify email ownership. Turn it on for a provider that does not verify emails and you have handed over account takeover by signup.

Protecting routes in Next.js 16

In Next.js 16 the middleware file convention was renamed to proxy. The auth wrapper works the same way: it runs before the route and can redirect.

const PROTECTED_ROUTES = ["/dashboard", "/api/checkout", "/api/billing"]
const ADMIN_ROUTES = ["/admin", "/api/admin"]

export default auth((req) => {
  const { pathname } = req.nextUrl
  const isLoggedIn = !!req.auth
  if (ADMIN_ROUTES.some((r) => pathname.startsWith(r))) {
    if (!isLoggedIn) return NextResponse.redirect(new URL("/login", req.nextUrl))
    if (req.auth?.user?.role !== "ADMIN") {
      return NextResponse.redirect(new URL("/dashboard", req.nextUrl))
    }
  }
  // ...
})

Here is the mistake we shipped, because it is the most useful part of this section. That list started life as an allowlist of public paths, with everything else treated as private. It reads as the safer default and it is not. Every path the list did not know about looked private, so a typo like /doc instead of /docs bounced an anonymous visitor to /login, and our 404 page was unreachable for anyone not signed in. We inverted it to the denylist above.

Loosening that file opens nothing, and the reason is the rule that matters more than the file itself: the proxy is defence in depth, never the only gate. Each private area checks its own session server-side, the dashboard and admin layouts call auth() and redirect, and every private API route returns 401 on its own. Next.js documentation makes the same point about server functions, which are not separate routes and can silently fall outside a matcher after a refactor.

Revoking a session that has no row

JWT sessions cannot be deleted, so a password reset would leave every stolen session alive. The fix is a version number on the user and a check in the token callback:

model User {
  // Bumped on password reset. Tokens carrying an older version are rejected,
  // which revokes every other session.
  sessionVersion Int @default(1)
}
// Re-verified at most once a minute, because the proxy runs auth() on
// nearly every request. Fails OPEN on a database error: for this threat
// model, availability beats strictness.
if (token.sub && now - (token.svAt ?? 0) > 60_000) {
  const dbUser = await prisma.user.findUnique({
    where: { id: token.sub },
    select: { sessionVersion: true },
  })
  if (!dbUser || dbUser.sessionVersion !== token.sv) return null
  token.svAt = now
}

Two decisions are visible there, and both are the kind a hosted provider makes for you without telling you. Revocation is eventual, within about sixty seconds, because checking on every request would put a database call in front of every page. And the check fails open, so a database blip logs nobody out. If your threat model says otherwise, that is the line to change.

What running it yourself actually costs

This is the section the vendor guides skip. Self-hosted authentication is not free, it is unpriced, and the bill arrives as maintenance.

Password storage. Hashing with bcrypt at cost 12, which is roughly 100ms per verification, above the minimum work factor of 10 that the OWASP password storage guidance recommends. Also the detail everyone meets eventually: bcrypt only considers the first 72 bytes, so longer input has to be rejected rather than silently truncated.

Not leaking which emails exist. Every failure mode of sign-in returns the same answer. Unknown address, OAuth-only account, wrong password: one identical null, so the form cannot be used to enumerate your users.

Rate limiting. The kit ships a fixed-window in-memory limiter, five attempts per fifteen minutes, and says plainly what it is: on serverless each instance has its own memory, so it is a speed bump rather than a wall, and bcrypt's cost is the real brake. A shared store such as Redis is the upgrade when you need a guarantee.

Deliverability. Magic links, verification and password resets are transactional email. If it lands in spam, your login is broken for that user, and you are now responsible for a domain's sending reputation.

Security updates, which is the honest one. On 3 August 2026 a critical advisory landed in @auth/core: a malformed Authorization: Bearer header made getToken() throw an uncaught exception. We patched it the same day by moving to next-auth@5.0.0-beta.32 and @auth/prisma-adapter@2.11.3, which pin a single copy of @auth/core@0.41.3 between them. That is the deal in one sentence. When you self-host, an advisory in your auth library is your pager, not somebody else's. It is a small duty if you have dependency updates and a test suite, and a genuinely bad one if you do not.

And the gap worth naming. TOTP two-factor sits on top of a user table you own and is a well-understood addition. SAML and SCIM are not: they are a real project, they are what managed providers charge the most for, and they are the usual reason a team keeps its own auth and buys enterprise federation separately when a large customer finally asks.

When you should not do this

A guide that only argues one way is marketing, so here is the other side. Self-hosted authentication is the wrong call, and renting is the better engineering decision, when:

  • Enterprise SSO is the deal you are closing this quarter. SAML, SCIM provisioning and per-tenant identity settings are months of work. Buying them is the right call.
  • Compliance certification is on the critical path. Some auditors are simply faster to satisfy when identity is a certified subprocessor.
  • Nobody is on call. Self-hosted auth assumes someone applies updates. If that person does not exist, a vendor's security team is a real advantage.
  • You want features you have no time to build. Passkeys, device fingerprinting, risk scoring and bot detection arrive for free from a good provider.

The tell is the shape of the workload, not the ideology. If authentication is a commodity you never want to think about and you can afford the per-user price forever, rent it.

Frequently asked questions

What does self-hosted authentication mean?

It means the user record and the session live in a database you operate, and your application decides who is signed in without asking an external service. It does not require you to write cryptography, invent a session format or run your own servers: a library handles the protocol work, and self-hosted refers to where the data sits and who owns the decision.

Do I need to run a separate authentication server?

No. That is one of two shapes. An identity server is a second service you deploy and operate, and it earns its keep when several applications share one login or when you need enterprise federation. If you have a single application, an auth library inside it keeps the user table in the same database as the rest of your data and adds no service to run.

Is self-hosted authentication less secure than a managed provider?

It is differently secure. A managed provider has a security team and a bug bounty, and a breach on their side reaches everyone at once. Self-hosting means the attack surface is only yours, and so is the patching duty: when an advisory lands in your auth library, nobody upgrades it for you. The honest answer depends on whether you will actually apply updates.

Can I do SSO and MFA if I self-host?

Yes, with different amounts of work. TOTP two-factor sits on top of your own user table and is a well-trodden path. SAML and SCIM, the enterprise flavours of single sign-on, are a real project: they are the part managed providers price highest, and the most common reason a team keeps its own auth and buys federation separately when a large customer finally asks.

How much does self-hosted authentication cost?

There is no per-user fee, so the direct cost is the database row and the compute you already pay for. The real cost is maintenance: password storage, rate limiting, session revocation, email deliverability and security updates. That bill is roughly flat as you grow, which is the opposite shape of per-active-user pricing.

Can I move off a managed provider later?

Yes, and how easily depends on whether your provider hands back the password hashes. Some do it from the dashboard in minutes, others only through a support request, and that difference is the practical measure of lock-in. When the hashes are out of reach the standard path is a gradual migration: run both, verify against the old provider on first sign-in, write your own record, and let the population move over as people return. Choosing deliberately at the start is cheaper than discovering the constraint at ten thousand users.

Try it before you build it

Everything above is running, and you can check it rather than take our word for it. The live demo signs you in with Google, GitHub or a one-click shared account, and what you get back is a row in a database rather than a token from a vendor. Then read the authentication setup guide for the environment variables behind every provider, including the magic link and password flows a shared demo cannot show you, or the getting started guide to go from clone to a running app in about ten minutes.

The kit is MIT licensed and free, with no gated auth tier, because self-hosted authentication is not the feature you should be paying for. Clone it, delete what you do not need, and keep your users where the rest of your data already is.