Quickstarts

Add authentication to a Next.js app

Add authentication to your Next.js app with AuthPI — using the AuthPI SDK or any OIDC client library.

Last updated 2026-06-11

Overview

This guide walks through adding AuthPI authentication to a Next.js App Router application. Every code section shows two approaches side by side:

  • @authpi/idp — the official AuthPI SDK with built-in PKCE, token refresh, and agent management.
  • arctic — a lightweight, generic OAuth 2.0 client where you wire up endpoints yourself.

Both implement the Authorization Code flow with PKCE (S256).

Prerequisites

  • An AuthPI account with an Issuer configured
  • A registered Client (Web type) with redirect URI set to http://localhost:3000/api/auth/callback
  • Your Client ID and Client Secret noted
  • A test User created in your Issuer

Install

npm install @authpi/idp
npm install arctic

Environment variables

Create a .env.local file in your project root:

AUTHPI_ISSUER_URL=https://idp.authpi.com/iss_your_issuer_id
AUTHPI_CLIENT_ID=cli_your_client_id
AUTHPI_CLIENT_SECRET=cli_secret_your_client_secret
NEXT_PUBLIC_BASE_URL=http://localhost:3000

Configure the client

// lib/auth.ts
import { IdpClient } from "@authpi/idp";

export const authClient = new IdpClient({
  issuerUrl: process.env.AUTHPI_ISSUER_URL!,
  clientId: process.env.AUTHPI_CLIENT_ID!,
  clientSecret: process.env.AUTHPI_CLIENT_SECRET!,
  redirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`,
});
// lib/auth.ts
import * as arctic from "arctic";

export const issuerUrl = process.env.AUTHPI_ISSUER_URL!;

export const oauthClient = new arctic.OAuth2Client(
  process.env.AUTHPI_CLIENT_ID!,
  process.env.AUTHPI_CLIENT_SECRET!,
  `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
);

Login route

Redirect the user to AuthPI's authorization endpoint. Store the PKCE code verifier and state in httpOnly cookies so they survive the redirect.

// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import { authClient } from "@/lib/auth";

export async function GET() {
  const { url, codeVerifier, state } = await authClient.createAuthorizationUrl({
    scopes: ["openid", "profile", "email"],
  });

  const response = NextResponse.redirect(url);

  response.cookies.set("code_verifier", codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  response.cookies.set("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  return response;
}
// app/api/auth/login/route.ts
import { NextResponse } from "next/server";
import * as arctic from "arctic";
import { oauthClient, issuerUrl } from "@/lib/auth";

export async function GET() {
  const state = arctic.generateState();
  const codeVerifier = arctic.generateCodeVerifier();

  const url = oauthClient.createAuthorizationURLWithPKCE(
    `${issuerUrl}/authorize`,
    state,
    arctic.CodeChallengeMethod.S256,
    codeVerifier,
    ["openid", "profile", "email"]
  );

  const response = NextResponse.redirect(url);

  response.cookies.set("code_verifier", codeVerifier, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  response.cookies.set("oauth_state", state, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 10,
    sameSite: "lax",
  });

  return response;
}

Callback route

Exchange the authorization code for tokens, then store them in a session cookie. In production you should encrypt this cookie — here we keep it simple with JSON.

// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { authClient } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const storedState = request.cookies.get("oauth_state")?.value;
  const codeVerifier = request.cookies.get("code_verifier")?.value;

  if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
    return NextResponse.json({ error: "Invalid callback parameters" }, { status: 400 });
  }

  const agent = await authClient.exchangeCode(code, codeVerifier);

  const response = NextResponse.redirect(new URL("/dashboard", request.url));

  response.cookies.set("session", JSON.stringify(agent.tokens), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
    sameSite: "lax",
  });

  response.cookies.delete("code_verifier");
  response.cookies.delete("oauth_state");

  return response;
}
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { oauthClient, issuerUrl } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const url = new URL(request.url);
  const code = url.searchParams.get("code");
  const state = url.searchParams.get("state");

  const storedState = request.cookies.get("oauth_state")?.value;
  const codeVerifier = request.cookies.get("code_verifier")?.value;

  if (!code || !state || !storedState || !codeVerifier || state !== storedState) {
    return NextResponse.json({ error: "Invalid callback parameters" }, { status: 400 });
  }

  const tokens = await oauthClient.validateAuthorizationCode(
    `${issuerUrl}/token`,
    code,
    codeVerifier
  );

  const sessionData = {
    accessToken: tokens.accessToken(),
    refreshToken: tokens.refreshToken(),
    idToken: tokens.idToken(),
    expiresAt: tokens.accessTokenExpiresAt().toISOString(),
  };

  const response = NextResponse.redirect(new URL("/dashboard", request.url));

  response.cookies.set("session", JSON.stringify(sessionData), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    path: "/",
    maxAge: 60 * 60 * 24 * 7, // 7 days
    sameSite: "lax",
  });

  response.cookies.delete("code_verifier");
  response.cookies.delete("oauth_state");

  return response;
}

