Back to guides

A comprehensive introduction to Own Auth with Next.js


Own Auth gives a Next.js application a complete authentication engine without moving user data into a hosted auth platform. It handles password hashing, users, one-time tokens, database sessions, rate limits, organisations, API keys, and audit events in your Postgres database.

Next.js remains responsible for the application layer: forms, Route Handlers, Server Actions, cookies, redirects, rendering, and authorization checks around your own data. This guide uses the App Router and keeps the Own Auth instance entirely on the server.

What you will build

  • Email and password sign-up and sign-in Route Handlers
  • An opaque session token stored in an HttpOnly cookie
  • A reusable server-side current-session function
  • A protected Server Component and Server Action
  • Sign-out with immediate database revocation
  • Passwordless sign-in with magic links
  • Safe responses for expected authentication errors

How Own Auth fits into Next.js

Request flow
Browser
-> Next.js form or client request
-> Route Handler or Server Action
-> own-auth
-> Postgres

The browser stores one opaque HttpOnly session token.
Every protected server entry point verifies it with own-auth.

Prerequisites

  • A Next.js application using the App Router
  • Node.js 18 or later
  • A Postgres database

Install and migrate

Install Own Auth and a schema validator for the HTTP boundary. Then apply the Own Auth migrations to your Postgres database.

Terminal
npm install own-auth zod
npx own-auth migrate
npx own-auth status

Own Auth creates only own_auth_ prefixed tables. It does not modify your application tables. If you also use Prisma, Drizzle, or another migration system, let each tool manage only the tables it owns.

Configure the server environment

.env.local
DATABASE_URL=postgres://user:password@localhost:5432/myapp
OWN_AUTH_TOKEN_PEPPER=replace-with-a-long-random-secret
APP_URL=http://localhost:3000

Generate the pepper once, store it with your production secrets, and keep it stable. Changing it invalidates existing sessions, auth links, SMS codes, and API keys.

Create the Own Auth instance

Create one shared instance in a server-only module. baseUrl tells Own Auth where application auth links begin, while redirectAllowlist controls allowed absolute destinations.

lib/auth.ts
import "server-only";
import { createOwnAuth } from "own-auth";

const appUrl = process.env.APP_URL;
if (!appUrl) throw new Error("APP_URL is required");

export const auth = createOwnAuth({
  tokenPepper: process.env.OWN_AUTH_TOKEN_PEPPER,
  baseUrl: appUrl,
  redirectAllowlist: [appUrl],
});

Centralize the session cookie

Own Auth returns an opaque session token and stores only its protected hash in Postgres. Put the raw token in an HttpOnly cookie so browser JavaScript cannot read it. Next.js cookies is asynchronous in current App Router releases.

lib/session-cookie.ts
import "server-only";
import { cookies } from "next/headers";

const sessionCookieName = "own_auth_session";
const sessionCookieOptions = {
  httpOnly: true,
  path: "/",
  sameSite: "lax" as const,
  secure: process.env.NODE_ENV === "production",
};

export async function readSessionToken() {
  return (await cookies()).get(sessionCookieName)?.value;
}

export async function setSessionCookie(token: string, expires: Date) {
  (await cookies()).set(sessionCookieName, token, {
    ...sessionCookieOptions,
    expires,
  });
}

export async function clearSessionCookie() {
  (await cookies()).delete(sessionCookieName);
}

Cookies can be read in Server Components, but they can be changed only in a Route Handler or Server Action. Keep the set and delete calls inside those server mutation paths.

Validate authentication input

Own Auth validates authentication rules, but your Route Handler should still reject malformed JSON and incorrectly shaped fields before calling it.

lib/auth-validation.ts
import { z } from "zod";

export const signUpSchema = z.object({
  name: z.string().trim().min(1).optional(),
  email: z.string().trim().email(),
  password: z.string().min(8),
});

export const signInSchema = z.object({
  email: z.string().trim().email(),
  password: z.string(),
});

export const magicLinkSchema = z.object({
  email: z.string().trim().email(),
});

