# What is the best authentication solution for Next.js?

> There is no single best authentication solution for Next.js; the right one depends on your situation. Clerk and Supabase Auth get a prototype or a social-login app running quickly. WorkOS, Auth0 and Clerk suit B2B apps that need organizations and SAML. Better Auth and Auth.js keep users in your own database, and loginos is a hosted OpenID Connect option for many domains that share one user pool.

You are starting a Next.js app, or moving one to the App Router, and every list you find names a different winner. There isn’t one. The right choice depends on who signs in, what they sign in with, whether business customers need SAML, how many domains you run, and who should hold the user records.

Below is a map of the options, a guide by situation, and the App Router details that apply to all of them.

## What are the main options?

Next.js authentication comes in two kinds. A **library** runs inside your app and stores users in your database. A **hosted service** runs the sign-in pages and stores the accounts for you, and your app talks to it through an SDK or OpenID Connect.

| Option | Kind | Where accounts live | Known for |
| --- | --- | --- | --- |
| Auth.js (NextAuth.js) | Open-source library | Your database, if you add one | Many OAuth providers, sessions without a database |
| Better Auth | Open-source library (MIT) | Your database | Plugins for organizations, two-factor, passkeys and SSO |
| Clerk | Hosted | Clerk | Prebuilt UI components, organizations |
| Supabase Auth | Hosted, or self-hosted | Your Supabase Postgres database | Row Level Security, magic links, phone sign-in |
| Firebase Auth | Hosted by Google | Firebase | Mobile SDKs, anonymous sign-in |
| Auth0 | Hosted by Okta | Auth0 | Enterprise connections, Organizations |
| WorkOS AuthKit | Hosted | WorkOS | SAML and OIDC single sign-on, Directory Sync |
| Kinde | Hosted | Kinde | Organizations, permissions, enterprise connections |
| Your own code | Small libraries | Your database | Full control and full responsibility |

Auth.js, formerly NextAuth.js, became part of Better Auth in September 2025, and its maintainers now recommend Better Auth for new projects. If you write your own, pick maintained pieces: the Next.js docs point to `jose` and `iron-session` for sessions, and `openid-client` handles OpenID Connect. Lucia and the Arctic OAuth library are deprecated.

## Which one fits your situation?

### A quick prototype

Start with what you already use. Supabase Auth comes with Supabase. Firebase Auth comes with Firebase, but it signs people in in the browser, so server components need Admin SDK session cookies or `initializeServerApp`. For prebuilt screens on a fresh app, Clerk’s `npx -y clerk@latest init` writes your keys and the proxy file. With no vendor at all, Better Auth or Auth.js with one GitHub provider needs a short config file.

### B2C with social logins

Social logins are buttons like “Sign in with Google”, “Apple” or “GitHub”. Clerk, Supabase Auth, Firebase Auth, Auth0 and Kinde have them built in, and you turn each provider on in a dashboard. With Better Auth or Auth.js, you register an OAuth app with each provider and keep its secret in your environment. Also check what happens when two accounts share an email address. Auth.js, for example, doesn’t merge them unless you set `allowDangerousEmailAccountLinking`.

### B2B with organizations and SAML

Business customers want their staff to sign in through the company’s identity provider, such as Okta or Microsoft Entra ID. That takes SAML (Security Assertion Markup Language), the XML-based single sign-on standard that corporate identity systems speak. Larger customers also ask for SCIM (System for Cross-domain Identity Management), which adds and removes users from their directory.

WorkOS is built around single sign-on and Directory Sync, which keeps users in step with the customer’s directory. Auth0, Clerk and Kinde pair organizations with SAML enterprise connections. Better Auth does the same inside your app with its organization and SSO plugins, and Supabase Auth supports SAML 2.0 on its Pro plan and above. Enterprise features often depend on the plan, so check each vendor’s pricing page.

### Many domains or brands