Protected page

Read the session cookie on the server and display user information. The AuthPI SDK can reconstitute an agent from stored tokens and will auto-refresh expired access tokens. With arctic, you fetch the userinfo endpoint manually.

// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { authClient } from "@/lib/auth";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get("session")?.value;

  if (!sessionCookie) {
    redirect("/api/auth/login");
  }

  const tokens = JSON.parse(sessionCookie);
  const agent = await authClient.createAgent(tokens);

  return (
    <main>
      <h1>Dashboard</h1>
      <p>User ID: {agent.id}</p>
      <p>Email: {agent.email}</p>
      <a href="/api/auth/logout">Log out</a>
    </main>
  );
}
// app/dashboard/page.tsx
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { issuerUrl } from "@/lib/auth";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const sessionCookie = cookieStore.get("session")?.value;

  if (!sessionCookie) {
    redirect("/api/auth/login");
  }

  const session = JSON.parse(sessionCookie);

  const response = await fetch(`${issuerUrl}/userinfo`, {
    headers: { Authorization: `Bearer ${session.accessToken}` },
  });

  if (!response.ok) {
    redirect("/api/auth/login");
  }

  const user = await response.json();

  return (
    <main>
      <h1>Dashboard</h1>
      <p>User ID: {user.sub}</p>
      <p>Email: {user.email}</p>
      <a href="/api/auth/logout">Log out</a>
    </main>
  );
}

Logout route

Revoke the refresh token, clear the session cookie, and redirect to AuthPI's logout endpoint so the IdP session is also terminated.

// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { authClient } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const sessionCookie = request.cookies.get("session")?.value;

  if (sessionCookie) {
    const tokens = JSON.parse(sessionCookie);

    if (tokens.refreshToken) {
      await authClient.revokeToken(tokens.refreshToken, "refresh_token");
    }
  }

  const logoutUrl = authClient.createLogoutUrl({
    postLogoutRedirectUri: `${process.env.NEXT_PUBLIC_BASE_URL}`,
  });

  const response = NextResponse.redirect(logoutUrl);
  response.cookies.delete("session");

  return response;
}
// app/api/auth/logout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { oauthClient, issuerUrl } from "@/lib/auth";

export async function GET(request: NextRequest) {
  const sessionCookie = request.cookies.get("session")?.value;

  if (sessionCookie) {
    const session = JSON.parse(sessionCookie);

    if (session.refreshToken) {
      await oauthClient.revokeToken(
        `${issuerUrl}/revoke`,
        session.refreshToken
      );
    }
  }

  const logoutUrl = new URL(`${issuerUrl}/logout`);
  logoutUrl.searchParams.set(
    "post_logout_redirect_uri",
    process.env.NEXT_PUBLIC_BASE_URL!
  );

  const response = NextResponse.redirect(logoutUrl.toString());
  response.cookies.delete("session");

  return response;
}

Next steps

You now have a working login/logout flow. From here you can:

  • Add Organizations support for multi-tenant access control
  • Subscribe to Events for audit logging
  • Set up Webhooks for real-time notifications when users sign up, update their profile, or change authentication methods

The AuthPI SDK handles token refresh automatically via createAgent(). If you are using arctic, you will need to implement refresh logic manually by calling the /token endpoint with grant_type=refresh_token when the access token expires.