Return safe Own Auth errors

Expected authentication failures are AuthError instances. Return their safe status, code, and message. Re-throw unknown errors so the normal Next.js error path and monitoring still see genuine failures.

lib/auth-error-response.ts
import { AuthError } from "own-auth";

export function authErrorResponse(error: unknown): Response {
  if (error instanceof AuthError) {
    return Response.json(
      {
        error: {
          code: error.code,
          message: error.safeMessage,
        },
      },
      { status: error.statusCode },
    );
  }

  throw error;
}

Create the sign-up Route Handler

app/api/auth/signup/route.ts
import { auth } from "@/lib/auth";
import { authErrorResponse } from "@/lib/auth-error-response";
import { signUpSchema } from "@/lib/auth-validation";
import { setSessionCookie } from "@/lib/session-cookie";

export async function POST(request: Request) {
  try {
    const body = await request.json().catch(() => null);
    const parsed = signUpSchema.safeParse(body);
    if (!parsed.success) {
      return Response.json({ error: "Invalid sign-up details" }, { status: 400 });
    }

    const result = await auth.signUpEmailPassword(parsed.data);
    await setSessionCookie(result.sessionToken, result.session.expiresAt);

    return Response.json({ user: result.user }, { status: 201 });
  } catch (error) {
    return authErrorResponse(error);
  }
}

signUpEmailPassword hashes the password with Argon2id, creates the user and session, and returns the session token. Do not hash the password separately or create a duplicate user record in another auth table.

Create the sign-in Route Handler

app/api/auth/signin/route.ts
import { auth } from "@/lib/auth";
import { authErrorResponse } from "@/lib/auth-error-response";
import { signInSchema } from "@/lib/auth-validation";
import { setSessionCookie } from "@/lib/session-cookie";

export async function POST(request: Request) {
  try {
    const body = await request.json().catch(() => null);
    const parsed = signInSchema.safeParse(body);
    if (!parsed.success) {
      return Response.json({ error: "Invalid sign-in details" }, { status: 400 });
    }

    const result = await auth.signInEmailPassword(parsed.data);
    await setSessionCookie(result.sessionToken, result.session.expiresAt);

    return Response.json({ user: result.user });
  } catch (error) {
    return authErrorResponse(error);
  }
}

Wrong emails and wrong passwords both return invalid_credentials. Keep that response deliberately vague so attackers cannot discover which email addresses have accounts.

Read the current session

Centralize the database-backed session check. React cache deduplicates repeated calls during one server render without turning an active session into long-lived shared application state.

lib/current-auth.ts
import "server-only";
import { cache } from "react";
import { auth } from "@/lib/auth";
import { readSessionToken } from "@/lib/session-cookie";

export const getCurrentAuth = cache(async () => {
  const sessionToken = await readSessionToken();
  return sessionToken
    ? auth.getCurrentSession(sessionToken)
    : null;
});
app/api/auth/session/route.ts
import { getCurrentAuth } from "@/lib/current-auth";

export async function GET() {
  const current = await getCurrentAuth();

  if (!current) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  return Response.json({
    user: current.user,
    session: current.session,
  });
}

Protect a Server Component

Check the session close to the data access or page that needs it. A layout that hides its children is not a security boundary because Next.js has other entry points, including Route Handlers and Server Actions.

app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { getCurrentAuth } from "@/lib/current-auth";

export default async function DashboardPage() {
  const current = await getCurrentAuth();
  if (!current) redirect("/signin");

  return <h1>Welcome, {current.user.name ?? current.user.email}</h1>;
}

Protect every Server Action

Treat a Server Action like a public mutation endpoint. Verify the session and authorization inside the action even when the button is visible only to signed-in users.

app/actions/profile.ts
"use server";

import { getCurrentAuth } from "@/lib/current-auth";

export async function updateProfile(formData: FormData) {
  const current = await getCurrentAuth();
  if (!current) throw new Error("Unauthorized");

  const name = String(formData.get("name") ?? "").trim();
  if (!name) return { error: "Name is required" };

  // Update application profile data using current.user.id.
  // Never accept the acting user ID from formData.
}

