From bd34c3b33a7d29ce46bdbfd67e92cf960ad9508d Mon Sep 17 00:00:00 2001 From: Brooke Mosby <25040341+brookemosby@users.noreply.github.com> Date: Mon, 6 Apr 2026 10:10:58 -0600 Subject: [PATCH] feat(next-auth): add Vercel skew protection headers to client fetches Attach x-deployment-id to SessionProvider and client auth requests so plain fetch calls stay pinned during rolling releases. Respects VERCEL_SKEW_PROTECTION_ENABLED and resolves deployment id from Next/Vercel conventions. Made-with: Cursor --- packages/next-auth/src/lib/client.ts | 3 ++ .../src/lib/vercel-skew-protection.test.ts | 39 +++++++++++++++ .../src/lib/vercel-skew-protection.ts | 50 +++++++++++++++++++ packages/next-auth/src/react.tsx | 3 ++ packages/next-auth/src/webauthn.ts | 5 +- 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 packages/next-auth/src/lib/vercel-skew-protection.test.ts create mode 100644 packages/next-auth/src/lib/vercel-skew-protection.ts diff --git a/packages/next-auth/src/lib/client.ts b/packages/next-auth/src/lib/client.ts index 22b0f0cbb2..781739f61f 100644 --- a/packages/next-auth/src/lib/client.ts +++ b/packages/next-auth/src/lib/client.ts @@ -5,6 +5,8 @@ import type { ProviderId, ProviderType } from "@auth/core/providers" import type { LoggerInstance, Session } from "@auth/core/types" import { AuthError } from "@auth/core/errors" +import { getSkewProtectionHeaderInit } from "./vercel-skew-protection.js" + /** @todo */ class ClientFetchError extends AuthError {} @@ -148,6 +150,7 @@ export async function fetchData( headers: { "Content-Type": "application/json", ...(req?.headers?.cookie ? { cookie: req.headers.cookie } : {}), + ...getSkewProtectionHeaderInit(), }, } diff --git a/packages/next-auth/src/lib/vercel-skew-protection.test.ts b/packages/next-auth/src/lib/vercel-skew-protection.test.ts new file mode 100644 index 0000000000..2c78f4cc28 --- /dev/null +++ b/packages/next-auth/src/lib/vercel-skew-protection.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + getSkewProtectionHeaderInit, + resolveDeploymentIdForSkewProtection, +} from "./vercel-skew-protection.js" + +afterEach(() => { + vi.unstubAllEnvs() + ;(globalThis as Record).NEXT_DEPLOYMENT_ID = undefined +}) + +describe("resolveDeploymentIdForSkewProtection", () => { + it("prefers globalThis.NEXT_DEPLOYMENT_ID", () => { + ;(globalThis as Record).NEXT_DEPLOYMENT_ID = "dpl_global" + expect(resolveDeploymentIdForSkewProtection()).toBe("dpl_global") + }) + + it("uses VERCEL_DEPLOYMENT_ID when global is unset", () => { + vi.stubEnv("VERCEL_DEPLOYMENT_ID", "dpl_env") + expect(resolveDeploymentIdForSkewProtection()).toBe("dpl_env") + }) +}) + +describe("getSkewProtectionHeaderInit", () => { + it("returns empty when skew protection is disabled", () => { + vi.stubEnv("VERCEL_SKEW_PROTECTION_ENABLED", "0") + vi.stubEnv("VERCEL_DEPLOYMENT_ID", "dpl_x") + expect(getSkewProtectionHeaderInit()).toEqual({}) + }) + + it("returns x-deployment-id when enabled and id is resolvable", () => { + vi.stubEnv("VERCEL_SKEW_PROTECTION_ENABLED", "1") + vi.stubEnv("VERCEL_DEPLOYMENT_ID", "dpl_abc") + expect(getSkewProtectionHeaderInit()).toEqual({ + "x-deployment-id": "dpl_abc", + }) + }) +}) diff --git a/packages/next-auth/src/lib/vercel-skew-protection.ts b/packages/next-auth/src/lib/vercel-skew-protection.ts new file mode 100644 index 0000000000..302491e3b2 --- /dev/null +++ b/packages/next-auth/src/lib/vercel-skew-protection.ts @@ -0,0 +1,50 @@ +/** + * [Vercel Skew Protection](https://vercel.com/docs/skew-protection) does not automatically + * pin custom `fetch()` calls. Next.js sets `globalThis.NEXT_DEPLOYMENT_ID` from `data-dpl-id` + * on the document; we also fall back to Vercel/Next deployment id env vars when available. + */ + +const DEPLOYMENT_ID_HEADER = "x-deployment-id" + +function isSkewProtectionEnabled(): boolean { + if (typeof process === "undefined") return true + const flag = process.env.VERCEL_SKEW_PROTECTION_ENABLED + return flag === undefined || flag === "1" +} + +export function resolveDeploymentIdForSkewProtection(): string | undefined { + if (typeof globalThis !== "undefined") { + const fromGlobal = (globalThis as Record) + .NEXT_DEPLOYMENT_ID + if (typeof fromGlobal === "string" && fromGlobal.length > 0) { + return fromGlobal + } + } + if (typeof document !== "undefined") { + const fromDataset = document.documentElement?.dataset?.dplId + if (fromDataset) return fromDataset + const fromAttr = + document.documentElement?.getAttribute("data-dpl-id") ?? undefined + if (fromAttr) return fromAttr + } + if (typeof process !== "undefined") { + if (process.env.VERCEL_DEPLOYMENT_ID) { + return process.env.VERCEL_DEPLOYMENT_ID + } + if (process.env.NEXT_DEPLOYMENT_ID) { + return process.env.NEXT_DEPLOYMENT_ID + } + } + return undefined +} + +/** + * Header entries to merge into client-side Auth.js fetches so they stay pinned during + * rolling releases on Vercel. + */ +export function getSkewProtectionHeaderInit(): Record { + if (!isSkewProtectionEnabled()) return {} + const deploymentId = resolveDeploymentIdForSkewProtection() + if (!deploymentId) return {} + return { [DEPLOYMENT_ID_HEADER]: deploymentId } +} diff --git a/packages/next-auth/src/react.tsx b/packages/next-auth/src/react.tsx index 4110e2bf81..3808931abf 100644 --- a/packages/next-auth/src/react.tsx +++ b/packages/next-auth/src/react.tsx @@ -21,6 +21,7 @@ import { parseUrl, useOnline, } from "./lib/client.js" +import { getSkewProtectionHeaderInit } from "./lib/vercel-skew-protection.js" import type { ProviderId } from "@auth/core/providers" import type { LoggerInstance, Session } from "@auth/core/types" @@ -288,6 +289,7 @@ export async function signIn( headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Auth-Return-Redirect": "1", + ...getSkewProtectionHeaderInit(), }, body: new URLSearchParams({ ...signInParams, @@ -349,6 +351,7 @@ export async function signOut( headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Auth-Return-Redirect": "1", + ...getSkewProtectionHeaderInit(), }, body: new URLSearchParams({ csrfToken, callbackUrl: redirectTo }), }) diff --git a/packages/next-auth/src/webauthn.ts b/packages/next-auth/src/webauthn.ts index e1ed98a96b..304d450816 100644 --- a/packages/next-auth/src/webauthn.ts +++ b/packages/next-auth/src/webauthn.ts @@ -1,4 +1,5 @@ import { apiBaseUrl } from "./lib/client.js" +import { getSkewProtectionHeaderInit } from "./lib/vercel-skew-protection.js" import { startAuthentication, startRegistration } from "@simplewebauthn/browser" import { getCsrfToken, getProviders, __NEXTAUTH } from "./react.js" @@ -33,7 +34,8 @@ async function webAuthnOptions( const params = new URLSearchParams(options) const optionsResp = await fetch( - `${baseUrl}/webauthn-options/${providerID}?${params}` + `${baseUrl}/webauthn-options/${providerID}?${params}`, + { headers: { ...getSkewProtectionHeaderInit() } } ) if (!optionsResp.ok) { return { error: optionsResp } @@ -116,6 +118,7 @@ export async function signIn( headers: { "Content-Type": "application/x-www-form-urlencoded", "X-Auth-Return-Redirect": "1", + ...getSkewProtectionHeaderInit(), }, body: new URLSearchParams({ ...signInParams,