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-idpConfigure
BASE_IDP_CLIENT_ID=sq_live_web
BASE_IDP_CLIENT_SECRET=sqk_your_secretNotice: 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_secretThis 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.
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.
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.
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.
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.
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;
}
}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.
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.
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;
}Cookie helpers
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/callbackA 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 storageFour 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).
The session cookie is not set after callback
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.
Secure cookie rejected on localhost
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.