Base IdP

Base IdP

Frameworks

Next.js (fullstack)

Server-rendered login in a Next.js app — all auth is server-side, tokens live in httpOnly cookies, no Base IdP key ever reaches the browser.

A Next.js app that uses SSR for auth is fully server-side. The browser never knows about Base IdP. It navigates to /api/auth/start, the server builds the authorize URL and redirects to Base IdP, Base IdP redirects back to /api/auth/callback, the server exchanges the code for tokens and sets them as httpOnly cookies. That is the whole picture.

There is no browser SDK. No NEXT_PUBLIC_ prefix. No client id in the browser bundle. The user types their password into Base IdP's hosted login page, and your Next.js server handles everything else.

This is the architecture used by Square's own console app. Every auth file in that codebase begins with import "server-only".

Install

npm install base-idp

Configure

.env.local
BASE_IDP_CLIENT_ID=sq_live_web
BASE_IDP_CLIENT_SECRET=sqk_your_secret

Notice: no NEXT_PUBLIC_ prefix on either variable. .env.local is git-ignored by default in Next.js. For deployment use your platform's secrets manager — vercel env, fly secrets, Kubernetes secrets.

Scaffold

npx base-idp create \
  --stack nextjs-fullstack \
  --client-id sq_live_web \
  --client-secret sqk_your_secret

This writes four Route Handlers, a session helper, a .env.local, and a setup readme into ./base-idp-nextjs-fullstack/. Move the route handlers into your project.

The four route handlers

/api/auth/start

Builds the authorize URL, stores the PKCE verifier server-side, and redirects the browser to Base IdP.

app/api/auth/start/route.ts
import "server-only";
import { NextRequest, NextResponse } from "next/server";
import { createHash, randomBytes } from "crypto";
import { persistPkceState } from "@/lib/auth/state-store";
import { resolveIssuer } from "@/lib/auth/config";

export async function GET(request: NextRequest) {
  const clientId = process.env.BASE_IDP_CLIENT_ID!;
  const returnTo = request.nextUrl.searchParams.get("return_to") ?? "/";
  const state = crypto.randomUUID();
  const codeVerifier = randomBytes(32).toString("base64url");
  const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");

  await persistPkceState(state, returnTo, codeVerifier);

  const redirectUri = new URL("/api/auth/callback", request.url).toString();
  const issuer = resolveIssuer();
  const authorizeUrl = new URL("/oauth2/authorize", issuer);
  authorizeUrl.searchParams.set("response_type", "code");
  authorizeUrl.searchParams.set("client_id", clientId);
  authorizeUrl.searchParams.set("redirect_uri", redirectUri);
  authorizeUrl.searchParams.set("scope", "openid profile");
  authorizeUrl.searchParams.set("state", state);
  authorizeUrl.searchParams.set("code_challenge", codeChallenge);
  authorizeUrl.searchParams.set("code_challenge_method", "S256");

  return NextResponse.redirect(authorizeUrl, { headers: { "Cache-Control": "no-store" } });
}

/api/auth/callback

Receives the authorization code from Base IdP. Exchanges it for tokens using the secret. Stores them as httpOnly cookies. Redirects the user to where they were going.

app/api/auth/callback/route.ts
import "server-only";
import { NextRequest, NextResponse } from "next/server";
import { consumePkceState } from "@/lib/auth/state-store";
import { resolveIssuer } from "@/lib/auth/config";
import { setSessionCookies } from "@/lib/auth/cookies";

type TokenResponse = {
  access_token: string;
  refresh_token?: string;
  expires_in: number;
};

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

  if (!code) {
    return NextResponse.json({ error: "missing_code" }, { status: 400 });
  }

  const pkce = await consumePkceState(state);
  if (!pkce) {
    return NextResponse.redirect(new URL("/api/auth/start", request.url));
  }

  const clientId = process.env.BASE_IDP_CLIENT_ID!;
  const clientSecret = process.env.BASE_IDP_CLIENT_SECRET!;
  const redirectUri = new URL("/api/auth/callback", request.url).toString();

  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    client_id: clientId,
    redirect_uri: redirectUri,
    code_verifier: pkce.codeVerifier,
  });

  const tokenResponse = await fetch(
    new URL("/oauth2/token", resolveIssuer()).toString(),
    {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
        Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
      },
      body,
      cache: "no-store",
    },
  );

  if (!tokenResponse.ok) {
    return NextResponse.redirect(new URL("/api/auth/start", request.url));
  }

  const tokens = (await tokenResponse.json()) as TokenResponse;
  const response = NextResponse.redirect(
    new URL(pkce.returnTo ?? "/", request.url),
    { headers: { "Cache-Control": "no-store" } },
  );
  setSessionCookies(response, request, tokens);
  return response;
}