Cookies belong to one domain, so `brand-a.com` and `brand-b.com` can’t share a session. Each domain needs its own session, created after one central sign-in. Clerk does this with satellite domains, which need a paid plan in production, and its docs advise against passkeys when sessions are shared across different domains. Auth0 lets one tenant serve several branded login domains on Enterprise plans. With Auth.js, every domain runs the same setup, as [NextAuth with multiple domains](https://loginos.io/answers/nextauth-multiple-domains.md) explains. loginos is built for this case, as shown below.

### Owning your user data

If user records must stay in your own database, use a library: Better Auth, or Auth.js with a database adapter. Supabase Auth keeps users in your project’s Postgres database, and Supabase is open source, so you can self-host it. For a complete identity server on your own machines, see [simpler alternatives to Keycloak](https://loginos.io/answers/keycloak-alternatives.md). Hosted services keep the accounts on their side, and your app keeps its own user table keyed by their stable user id.

## What changes with the App Router?

The App Router gives authentication three places to run:

- **Route handlers** (`app/**/route.ts`) receive the OAuth callback. They can read the query string, call the token endpoint and set cookies.
- **Proxy** (`proxy.ts`) runs before matching requests. Next.js 16 renamed `middleware.ts` to `proxy.ts` and runs it on Node.js by default. Use it for optimistic checks, like redirecting visitors without a session cookie.
- **Server components** read the session with `await cookies()`, which is async since Next.js 15, but they can’t set or delete cookies. Sign-in and sign-out belong in a route handler or a server function.

Check the session again close to the data, for example in a `verifySession()` function that every query calls, because layouts don’t re-render on every navigation. The [Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication) shows the pattern. Each option fills the three places differently:

| Option | Route handler | Proxy | Session in a server component |
| --- | --- | --- | --- |
| Auth.js | `app/api/auth/[...nextauth]/route.ts` | `export { auth as proxy }`, optional | `await auth()` |
| Better Auth | `app/api/auth/[...all]/route.ts` | `getSessionCookie()` for a quick check | `auth.api.getSession({ headers: await headers() })` |
| Clerk | None needed | `clerkMiddleware()`, required | `await auth()` |
| Supabase Auth | `app/auth/callback/route.ts` | Refreshes tokens with `getClaims()` | `supabase.auth.getClaims()` |
| Auth0 | Mounted by the proxy under `/auth/*` | `auth0.middleware(request)` | `auth0.getSession()` |
| WorkOS | `app/callback/route.ts` with `handleAuth()` | `authkitProxy()` | `withAuth()` |
| Kinde | `app/api/auth/[kindeAuth]/route.ts` | `withAuth` | `getKindeServerSession()` |

## Where loginos fits

loginos is a hosted OpenID Connect provider for products on many domains. OpenID Connect (OIDC) is the standard layer on top of OAuth 2.0 that tells your app who signed in. You create a platform in the [developer console](https://loginos.io/developer) and add your domains, and all of them share one pool of people.

**loginos fits** when several domains or brands should share users, with no limit on domains. Passkeys are built in and belong to loginos.io, so they work on every domain without WebAuthn code. Roles and permissions come in every token, and existing users can [link their accounts](https://loginos.io/docs/link-accounts.md) instead of migrating. It is standard OIDC, and loginos has no paid plans today.

**loginos doesn’t fit** when you need social logins like “Sign in with Google”, or SAML and organizations for B2B customers. It also has no SMS or TOTP codes, no login page on your own domain and no self-hosting, and it doesn’t verify email addresses yet.

In the App Router, the whole integration is two route handlers. The first sends people to loginos with a `state` and a PKCE challenge. PKCE (Proof Key for Code Exchange) is a one-time secret your server keeps, so a stolen code can’t be redeemed without it. The second route checks `state` and exchanges the code on your server.

```ts title="Sign-in route"
// app/api/auth/login/single/route.ts
import { createHash, randomBytes } from "node:crypto";
import { NextResponse, type NextRequest } from "next/server";

const LOGINOS = "https://loginos.io";
const b64url = (b: Buffer) => b.toString("base64url");

export function GET(req: NextRequest) {
  const state = b64url(randomBytes(16));
  const verifier = b64url(randomBytes(32));
  const url = new URL("/oauth/authorize", LOGINOS);
  url.search = new URLSearchParams({
    client_id: process.env.LOGINOS_APP_KEY!,
    redirect_uri: `${req.nextUrl.origin}/api/auth/login/single/callback`,
    response_type: "code",
    scope: "openid profile email",
    state,
    code_challenge: b64url(createHash("sha256").update(verifier).digest()),
    code_challenge_method: "S256",
  }).toString();
  const res = NextResponse.redirect(url);
  res.cookies.set("lo_flow", `${state}.${verifier}`, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 });
  return res;
}
```
```ts title="Callback route"
// app/api/auth/login/single/callback/route.ts
import { NextResponse, type NextRequest } from "next/server";
import { startYourSession } from "@/lib/session"; // your own

const LOGINOS = "https://loginos.io";

export async function GET(req: NextRequest) {
  const [state, verifier] = (req.cookies.get("lo_flow")?.value ?? "").split(".");
  if (!state || req.nextUrl.searchParams.get("state") !== state)
    return new Response("Sign-in expired, try again.", { status: 400 });

  const res = await fetch(`${LOGINOS}/oauth/token`, {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: req.nextUrl.searchParams.get("code") ?? "",
      redirect_uri: `${req.nextUrl.origin}/api/auth/login/single/callback`,
      code_verifier: verifier,
      client_id: process.env.LOGINOS_APP_KEY!,
      client_secret: process.env.LOGINOS_APP_SECRET!,
    }),
  });
  if (!res.ok) return new Response("Sign-in failed.", { status: 401 });

  // { sub, preferred_username, name, picture, email, roles, permissions }
  const { user } = await res.json();
  const done = NextResponse.redirect(new URL("/", req.url));
  done.cookies.delete("lo_flow");
  await startYourSession(done, user); // sets your own HttpOnly session cookie
  return done;
}
```

The callback path is the same on every domain, so these two files work on all of them. Your server components then read your own session cookie with `await cookies()` and never call loginos. The [quickstart](https://loginos.io/docs/quickstart.md) adds sign-out, and the [OpenID Connect reference](https://loginos.io/docs/oidc.md#claims) lists every claim in `user`.

Auth.js works with loginos too, as a standard OIDC provider next to the usual `app/api/auth/[...nextauth]/route.ts`:

```ts title="auth.ts"
import NextAuth from "next-auth";

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    {
      id: "loginos",
      name: "loginos",
      type: "oidc",
      issuer: "https://loginos.io",
      clientId: process.env.LOGINOS_APP_KEY,
      clientSecret: process.env.LOGINOS_APP_SECRET,
      checks: ["pkce", "state"],
    },
  ],
});
```

Set the **Callback path** in the console’s **Domains** tab to `/api/auth/callback/loginos`, the path Auth.js uses for a provider with the id `loginos`. The ID token’s `roles` and `permissions` reach the `jwt` callback as `profile`. Better Auth’s generic OAuth plugin uses the same callback path and takes `https://loginos.io/.well-known/openid-configuration` as its discovery URL.

## Frequently asked questions

### Is NextAuth.js still maintained?

Yes, as Auth.js. The Better Auth team has looked after it since September 2025, with security patches and urgent fixes for existing apps, and the Next.js package still installs as `next-auth@beta`. For new projects, the maintainers recommend Better Auth.

### Does Next.js have built-in authentication?

No. Next.js gives you the building blocks, such as route handlers, server functions, cookies and Proxy, but no sign-in system. Its own docs walk through a password flow for learning, then recommend an auth library for security and simplicity.

### Should I check authentication in Next.js middleware?

Only as a first, optimistic check. Proxy, called middleware before Next.js 16, is fine for redirecting visitors without a session cookie, but the Next.js docs say it shouldn’t be your only line of defense. A matcher change can silently remove it from a route, and in March 2025 CVE-2025-29927 let requests with a crafted header skip middleware. Verify the session again in server components, server functions and route handlers.

### Do I need a hosted service or a library?

A library like Better Auth or Auth.js keeps everything in your code and database, and you secure and operate it. A hosted service runs the sign-in pages, password storage and enterprise features for you, and you depend on its uptime and plans. A practical rule is to follow your data: Supabase Auth with Supabase, Firebase Auth with Firebase, and a library when the data is in your own Postgres.

---

Updated 2026-10-14. Web page: https://loginos.io/answers/best-authentication-for-nextjs
