theAuth is open-source auth for AI agents and humans. Star the repo on GitHub · Read the docs · Run the quickstart · theauth.dev
Last month I opened a side project I had shipped with no login at all. Every page was public, every API route trusted whoever called it. I had told myself I would "add auth later," and later arrived the day a friend pasted my admin URL into a group chat.
Adding auth to a running app is a different job from starting a new one. You have routes, layouts, and data that already exist. You cannot scaffold your way out of it. You need a small, boring set of changes that you can read in one sitting and undo if they go wrong.
This guide walks that path with theAuth, an open-source TypeScript auth library that also handles AI agent identity. We will add username and password login, cookie sessions, and protected pages to a Next.js App Router app. I wrote it from the docs and the package source, so every import and option below exists in the code. Where a feature is missing or rough, I say so.
This is guide 1 of 8 in the theAuth guides. It stands on its own, so you can start right here. Nothing comes before it. If you build for AI agents rather than people, jump to guide 5.
| Guide | Title | Read it when |
|---|---|---|
| 1 | Add login to an existing Next.js app (this guide) | You have an app with no auth yet |
| 2 | Passwordless login: passkeys, links, OTP | You want to drop passwords or add 2FA |
| 3 | Multi-tenant SaaS auth: orgs, RBAC, SSO, SCIM | You sell to teams and companies |
| 4 | Migrate from Auth0 or Clerk | You already run another provider |
| 5 | Give every AI agent its own identity | You run AI agents and need to start somewhere |
| 6 | Cap agent spend and require human approval | Your agents spend money or act on risky things |
| 7 | Secure an MCP server for production | You expose tools over MCP |
| 8 | Build an audit trail for AI agent actions | Someone will ask what your agents did |
Building for people? Start at guide 1. Building for AI agents? Start at guide 5. Every guide links to the docs page for each concept it touches.
TL;DR
| Step | What you do | Where |
|---|---|---|
| 1 | Install @glinr/theauth | terminal |
| 2 | Add a 32+ character session secret | .env.local |
| 3 | Create one shared instance and session manager | lib/theauth.ts |
| 4 | Add sign-up, sign-in, and sign-out route handlers | app/api/account/* |
| 5 | Read the session in server code | lib/auth.ts |
| 6 | Guard pages with middleware plus a server check | middleware.ts, app/dashboard |
| 7 | Build the forms and run the first login | app/sign-in, app/sign-up |
Expect about 150 lines of code in total. You will touch six or seven files and add zero new database tables by hand.
What you need first
You need an existing Next.js app on the App Router. The @glinr/theauth-nextjs adapter declares next >= 14 as its peer dependency. The code in this guide uses the async headers() function from Next.js 15, so on 14 you drop the await.
You need Node.js 18 or later and a package manager. I use pnpm. The commands translate to npm or yarn without changes.
You need a database. SQLite works for local development and needs no setup. For production, plan on Postgres (or MySQL, or D1 on Cloudflare). theAuth creates its own tables with a theauth_ prefix next to yours, so it does not touch your schema.
One honest note before you invest an hour. theAuth takes an agent-first approach to design. Its built-in human sign-in is username and password, not email and password, and it ships headless: no prebuilt login page, no social buttons wired by default. If you want email sign-in with a hosted UI in ten minutes, Clerk or Auth.js will get you there faster. If you want one library that also issues identities to your AI agents later, keep reading. I cover this tradeoff again near the end.
Step 1: Install the packages
pnpm add @glinr/theauth That one package carries the core, the SQLite driver (sql.js, compiled to WebAssembly), the username module, and the session managers. For Postgres, add the pg driver as well:
pnpm add pg pnpm add -D @types/pg The database driver is an optional peer dependency, so theAuth only loads it when you pick that provider. If you forget it, you get a clear startup error that names the missing package. The database setup page lists the driver for each provider, including the native SQLite option that needs a C++ build step.
You do not need @glinr/theauth-nextjs yet. That adapter mounts agent and MCP endpoints, not human login. I come back to it in step 8.
Step 2: Add the secrets
Generate a session secret and put it in .env.local:
openssl rand -base64 32 # .env.local SESSION_SECRET=paste-the-generated-value-here APP_HOST=localhost:3000 # Production only: your public host name and database # APP_HOST=app.example.com # DATABASE_URL=postgresql://user:password@host:5432/dbname The secret signs every session token, and theAuth rejects anything under 32 characters. Keep this value stable across deploys. A new secret invalidates every cookie already out there, which signs all your users out at once. The cookie options page says plainly that theAuth does not support a list of secrets for rotation, so plan any change for a quiet hour.
Step 3: Create one shared instance
Create two files. The first holds the cookie name and the app origin in a short module, so the middleware can import them later without pulling in a database driver. The second, lib/theauth.ts, builds the theAuth instance and the cookie session manager once per process.
// lib/constants.ts export const SESSION_COOKIE = 'theauth_session'; const scheme = process.env.NODE_ENV === 'production' ? 'https' : 'http'; export const BASE_URL = `${scheme}://${process.env.APP_HOST ?? 'localhost:3000'}`; // lib/theauth.ts import { createCookieSessionManager, createTheAuth } from '@glinr/theauth'; import { BASE_URL, SESSION_COOKIE } from './constants'; const isProd = process.env.NODE_ENV === 'production'; async function init() { const theauth = await createTheAuth({ database: isProd ? { provider: 'postgres', url: process.env.DATABASE_URL! } : { provider: 'sqlite', url: './theauth-dev.db' }, baseUrl: BASE_URL, auth: { session: { secret: process.env.SESSION_SECRET!, cookieName: SESSION_COOKIE }, }, username: {}, }); if (!theauth.username) { throw new Error('The username module did not start. Check auth.session.'); } const sessions = createCookieSessionManager( { secret: process.env.SESSION_SECRET!, sessionName: SESSION_COOKIE, maxAge: 7 * 24 * 60 * 60, autoRefresh: false, cookieOptions: { httpOnly: true, secure: isProd, sameSite: 'lax', path: '/' }, }, theauth.db, ); return { theauth, username: theauth.username, sessions }; } type TheAuthContext = Awaited<ReturnType<typeof init>>; const globalForTheAuth = globalThis as typeof globalThis & { theauthContext?: Promise<TheAuthContext>; }; export function getTheAuth(): Promise<TheAuthContext> { globalForTheAuth.theauthContext ??= init(); return globalForTheAuth.theauthContext; } Four choices in that file deserve a closer look.
First, createTheAuth is async, so you cannot export a ready instance from a plain module without top-level await. The getTheAuth() function stores the promise on globalThis. In development, Next.js reloads modules often, and without the global you would open a new database connection on every edit.
Second, the session secret lives at auth.session.secret. The configuration reference notes that the config type has a top-level secret option, yet createTheAuth() never reads it. Some of the shorter examples in the docs pass it anyway. Ignore that and use auth.session.
Third, username: {} turns on the username module with its defaults: 3 to 32 characters for usernames, 8 to 128 for passwords. The module is null unless auth.session is also set, which is why I throw when it comes back empty.
Fourth, autoRefresh: false. I explain this one in the gotchas, because it cost me the most thinking. Short version: server components cannot set cookies, and the refresh feature needs to.
On first start, theAuth runs CREATE TABLE IF NOT EXISTS for its tables. You do not run a migration command. If you manage schema changes yourself with another tool, set skipMigrations: true inside database. The database page covers the tradeoff: auto-migration only creates missing tables and never alters existing ones.
Step 4: Sign-up, sign-in, and sign-out routes
The username module has a built-in handleRequest that expects paths like /auth/username/sign-up. The Next.js adapter does not route to it, so I call the typed methods directly from my own route handlers. That gives me full control over cookies and error codes, and it keeps the code short.
First, a small helper that turns a sign-in result into a cookie. Put it in lib/session.ts:
// lib/session.ts import { getTheAuth } from './theauth'; interface SignedIn { user: { id: string; username: string }; session: { token: string }; } export async function issueSession(result: SignedIn, req: Request): Promise<string> { const { sessions } = await getTheAuth(); // signUp and signIn each mint a session of their own. // Drop it and issue one through the cookie manager so we control the metadata. const minted = await sessions.raw.validate(result.session.token); if (minted) await sessions.revokeSession(minted.id); const { setCookieHeader } = await sessions.createSession(result.user.id, { username: result.user.username, userAgent: req.headers.get('user-agent') ?? 'unknown', }); return setCookieHeader; } Why the extra revoke? The username module returns a session token in its JSON, not a cookie. I want a cookie with HttpOnly, SameSite, and my own metadata on the row. Revoking the module's session and creating one through the cookie manager costs one extra insert and one delete per login. It also means I can read session.metadata.username later without another database query. The sessions page documents createSession and its metadata argument.
Now the sign-up route:
// app/api/account/sign-up/route.ts import { NextResponse } from 'next/server'; import { issueSession } from '@/lib/session'; import { getTheAuth } from '@/lib/theauth'; export async function POST(req: Request) { const body = (await req.json()) as { username?: string; password?: string; name?: string }; if (!body.username || !body.password) { return NextResponse.json({ error: 'Username and password are required' }, { status: 400 }); } const { username } = await getTheAuth(); try { const result = await username.signUp({ username: body.username, password: body.password, name: body.name, }); const setCookie = await issueSession(result, req); return NextResponse.json({ user: result.user }, { status: 201, headers: { 'Set-Cookie': setCookie } }); } catch (error) { const message = error instanceof Error ? error.message : 'Sign-up failed'; return NextResponse.json({ error: message }, { status: 400 }); } } signUp throws with a readable message when the username already exists, runs too short, or contains characters outside the allowed pattern. The route passes that message through as a 400, which suits a form. The username page lists each rule and how to change the limits.
Sign-in follows the same shape, with one difference in how it handles failure:
// app/api/account/sign-in/route.ts import { NextResponse } from 'next/server'; import { issueSession } from '@/lib/session'; import { getTheAuth } from '@/lib/theauth'; export async function POST(req: Request) { const body = (await req.json()) as { username?: string; password?: string }; if (!body.username || !body.password) { return NextResponse.json({ error: 'Username and password are required' }, { status: 400 }); } const { username } = await getTheAuth(); try { const result = await username.signIn({ username: body.username, password: body.password }); const setCookie = await issueSession(result, req); return NextResponse.json({ user: result.user }, { headers: { 'Set-Cookie': setCookie } }); } catch { return NextResponse.json({ error: 'Invalid username or password' }, { status: 401 }); } } I return the same generic message for every failure. The module itself throws one message for an unknown username and another for a wrong password path, and a third for a forced password reset. Collapsing them in the route stops an attacker from using your login form to find valid usernames. If you plan to use the forced reset feature, split that case out so those users learn why they cannot sign in.
Sign-out revokes the row and clears the cookie:
// app/api/account/sign-out/route.ts import { NextResponse } from 'next/server'; import { getTheAuth } from '@/lib/theauth'; export async function POST(req: Request) { const { sessions } = await getTheAuth(); const { session } = await sessions.validateSession(req.headers.get('cookie') ?? ''); if (session) await sessions.revokeSession(session.id); return new NextResponse(null, { status: 204, headers: { 'Set-Cookie': sessions.buildLogoutCookie() }, }); } Revocation deletes the session row, so the token stops working at once even if someone copied it. That is the main reason to keep sessions in the database instead of using bare JWTs. The JWT sessions page covers the stateless alternative for mobile apps and separate API origins, where cookies do not fit.
Step 5: Read the session in server code
Every protected page and route handler needs the same question answered: who is this? Put the answer in one file, lib/auth.ts:
// lib/auth.ts import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { getTheAuth } from './theauth'; export interface CurrentUser { id: string; username: string; sessionId: string; } export async function getCurrentUser(): Promise<CurrentUser | null> { const { sessions } = await getTheAuth(); const cookieHeader = (await headers()).get('cookie') ?? ''; const { session } = await sessions.validateSession(cookieHeader); if (!session) return null; return { id: session.userId, username: String(session.metadata?.username ?? ''), sessionId: session.id, }; } export async function requireUser(): Promise<CurrentUser> { const user = await getCurrentUser(); if (!user) redirect('/sign-in'); return user; } validateSession takes the raw Cookie header and returns { session, refreshCookieHeader }. The session is null when the cookie is missing, malformed, expired, or revoked. The call returns no error code to branch on, which keeps the call site simple: either you have a session or you do not.
The user.id is the value to store in your own tables. If your app already has a projects table with an owner column, new rows get user.id there. Because theAuth stores its users in theauth_users, your existing tables stay as they are, and you add a foreign key from your tables to that ID only if you want one.
Step 6: Protect routes in two layers
Protection needs two layers, and the first one is not security.
Layer one: middleware as a fast redirect
Middleware runs before the page renders and often on the edge runtime, where a database driver may not work. I use it only to send visitors without a cookie to the sign-in page. Anyone can forge a cookie, so this check proves nothing. It just saves a render.
// middleware.ts import { NextResponse, type NextRequest } from 'next/server'; import { SESSION_COOKIE } from '@/lib/constants'; export function middleware(req: NextRequest) { if (!req.cookies.has(SESSION_COOKIE)) { return NextResponse.redirect(new URL('/sign-in', req.url)); } return NextResponse.next(); } export const config = { matcher: ['/dashboard/:path*'] }; The cookie name comes from lib/constants.ts on purpose. Importing it from lib/theauth.ts would drag the database driver into the middleware bundle, and the build fails with a confusing driver error. Newer Next.js releases also renamed the middleware file convention, so check what your installed version expects.
Layer two: the real check on the server
The second layer is the one that counts. Every page and handler that returns private data calls requireUser() or getCurrentUser() itself. A dashboard page looks like this:
// app/dashboard/page.tsx import { requireUser } from '@/lib/auth'; import { SignOutButton } from './sign-out-button'; export default async function DashboardPage() { const user = await requireUser(); return ( <main> <h1>Dashboard</h1> <p>Signed in as {user.username}</p> <SignOutButton /> </main> ); } And a protected API route r...