The secret never touches the browser. The token exchange is an HTTP call from your server to Base IdP's token endpoint, not from the user's device.

/api/auth/refresh

Called when the access token in the cookie has expired. Exchanges the refresh token for a new pair and updates the cookies.

app/api/auth/refresh/route.ts
import "server-only";
import { NextRequest, NextResponse } from "next/server";
import { resolveIssuer } from "@/lib/auth/config";
import { getRefreshCookie, setSessionCookies } from "@/lib/auth/cookies";

export async function POST(request: NextRequest) {
  const refreshToken = getRefreshCookie(request);
  if (!refreshToken) {
    return NextResponse.json({ error: "no_refresh_token" }, { status: 401 });
  }

  const clientId = process.env.BASE_IDP_CLIENT_ID!;
  const clientSecret = process.env.BASE_IDP_CLIENT_SECRET!;

  const tokenResponse = await fetch(
    new URL("/oauth2/token", resolveIssuer()).toString(),
    {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Accept: "application/json",
        Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
      },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        client_id: clientId,
      }),
      cache: "no-store",
    },
  );

  if (!tokenResponse.ok) {
    const response = NextResponse.json({ error: "refresh_failed" }, { status: 401 });
    clearSessionCookies(response, request);
    return response;
  }

  const tokens = await tokenResponse.json();
  const response = NextResponse.json({ ok: true });
  setSessionCookies(response, request, tokens);
  return response;
}

/api/auth/logout

Revokes the refresh token and clears the session cookies.

app/api/auth/logout/route.ts
import "server-only";
import { NextRequest, NextResponse } from "next/server";
import { resolveIssuer } from "@/lib/auth/config";
import { getRefreshCookie, clearSessionCookies } from "@/lib/auth/cookies";

export async function POST(request: NextRequest) {
  const refreshToken = getRefreshCookie(request);

  if (refreshToken) {
    const clientId = process.env.BASE_IDP_CLIENT_ID!;
    await fetch(new URL("/oauth2/revoke", resolveIssuer()).toString(), {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ token: refreshToken, client_id: clientId }),
      cache: "no-store",
    }).catch(() => {});
  }

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

Token verification in Server Components

Use the access token from the cookie to verify on each request. The verifyPasetoV4Public function from base-idp/server verifies offline — no call to Base IdP, just a local Ed25519 check.

lib/auth/session.ts
import "server-only";
import { cookies } from "next/headers";
import { verifyPasetoV4Public } from "base-idp/server";
import { publicKeys } from "@/lib/auth/keys";
import { resolveIssuer } from "@/lib/auth/config";

const SESSION_COOKIE = "sq_access";

export async function currentPrincipal() {
  const token = (await cookies()).get(SESSION_COOKIE)?.value;
  if (!token) return null;

  try {
    const keySet = await publicKeys(resolveIssuer());
    return verifyPasetoV4Public(token, keySet, {
      issuer: resolveIssuer(),
      audience: "square-experience",
    });
  } catch {
    return null;
  }
}
app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { currentPrincipal } from "@/lib/auth/session";

export default async function DashboardPage() {
  const principal = await currentPrincipal();
  if (!principal) redirect("/api/auth/start?return_to=/dashboard");

  return <p>Hello {principal.name}</p>;
}

The sign-in button

The browser component that starts a login is a plain anchor tag. No SDK. No client id. No Base IdP knowledge. Just a link to your Route Handler.

app/sign-in/page.tsx
export default function SignInPage({
  searchParams,
}: {
  searchParams: { return_to?: string };
}) {
  const href = searchParams.return_to
    ? `/api/auth/start?return_to=${encodeURIComponent(searchParams.return_to)}`
    : "/api/auth/start";

  return (
    <main>
      <h1>Sign in</h1>
      <a href={href}>Continue with Base IdP</a>
    </main>
  );
}