Sign out

Revoke the database session before deleting the cookie. After sign-out, the same token stops working on its next request even if a copy exists elsewhere.

app/api/auth/signout/route.ts
import { auth } from "@/lib/auth";
import {
  clearSessionCookie,
  readSessionToken,
} from "@/lib/session-cookie";

export async function POST() {
  const sessionToken = await readSessionToken();
  if (sessionToken) await auth.signOut(sessionToken);

  await clearSessionCookie();
  return new Response(null, { status: 204 });
}

Add magic-link sign-in

Configure an email provider, then expose one endpoint to request the email and another to consume its one-time token. Always return a fixed request response instead of revealing whether an email address exists.

app/api/auth/magic-link/route.ts
const body = await request.json().catch(() => null);
const parsed = magicLinkSchema.safeParse(body);
if (!parsed.success) {
  return Response.json({ error: "Invalid email" }, { status: 400 });
}

await auth.requestMagicLink({
  email: parsed.data.email,
  redirectUrl: "/dashboard",
});

return Response.json({
  message: "If that address can sign in, a link is on its way.",
});
app/auth/magic-link/verify/route.ts
const token = new URL(request.url).searchParams.get("token");
if (!token) return Response.json({ error: "Invalid link" }, { status: 400 });

const result = await auth.verifyMagicLink({ token });
await setSessionCookie(result.sessionToken, result.session.expiresAt);

return Response.redirect(new URL("/dashboard", request.url));

The token is single-use and expires. Do not log the request URL or token. If you need to show expired or already-used states, map the matching AuthError codes to safe user-facing messages.

Add social sign-in

Use the provider's supported server library to verify an Apple, Google, or other identity token first. Check its signature, issuer, audience, expiry, and nonce where applicable. Own Auth receives only the identity your backend has already verified.

app/api/auth/google/route.ts
const googleUser = await verifyGoogleIdToken(idToken);

const result = await auth.signInWithExternalProvider({
  provider: "google",
  providerAccountId: googleUser.sub,
  email: googleUser.email,
  emailVerified: googleUser.email_verified === true,
});

await setSessionCookie(result.sessionToken, result.session.expiresAt);

Production checklist

  • Use HTTPS and Secure cookies in production
  • Keep Own Auth imports inside server-only modules
  • Run Own Auth migrations before starting the new release
  • Verify the database session in every protected Route Handler and Server Action
  • Perform authorization checks next to each protected data operation
  • Use CSRF protection for cookie-authenticated mutations
  • Keep invalid-credential and magic-link request responses generic
  • Never log passwords, session tokens, magic-link URLs, or token-bearing request bodies

FAQ

Is Own Auth a Next.js plugin?

No. Own Auth is a framework-independent TypeScript authentication library. Next.js calls its server APIs from Route Handlers, Server Actions, or server-side data access modules.

Can I use Own Auth with the Pages Router?

Yes. Call the same Own Auth methods from API Routes and use secure response cookies there. The examples in this guide use the App Router because it is the current Next.js architecture.

Should I protect everything in Proxy or Middleware?

No. Proxy can perform an early redirect, but the secure check still belongs in each protected data access, Route Handler, and Server Action. Do not depend on a URL redirect as the only authorization layer.

Does Own Auth use JWT sessions?

No. The browser holds an opaque session token and Own Auth verifies it against a database-backed session. Revocation takes effect immediately without waiting for a JWT to expire.

Can I keep application profiles in Prisma or Drizzle?

Yes. Keep application-specific profile data in your own tables keyed by the Own Auth user ID. Do not duplicate password, token, or session records outside Own Auth.

Conclusion

The clean Next.js integration is deliberately small. Own Auth owns authentication and database sessions. Next.js owns server entry points, cookies, rendering, and authorization around your application data. Keeping those responsibilities separate gives you an auth system that is secure, auditable, and still entirely under your control.