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.
| 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.
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.
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.
Generate a session secret and put it in .env.local:
openssl rand -base64 32
SESSION_SECRET=paste-the-generated-value-here
APP_HOST=localhost:3000
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.
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'}`;
js
// 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.
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.
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.
Protection needs two layers, and the first one is not security.
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.
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 returns 401 instead of redirecting:
// app/api/projects/route.ts
import { NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth';
export async function GET() {
const user = await getCurrentUser();
if (!user) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
// Query your own tables with user.id here.
return NextResponse.json({ projects: [], ownerId: user.id });
}
Keep the rule simple. Middleware may redirect, but only the server check may return data. If you delete the middleware file tomorrow, nothing leaks.
Two client components and one small button. Start with the sign-in form:
// app/sign-in/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function SignInPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setPending(true);
setError(null);
const form = new FormData(event.currentTarget);
const res = await fetch('/api/account/sign-in', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: form.get('username'),
password: form.get('password'),
}),
});
setPending(false);
if (!res.ok) {
const data = (await res.json()) as { error?: string };
setError(data.error ?? 'Sign-in failed');
return;
}
router.push('/dashboard');
router.refresh();
}
return (
<form onSubmit={onSubmit}>
<label>
Username
<input name="username" autoComplete="username" required />
</label>
<label>
Password
<input name="password" type="password" autoComplete="current-password" required />
</label>
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Signing in...' : 'Sign in'}
</button>
</form>
);
}
The sign-up page is the same form posting to /api/account/sign-up with an extra name input, so I will not paste it twice. Copy the file, swap the URL, add the field, and set autoComplete="new-password" on the password input.
The sign-out button is a short client component:
// app/dashboard/sign-out-button.tsx
'use client';
import { useRouter } from 'next/navigation';
export function SignOutButton() {
const router = useRouter();
async function onClick() {
await fetch('/api/account/sign-out', { method: 'POST' });
router.push('/sign-in');
router.refresh();
}
return <button onClick={onClick}>Sign out</button>;
}
Now run the first login. Start the dev server with pnpm dev, then try the whole loop from a terminal. This tests the server without any UI getting in the way:
curl -i -c jar.txt -X POST localhost:3000/api/account/sign-up \
-H 'Content-Type: application/json' \
-d '{"username":"ada_lovelace","password":"correct horse battery","name":"Ada"}'
curl -s -b jar.txt localhost:3000/dashboard | grep "Signed in as"
curl -i localhost:3000/dashboard
The first call should return 201 with a Set-Cookie: theauth_session=... header that includes HttpOnly and SameSite=Lax. The second should print the line Signed in as ada_lovelace. The third should return a redirect to /sign-in. If all three behave, open the browser, visit /sign-up, create a second account, and watch the dashboard load.
You will also see a theauth-dev.db file appear in your project root. Add it to .gitignore now.
Everything above handles humans. If you also want to issue identities to AI agents later, the adapter is where that lives. Install it and mount the catch-all route:
pnpm add @glinr/theauth-nextjs
js
// app/api/theauth/[...theauth]/route.ts
import { theAuthNextjs } from '@glinr/theauth-nextjs';
import { getTheAuth } from '@/lib/theauth';
const { theauth } = await getTheAuth();
const handlers = theAuthNextjs(theauth);
export const GET = handlers.GET;
export const POST = handlers.POST;
export const PATCH = handlers.PATCH;
export const DELETE = handlers.DELETE;
export const OPTIONS = handlers.OPTIONS;
You must also add agents: { enabled: true } to the createTheAuth call in lib/theauth.ts. That option is what creates the agent, permission, delegation, and audit tables. Without it, agent.create fails.
Read the next sentence twice. The route handler exposes theAuth's REST endpoints and does not add authentication of its own. The add-to-existing-app guide says so directly. If you mount it, protect /api/theauth/* with your own check, for example by calling getCurrentUser() in a wrapper. Do not leave it open because "nobody knows the path."
The Next.js adapter page lists every endpoint it serves, including the optional MCP OAuth endpoints. If you only need human login, skip this step entirely.
This is the autoRefresh trap. With autoRefresh: true, every successful validateSession call deletes the session row and creates a new one, then hands you the new cookie in refreshCookieHeader. A Server Component cannot set cookies. The row gets replaced, the browser keeps the old cookie, and the next request fails.
You have two fixes. Turn autoRefresh off, as in step 3, and accept a fixed 7-day lifetime from sign-in. Or keep it on and call validateSession only in places that can write headers, such as route handlers and middleware, then forward refreshCookieHeader yourself. I picked the first option because it has no moving parts. The cookie options page shows the trade.
theauth.username is null
It stays null unless two things are true: you passed a username key, and you configured auth.session. Missing either one gives you a null module with no error, which is why lib/theauth.ts checks for it and throws.
The cookie manager sets Secure based on NODE_ENV, not on your URL. If your host does not set NODE_ENV=production, browsers get a cookie without Secure, or you get one with Secure over plain HTTP and the browser drops it. Set cookieOptions.secure explicitly, as step 3 does.
@glinr/theauth-react ships TheAuthProvider, useUser, and useSignIn. In its managed mode, those hooks post to {basePath}/auth/sign-in with an email and password. theAuth core does not serve a route at that path. The React page says this in a warning box. That is why this guide uses plain forms.
You have two options if you want the hooks. Serve the routes the provider expects from your own handler, or use the provider's external mode and point mePath and logoutPath at your own API. I have not built a full flow on external mode for this guide, so test it before you rely on it.
The rateLimit() plugin applies per-IP limits to /auth/* endpoints that theAuth itself serves. My /api/account/* handlers are plain Next.js routes, and I found nothing in the source that wraps them. Add limits at your edge, in your host's firewall, or with your own counter. A login form with no throttle invites password guessing. The rate limiting page explains what the plugin does cover.
SameSite=Lax blocks the common cross-site POST. For a second layer, the session helpers export validateOrigin, which checks the Origin header against a list you trust. Call it at the top of each mutating route:
import { validateOrigin } from '@glinr/theauth';
import { BASE_URL } from '@/lib/constants';
const origin = validateOrigin(req, [BASE_URL]);
if (!origin.valid) {
return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 });
}
The sessions page also documents a double-submit CSRF token for stricter setups.
theauth_users
The username module has no email field in its flow. In the source, signUp writes <username>@username.local into the email column of theauth_users. Nothing breaks, but do not send mail to that address, and do not show it to users. If you need real emails, pair the module with the email verification module or switch to the magic link flow.
The sqlite provider keeps the database in memory and rewrites the whole file after each write. The database page warns against pointing two or more processes at one file. In practice that means do not run next dev and a second script against the same theauth-dev.db. Use Postgres anywhere that matters.
Choose theAuth for this job if you want username and password login you fully control, sessions you can revoke in one query, and a path to agent identities, delegation, and audit logs in the same library. You own the forms, the routes, and the error messages.
Choose something else if you need these today. Email and password with verified addresses and password reset emails, out of the box. Prebuilt sign-in components. A hosted dashboard for end users. Social login with five providers and no wiring. Clerk, Auth.js, or better-auth cover that ground with less code. The theAuth docs even include a migration section for people moving between libraries, and the add-to-existing-app guide shows theAuth running next to Clerk or Auth.js for the agent side only.
One more limit worth stating. I did not find a documented way to import existing password hashes into the username module. If your app already has users with passwords, plan on asking them to sign up again or reset, or run a parallel period where both systems work.
Each page below maps to a piece of this guide:
Secure default.createTheAuth() option.
Only if you want it to. The add-to-existing-app guide runs theAuth next to Clerk, Auth.js, or better-auth, using your current user ID as the owner of agents. This guide goes the other way and uses theAuth's own username module for human login.
Not with the built-in password module, which is username based. Use the magic link or email OTP modules for email-first sign-in. Both need auth.session and an email-sending callback that you write.
Postgres, MySQL, or D1 on Cloudflare. The SQLite providers work for local development and small single-process deploys. Install the matching driver (pg or mysql2) yourself, because theAuth loads it on demand.
Seven days by default (maxAge of 604,800 seconds). With autoRefresh off, as in this guide, the session ends 7 days after sign-in no matter how active the user is. Change maxAge on the cookie session manager to adjust it.
Session validation reads the database, so it does not fit an edge middleware that cannot reach your driver. Use middleware for a cookie presence check and redirect, then check the session in server components and route handlers where the driver works.
Add agents: { enabled: true } to the config, then call theauth.agent.create with the user.id as ownerId. Agents get bearer tokens, scoped permissions, and audit entries. The quickstart page walks through the first agent in five short steps.
When you added auth to an app that was already live, what broke first: the cookies, the redirects, or the existing users? I would like to know which of the gotchas above hit you, and which one I missed.
The fastest way in is the quickstart. If this guide saved you time, a star on GitHub helps other developers find the project, and the docs cover every option used above. More about the project lives at theauth.dev.
Next: guide 2, passwordless login. The full list sits in the table at the top of this page.
GDS K S · thegdsks.com · building Glincker · follow on X @thegdsks
Ship the login first, then give your agents their own identity so a leaked admin URL stops being your only defense.