Skip to content
Docs

How to identify and authorize visitors with the Vercel Passport token in Next.js

Read the Vercel Passport token server-side in a Next.js app to identify visitors with the external_sub claim and authorize access to your data.

5 min read
Last updated June 20, 2026

After Vercel Passport authenticates a visitor, you can identify that visitor in your Next.js app and decide what data they see. Vercel injects a signed token into the x-vercel-oidc-passport-token request header, and that token carries the visitor's identity claims from your identity provider. Because Vercel validates the session and injects the token server-side, your code can read the visitor's identity without running its own sign-in flow. Use the external_sub claim as the visitor's stable identifier to scope database queries, gate routes, and personalize responses.

This guide will show you how to read the Passport token from a server-side request in Next.js, decode it to access the visitor's identity claims, and use the external_sub claim to authorize a visitor and scope their data. It also covers how to handle requests where the token is missing.

Before you begin, make sure you have:

  • Passport enabled on your project, currently available for Enterprise customers. See Restrict access to deployments with Passport to turn it on.
  • A Next.js app deployed to Vercel. Vercel injects the Passport token only for protected deployments, so the header isn't present in local development.
  • Code that runs on the server, such as a Route Handler, a Server Component, or a server action. The token is never available in client components or in the browser.

Vercel forwards the Passport session token to your deployment in the x-vercel-oidc-passport-token request header. The token is a Vercel-signed JWT that carries deployment context and the visitor's identity claims. The reliable user identifier is the external_sub claim, which comes from the external subject your identity provider returns.

You don't need to verify the token's signature in your own code. Vercel strips any client-supplied value from this header and injects the verified token after validating the session, so a token present in a server-side request is already trustworthy. Your job is to read the header and decode the claims.

The token carries the following claims:

ClaimDescription
external_subThe stable visitor identifier returned by your identity provider. Use this to identify the visitor.
subThe owner, connector_id, and external_sub in a stable Vercel format.
scopeIncludes the owner, connector_id, and external_sub.
email, nameProfile fields. Not guaranteed. They appear only if your identity provider returns them in the Passport user info response.

The Passport token is separate from the OIDC federation token in the x-vercel-oidc-token header. The federation token is for exchanging for short-lived cloud credentials with the @vercel/oidc helpers, while the Passport token identifies the human visiting your protected deployment.

Read the x-vercel-oidc-passport-token header in a server-side Route Handler. If the header is missing, the request didn't pass through Passport, so return a 401 response.

app/api/me/route.ts
export async function GET(request: Request) {
const token = request.headers.get('x-vercel-oidc-passport-token');
if (!token) {
return new Response('Unauthorized', { status: 401 });
}
// Next: decode the token to read the visitor's claims.
return Response.json({ authenticated: true });
}

Missing tokens usually mean the request came from local development or an unprotected deployment. Guarding for it keeps your code working in both places.

Decode the token's payload to read the claims. Vercel has already validated the session, so you decode the JWT rather than verifying its signature. The jose library's decodeJwt reads the claims and works regardless of runtime.

Install jose:

Terminal
pnpm i jose

Then add a helper that reads and decodes the token in one place:

lib/passport.ts
import { decodeJwt } from 'jose';
export interface PassportClaims {
external_sub: string;
sub: string;
scope: string;
email?: string;
name?: string;
}
export function readPassportClaims(request: Request): PassportClaims | null {
const token = request.headers.get('x-vercel-oidc-passport-token');
if (!token) {
return null;
}
return decodeJwt(token) as PassportClaims;
}

If you'd rather avoid a dependency and you're on the Node.js runtime, decode the payload segment directly:

const payload = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString(),
);
const visitorId = payload.external_sub;

Buffer is available on the Node.js runtime. The jose approach works across runtimes, so prefer it if your route runs anywhere other than Node.js.

Use the external_sub claim to decide what the visitor can access, and key your queries on it so each visitor reads only their own records. Derive the identity from the token, not from request parameters. A userId taken from the query string or request body lets a visitor request another visitor's data.

app/api/documents/route.ts
import { readPassportClaims } from '@/lib/passport';
import { getDocumentsForUser } from '@/lib/db';
export async function GET(request: Request) {
const claims = readPassportClaims(request);
if (!claims) {
return new Response('Unauthorized', { status: 401 });
}
// Scope the query to the authenticated visitor.
const documents = await getDocumentsForUser(claims.external_sub);
return Response.json({ documents });
}

To restrict a route to specific visitors, check external_sub against an allowlist and return 403 for everyone else:

app/api/admin/route.ts
import { readPassportClaims } from '@/lib/passport';
const ADMINS = new Set(['external_subject_id_1', 'external_subject_id_2']);
export async function GET(request: Request) {
const claims = readPassportClaims(request);
if (!claims) {
return new Response('Unauthorized', { status: 401 });
}
if (!ADMINS.has(claims.external_sub)) {
return new Response('Forbidden', { status: 403 });
}
// Return admin-only data.
return Response.json({ ok: true });
}

Server Components read request headers through headers() from next/headers, so you can identify the visitor when rendering a page. headers() returns a promise, so await it.

app/dashboard/page.tsx
import { headers } from 'next/headers';
import { decodeJwt } from 'jose';
export default async function DashboardPage() {
const token = (await headers()).get('x-vercel-oidc-passport-token');
if (!token) {
return <p>This page is available on a Passport-protected deployment.</p>;
}
const { external_sub } = decodeJwt(token) as { external_sub: string };
return <p>Signed in as {external_sub}</p>;
}
  • Use external_sub as the identifier. It's stable and comes from your identity provider. Treat email and name as optional, since they appear only when your provider returns them.
  • Always read the token server-side. Vercel strips client-supplied header values, so the value you read in server code is the one Vercel injected and validated.
  • Derive identity from the token, not from request parameters. Scoping a query to a userId from the query string or body lets a visitor request another visitor's data.
  • Use the right status codes. Return 401 when the token is missing and 403 when the visitor is authenticated but not allowed.
  • Confirm Passport is enabled on the project and that you're requesting a protected deployment URL.
  • Confirm your code runs on the server. The header isn't available in client components or in the browser.
  • In local development, the header isn't present because requests don't pass through Passport. Guard for a missing token so local development still works.

email and name appear only if your identity provider returns them in the Passport user info response. Use external_sub to identify the visitor and treat profile fields as optional.

Was this helpful?

supported.

Read related documentation

Explore more Vercel Passport guides