The browser code touches no secrets, no client ids, no issuer URLs. That is the security boundary: server code does things browser code is not allowed to do.

Issuer config

The issuer is built in. In production it is https://authlayer.squareexp.com. For local development, override it.

lib/auth/config.ts
import "server-only";

const PRODUCTION_ISSUER = "https://authlayer.squareexp.com";
const LOCAL_ISSUER = "http://localhost:8080";

export function resolveIssuer() {
  const explicit = process.env.BASE_IDP_ENVIRONMENT?.toLowerCase();
  if (explicit === "dev" || explicit === "development") return LOCAL_ISSUER;
  if (explicit === "prod" || explicit === "production") return PRODUCTION_ISSUER;
  return process.env.NODE_ENV === "production" ? PRODUCTION_ISSUER : LOCAL_ISSUER;
}
lib/auth/cookies.ts
import "server-only";
import type { NextRequest, NextResponse } from "next/server";

export const ACCESS_COOKIE = "sq_access";
export const REFRESH_COOKIE = "sq_refresh";

export function getRefreshCookie(request: NextRequest) {
  return request.cookies.get(REFRESH_COOKIE)?.value;
}

export function setSessionCookies(
  response: NextResponse,
  request: NextRequest,
  tokens: { access_token: string; refresh_token?: string; expires_in?: number },
) {
  const secure = request.nextUrl.protocol === "https:";
  response.cookies.set(ACCESS_COOKIE, tokens.access_token, {
    httpOnly: true,
    secure,
    sameSite: "lax",
    path: "/",
    maxAge: tokens.expires_in ?? 3600,
  });
  if (tokens.refresh_token) {
    response.cookies.set(REFRESH_COOKIE, tokens.refresh_token, {
      httpOnly: true,
      secure,
      sameSite: "lax",
      path: "/",
      maxAge: 60 * 60 * 24 * 30,
    });
  }
}

export function clearSessionCookies(response: NextResponse, request: NextRequest) {
  const secure = request.nextUrl.protocol === "https:";
  for (const name of [ACCESS_COOKIE, REFRESH_COOKIE]) {
    response.cookies.set(name, "", {
      httpOnly: true,
      secure,
      sameSite: "lax",
      path: "/",
      expires: new Date(0),
    });
  }
}

Register the redirect URI

In Square Experience Cloud, add <your-origin>/api/auth/callback to the allowed redirect URIs. Register every origin: production, staging, every local port. The URI must match exactly.

http://localhost:3000/api/auth/callback
https://app.example.com/api/auth/callback

A full layout

app/
├── api/
│   └── auth/
│       ├── start/route.ts        # builds authorize URL, sets PKCE state
│       ├── callback/route.ts     # exchanges code, sets cookies
│       ├── refresh/route.ts      # refreshes tokens, updates cookies
│       └── logout/route.ts       # revokes, clears cookies
├── sign-in/page.tsx              # <a href="/api/auth/start">
└── dashboard/page.tsx            # currentPrincipal() or redirect
lib/
└── auth/
    ├── config.ts                 # resolveIssuer()
    ├── cookies.ts                # setSessionCookies, clearSessionCookies
    ├── keys.ts                   # publicKeys() with TTL cache
    ├── session.ts                # currentPrincipal()
    └── state-store.ts            # PKCE verifier storage

Four route handlers. Everything else is normal Next.js. The entire Base IdP surface is in lib/auth/ — none of it ships to the browser.

Common issues

"redirect_uri does not match"

The callback URL your Route Handler sent is not in the registered list. Add it in Square Experience Cloud, exactly as used (no trailing slash, no scheme mismatch).

Your callback handler returned a NextResponse but the Set-Cookie mutation was dropped. Make sure you call setSessionCookies(response, request, tokens) before returning — the mutation applies to the response object in place.

Browsers reject Secure cookies on http://. The setSessionCookies helper above sets secure based on the protocol. On local dev it omits Secure, so cookies work without HTTPS. In production it is always set.

Verification fails after key rotation

The key cache TTL is 60 seconds by default. On the first request after a rotation, the SDK re-fetches the key set. If you hardcoded a public key instead of using the keys endpoint, every rotation breaks verification until you redeploy. Never hardcode keys.

Where to go next

On this page