Managed Better Auth: implement login
Enabling auth: true is not implementing login. Follow the matching live quickstart, then verify sign-up, sign-in, sign-out, session, and a protected route.
- Overview: https://neon.com/docs/auth/overview.md
- Next.js (API methods): https://neon.com/docs/auth/quick-start/nextjs-api-only.md
- React (API methods, including React Router): https://neon.com/docs/auth/quick-start/react.md
- TanStack Router (UI components): https://neon.com/docs/auth/quick-start/tanstack-router.md
Keep Clerk, existing Better Auth, Supabase Auth, or another working provider. Do not migrate it unless the user asks. The SKILL.md Supabase case is that explicit login-migration request.
Framework-specific companion skills also live in neondatabase/neon-js (neon-auth-nextjs, neon-auth-react, neon-js-react). Prefer the live Neon guides above; do not copy those SDK-local files into this repo.
Auth emails
Managed Auth sends verification, email OTP, magic-link, and password-reset messages. Getting started uses the shared SMTP provider (auth@mail.myneon.app). Implementing login does not require Resend, SendGrid, or other application email code.
Production requires custom SMTP. Verification codes work on shared or custom SMTP. Verification links require custom SMTP. Checklist: https://neon.com/docs/auth/production-checklist.md. Branding and webhook delivery: https://neon.com/docs/auth/guides/customize-emails.md.
SMS is separate: the Phone Number plugin needs an application send.otp webhook.
Packages
| Need | Package |
|---|---|
| Auth only | @neondatabase/auth |
| Already using the combined SDK | @neondatabase/neon-js/auth re-export |
| Pre-built UI | @neondatabase/auth-ui |
Keep an existing SupabaseAuthAdapter() caller on that API (signInWithPassword, signInWithOAuth). Do not mix those methods into default Better Auth examples. Password hashes do not migrate from Supabase; updateUser() cannot change email or password; email verification needs app UI. Guide: https://neon.com/docs/auth/migrate/from-supabase.md
The Managed client is Better Auth methods through Neon's wrapper. It is not interchangeable with bare better-auth/client while Auth is managed: the wrapper rejects extra plugins and implements Neon OAuth verifier / iframe popup / JWT extraction.
Environment
| Variable | Purpose |
|---|---|
NEON_AUTH_BASE_URL |
Branch Managed Auth URL (includes path). Next server; injected into Functions. |
NEON_AUTH_COOKIE_SECRET |
Next app secret for cached session cookies. Generate with openssl rand -base64 32 (32+ characters). Not injected by Neon. |
VITE_NEON_AUTH_URL |
Public Auth URL for Vite / TanStack browser code. Assign the actual branch URL; env pull does not create this alias. |
NEON_AUTH_JWKS_URL |
Injected Functions JWKS. Verify tokens in neon-functions, not here. |
neon env pull / neon deploy write Managed NEON_AUTH_BASE_URL and NEON_AUTH_JWKS_URL when Auth is declared. The cookie secret and VITE_* name are application config.
Next.js
createNeonAuth from @neondatabase/auth/next/server. Optional Next peer on current @neondatabase/auth is >=16.0.0; check the installed package before changing an existing app's router file.
import { createNeonAuth } from "@neondatabase/auth/next/server";
export const auth = createNeonAuth({
baseUrl: process.env.NEON_AUTH_BASE_URL!,
cookies: { secret: process.env.NEON_AUTH_COOKIE_SECRET! },
});
app/api/auth/[...path]/route.ts:
import { auth } from "@/lib/auth/server";
export const { GET, POST, PUT, DELETE, PATCH } = auth.handler();
Those five methods are what the installed SDK returns. Existing apps that export only GET/POST keep serving GET/POST routes.
Browser client takes no arguments and talks to that same-origin proxy:
import { createAuthClient } from "@neondatabase/auth/next";
export const authClient = createAuthClient();
Protect routes with auth.middleware({ loginUrl: "/auth/sign-in" }) from proxy.ts on Next 16. Earlier Next apps may still use middleware.ts; match the installed SDK. Always set config.matcher to the protected pages. A matcher that covers every path redirects JavaScript and CSS for unauthenticated visitors, so the login page cannot load:
import { auth } from "@/lib/auth/server";
export default auth.middleware({ loginUrl: "/auth/sign-in" });
export const config = {
matcher: ["/account/:path*"],
};
Replace /account/:path* with the app's protected routes. Keep login, registration, recovery, /api/auth, and static assets accessible without a session.
Before reading protected data or performing a mutation, check the session inside the Route Handler or Server Action and enforce the resource's authorization rules. Verify direct unauthenticated requests are denied, independently of page redirects:
const { data: session } = await auth.getSession();
if (!session?.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
Server session:
const { data: session, error } = await auth.getSession();
const user = session?.user;
Do not destructure { user } from the top-level result. Do not pass options into createAuthClient() from /next. Do not put fetchOptions on the Managed createAuthClient URL-style config; adapter factories accept fetch options inside BetterAuthReactAdapter({ fetchOptions }) / BetterAuthVanillaAdapter(...).
JWT: const { data, error } = await auth.token(); then data.token. Do not call getJWTToken() on the public client.
Phone OTP: the browser client exposes phoneNumber. Existing users link a number, then sign in; there is no phone-first signup. Next.js auth.handler() forwards the catch-all path to Managed Auth, including phone OTP. A missing auth.phoneNumber server method is a missing typed helper, not a proxy rejection. SMS e2e needs a configured send.otp webhook and custom UI.
Organization invitations
organization.inviteMember() does not send email unless send_invitation_email is on (default false) and "Verify email at signup" is enabled. Accepting an emailed invite needs /auth/accept-invitation?invitationId= via AuthView or a custom flow that signs the recipient in and calls organization.acceptInvitation({ invitationId }). If email delivery stays off, use an in-app invitation list. https://neon.com/docs/auth/guides/plugins/organization.md
React / Vite
import { createAuthClient } from "@neondatabase/auth";
import { BetterAuthReactAdapter } from "@neondatabase/auth/react/adapters";
export const authClient = createAuthClient(import.meta.env.VITE_NEON_AUTH_URL, {
adapter: BetterAuthReactAdapter(),
});
Call adapter factories with (). Omit the adapter for vanilla Better Auth methods without useSession.
Public calls: signUp.email({ email, password, name }), signIn.email({ email, password }), signIn.social({ provider, callbackURL }), getSession(), signOut(). Result shape is { data, error }; user is data.user. Handle error and thrown HTTP errors, pending UI, and authenticated vs unauthenticated display.
JWT: authClient.token() then data.token.
UI
import "@neondatabase/auth-ui/css";
import { NeonAuthUIProvider, AuthView } from "@neondatabase/auth-ui";
Choose one CSS import: /css or /tailwind, never both. Current @neondatabase/auth-ui uses <AuthView path={path} />. Check installed types before copying a pathname example from older docs.
UI flags (emailOTP, magicLink, social providers, organization) do not enable the Managed plugin. Configure the plugin on the branch, then the UI.
Preserve existing @neondatabase/auth/react/ui imports rather than forcing a drive-by migration. New snippets use @neondatabase/auth-ui.
Cross-subdomain vs bearer JWT
cookies.domain shares session cookies across subdomains of one parent domain (see the neon-js cross-domain-cookies example). That is not cookie sharing across unrelated frontend and backend hosts. A Neon Function authenticates with Authorization: Bearer. With Managed Auth, verify against the injected JWKS. With another identity, use that identity's token contract — see neon-functions. https://neon.com/docs/compute/functions/authentication.md and https://neon.com/docs/auth/guides/plugins/jwt.md.
Data API identity
Only when the app already uses PostgREST or a Supabase database client:
import { defineConfig } from "@neon/config/v1";
export default defineConfig({ auth: true, dataApi: true });
Existing external IdP:
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
dataApi: {
authProvider: "external",
jwksUrl: "https://your-idp/.well-known/jwks.json",
},
});
Do not enable Auth merely to satisfy a dataApi type error in an app that never needed the Data API. External JWKS on a Claimable project is accepted only after claim. Combined SDK createClient({ dataApi: { url, getToken } }) is a query client without .auth; confirm the installed @neondatabase/neon-js docs before introducing it.