Skip to content

[prebuilt-skew-protection] feat: adding in automatic deploymentId#88012

Merged
ijjk merged 63 commits intocanaryfrom
brooke/addAutomaticDeploymentId
Jan 12, 2026
Merged

[prebuilt-skew-protection] feat: adding in automatic deploymentId#88012
ijjk merged 63 commits intocanaryfrom
brooke/addAutomaticDeploymentId

Conversation

@brookemosby
Copy link
Contributor

@brookemosby brookemosby commented Jan 2, 2026

https://linear.app/vercel/issue/FLOW-5530/nextjs-support-automatic-deploymentid-generation

So users can configure a deploymentId, to be used for skew protection with prebuilts

@nextjs-bot
Copy link
Collaborator

Allow CI Workflow Run

  • approve CI run for commit: 86f75fe

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

@nextjs-bot
Copy link
Collaborator

nextjs-bot commented Jan 2, 2026

Allow CI Workflow Run

  • approve CI run for commit: e4ae0c6

Note: this should only be enabled once the PR is ready to go and can only be enabled by a maintainer

Copy link
Contributor

@vercel vercel bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestions:

  1. The code uses config.deploymentId directly in a template literal to create a cookie value, but deploymentId can now be a function according to the schema changes, which would stringify to an invalid value like "[Function]".
View Details
📝 Patch Details
diff --git a/packages/next/src/lib/load-custom-routes.test.ts b/packages/next/src/lib/load-custom-routes.test.ts
index a4026d7180..b5bcf14a3b 100644
--- a/packages/next/src/lib/load-custom-routes.test.ts
+++ b/packages/next/src/lib/load-custom-routes.test.ts
@@ -173,4 +173,55 @@ describe('loadCustomRoutes', () => {
       })
     })
   })
+
+  describe('useSkewCookie', () => {
+    it('adds Set-Cookie header when useSkewCookie is true and deploymentId is a string', async () => {
+      const customRoutes = await loadCustomRoutes({
+        experimental: {
+          useSkewCookie: true,
+        },
+        deploymentId: 'my-deployment-id',
+      })
+      expect(customRoutes.headers).toEqual([
+        {
+          source: '/:path*',
+          headers: [
+            {
+              key: 'Set-Cookie',
+              value: '__vdpl=my-deployment-id; Path=/; HttpOnly',
+            },
+          ],
+        },
+      ])
+    })
+
+    it('does not add Set-Cookie header when deploymentId is a function', async () => {
+      const customRoutes = await loadCustomRoutes({
+        experimental: {
+          useSkewCookie: true,
+        },
+        deploymentId: () => 'my-deployment-id',
+      })
+      expect(customRoutes.headers).toEqual([])
+    })
+
+    it('does not add Set-Cookie header when useSkewCookie is false', async () => {
+      const customRoutes = await loadCustomRoutes({
+        experimental: {
+          useSkewCookie: false,
+        },
+        deploymentId: 'my-deployment-id',
+      })
+      expect(customRoutes.headers).toEqual([])
+    })
+
+    it('does not add Set-Cookie header when deploymentId is undefined', async () => {
+      const customRoutes = await loadCustomRoutes({
+        experimental: {
+          useSkewCookie: true,
+        },
+      })
+      expect(customRoutes.headers).toEqual([])
+    })
+  })
 })
diff --git a/packages/next/src/lib/load-custom-routes.ts b/packages/next/src/lib/load-custom-routes.ts
index 3ea01ce13a..552cafa083 100644
--- a/packages/next/src/lib/load-custom-routes.ts
+++ b/packages/next/src/lib/load-custom-routes.ts
@@ -725,7 +725,7 @@ export default async function loadCustomRoutes(
     )
   }
 
-  if (config.experimental?.useSkewCookie && config.deploymentId) {
+  if (config.experimental?.useSkewCookie && config.deploymentId && typeof config.deploymentId === 'string') {
     headers.unshift({
       source: '/:path*',
       headers: [

Analysis

Missing type check for deploymentId function in cookie header

What fails: loadCustomRoutes() uses config.deploymentId directly in a template literal to create a cookie value without verifying it's a string, but deploymentId is typed as string | (() => string | Promise<string>) | undefined. If a function is provided (as allowed by the config schema), it would stringify to the function source code instead of the actual ID.

How to reproduce: Create a next.config.js with:

module.exports = {
  experimental: {
    useSkewCookie: true,
  },
  deploymentId: () => 'my-deployment-id',
}

Then run next build or next experimental-analyze. The Set-Cookie header would be set to __vdpl=() => 'my-deployment-id' instead of __vdpl=my-deployment-id.

Result: Invalid cookie value containing function source code rather than the deployment ID. This could occur in:

  • next experimental-analyze (analyze flow doesn't call generateDeploymentId)
  • next dev (dev server can use function-type deploymentId from config)
  • Any custom code path calling loadCustomRoutes() before generateDeploymentId() conversion

Expected: Either skip the header if deploymentId is not a string (per TypeScript strict mode best practices), or ensure deploymentId is converted to a string before use.

Fix: Added typeof config.deploymentId === 'string' type guard to the condition to ensure the value is a string before using it in the template literal.

2. The analyze function loads config without calling `generateDeploymentId`\, but then tries to use `config.deploymentId` as a string\. If a user configures `deploymentId` as a function\, it would be stringified to \"\[Function\]\" instead of being evaluated\.
View Details
📝 Patch Details
diff --git a/packages/next/src/build/analyze/index.ts b/packages/next/src/build/analyze/index.ts
index 5723fb043c..24b3d0e167 100644
--- a/packages/next/src/build/analyze/index.ts
+++ b/packages/next/src/build/analyze/index.ts
@@ -9,6 +9,8 @@ import { PHASE_ANALYZE } from '../../shared/lib/constants'
 import { turbopackAnalyze, type AnalyzeContext } from '../turbopack-analyze'
 import { durationToString } from '../duration-to-string'
 import { cp, writeFile, mkdir } from 'node:fs/promises'
+import { generateDeploymentId } from '../generate-deployment-id'
+import { nanoid } from 'next/dist/compiled/nanoid/index.cjs'
 import {
   collectAppFiles,
   collectPagesFiles,
@@ -55,7 +57,11 @@ export default async function analyze({
       reactProductionProfiling,
     })
 
-    process.env.NEXT_DEPLOYMENT_ID = config.deploymentId || ''
+    const deploymentId = await generateDeploymentId(
+      config.deploymentId,
+      nanoid
+    )
+    process.env.NEXT_DEPLOYMENT_ID = deploymentId
 
     const distDir = path.join(dir, '.next')
     const telemetry = new Telemetry({ distDir })
diff --git a/packages/next/src/build/turbopack-build/impl.ts b/packages/next/src/build/turbopack-build/impl.ts
index 4a6612641d..bd8c221ce0 100644
--- a/packages/next/src/build/turbopack-build/impl.ts
+++ b/packages/next/src/build/turbopack-build/impl.ts
@@ -12,6 +12,8 @@ import { TurbopackManifestLoader } from '../../shared/lib/turbopack/manifest-loa
 import { promises as fs } from 'fs'
 import { PHASE_PRODUCTION_BUILD } from '../../shared/lib/constants'
 import loadConfig from '../../server/config'
+import { generateDeploymentId } from '../generate-deployment-id'
+import { nanoid } from 'next/dist/compiled/nanoid/index.cjs'
 import { hasCustomExportOutput } from '../../export/utils'
 import { Telemetry } from '../../telemetry/storage'
 import { setGlobal } from '../../trace'
@@ -230,6 +232,14 @@ export async function workerMain(workerData: {
       reactProductionProfiling: NextBuildContext.reactProductionProfiling,
     }
   ))
+  
+  // Evaluate deploymentId if it's a function
+  const deploymentId = await generateDeploymentId(
+    config.deploymentId,
+    nanoid
+  )
+  config.deploymentId = deploymentId
+  
   // Matches handling in build/index.ts
   // https://github.com/vercel/next.js/blob/84f347fc86f4efc4ec9f13615c215e4b9fb6f8f0/packages/next/src/build/index.ts#L815-L818
   // Ensures the `config.distDir` option is matched.
diff --git a/packages/next/src/build/webpack-build/impl.ts b/packages/next/src/build/webpack-build/impl.ts
index e332de299a..e35c3448a3 100644
--- a/packages/next/src/build/webpack-build/impl.ts
+++ b/packages/next/src/build/webpack-build/impl.ts
@@ -25,6 +25,8 @@ import {
 } from '../build-context'
 import { createEntrypoints } from '../entries'
 import loadConfig from '../../server/config'
+import { generateDeploymentId } from '../generate-deployment-id'
+import { nanoid } from 'next/dist/compiled/nanoid/index.cjs'
 import {
   getTraceEvents,
   initializeTraceState,
@@ -392,6 +394,14 @@ export async function workerMain(workerData: {
       reactProductionProfiling: NextBuildContext.reactProductionProfiling,
     }
   ))
+  
+  // Evaluate deploymentId if it's a function
+  const deploymentId = await generateDeploymentId(
+    config.deploymentId,
+    nanoid
+  )
+  config.deploymentId = deploymentId
+  
   await installBindings(config.experimental?.useWasmBinary)
   NextBuildContext.nextBuildSpan = trace(
     `worker-main-${workerData.compilerName}`
diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts
index 7c2360520f..3e0bf606a6 100644
--- a/packages/next/src/server/lib/router-server.ts
+++ b/packages/next/src/server/lib/router-server.ts
@@ -10,6 +10,8 @@ import '../require-hook'
 import url from 'url'
 import path from 'path'
 import loadConfig from '../config'
+import { generateDeploymentId } from '../../build/generate-deployment-id'
+import { nanoid } from 'next/dist/compiled/nanoid/index.cjs'
 import { serveStatic } from '../serve-static'
 import setupDebug from 'next/dist/compiled/debug'
 import * as Log from '../../build/output/log'
@@ -103,6 +105,13 @@ export async function initialize(opts: {
     { silent: false }
   )
 
+  // Evaluate deploymentId if it's a function
+  const deploymentId = await generateDeploymentId(
+    config.deploymentId,
+    nanoid
+  )
+  config.deploymentId = deploymentId
+
   let compress: ReturnType<typeof setupCompression> | undefined
 
   if (config?.compress !== false) {

Analysis

Unhandled function-type deploymentId in analyze and runtime server initialization

What fails: When a user configures deploymentId as a function (per the Next.js config schema which allows deploymentId?: string | (() => string | Promise<string>)), the analyze command and production/dev server initialization do not evaluate the function, instead stringifying it to "[Function]" when assigning to process.env.NEXT_DEPLOYMENT_ID.

How to reproduce:

  1. Create a next.config.js with a function-based deploymentId:
module.exports = {
  deploymentId: () => 'my-deployment-' + Date.now()
}
  1. Run next experimental-analyze - deploymentId becomes stringified
  2. Start production server with next start - deploymentId becomes stringified

Result: process.env.NEXT_DEPLOYMENT_ID is set to something like "() => 'my-deployment-' + Date.now()" instead of the actual evaluated deployment ID string

Expected: The function should be evaluated using generateDeploymentId() (which already exists and handles this case) to produce a proper string value

Root cause: While the build process correctly calls generateDeploymentId() to evaluate function-based deploymentId values (build/index.ts:938), the following paths do not:

  • build/analyze/index.ts:58 - loads config and uses deploymentId directly
  • server/lib/router-server.ts:102 - loads config for dev/production server without evaluating
  • build/turbopack-build/impl.ts - worker process loads fresh config without evaluating
  • build/webpack-build/impl.ts - worker process loads fresh config without evaluating

All have been fixed to call generateDeploymentId() with the config's deploymentId value before use.

Reference: Next.js config schema allows deploymentId as function, with documented examples showing async functions like async () => { const res = await fetch(...); return res.text(); }

Fix on Vercel

Copy link
Contributor

@vercel vercel bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

The code at line 464 doesn't check if deploymentId is a string before using it, unlike the rest of the codebase. If a user provides a function for deploymentId in their configuration, it would be passed directly to process.env.NEXT_DEPLOYMENT_ID instead of being handled gracefully.

View Details
📝 Patch Details
diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts
index 81c76b276e..3ecc77aa3d 100644
--- a/packages/next/src/server/base-server.ts
+++ b/packages/next/src/server/base-server.ts
@@ -461,8 +461,9 @@ export default abstract class Server<
     } else {
       let id = this.nextConfig.experimental.useSkewCookie
         ? ''
-        : this.nextConfig.deploymentId || ''
-
+        : typeof this.nextConfig.deploymentId === 'string'
+          ? this.nextConfig.deploymentId
+          : ''
       deploymentId = id
       process.env.NEXT_DEPLOYMENT_ID = id
     }

Analysis

Missing type check for deploymentId in BaseServer constructor

What fails: BaseServer constructor at line 464 does not check if deploymentId is a string before assigning it to process.env.NEXT_DEPLOYMENT_ID, allowing a function to be coerced to a string like "[Function: deploymentId]".

How to reproduce:

  1. Create a next.config.js with a function for deploymentId:
module.exports = {
  deploymentId: () => 'my-deployment-id'
}
  1. Start the dev server or production server without a prior build that would normalize the config
  2. Check process.env.NEXT_DEPLOYMENT_ID

Result: process.env.NEXT_DEPLOYMENT_ID contains the string representation of the function (e.g., "() => 'my-deployment-id'") instead of an empty string or a properly evaluated ID.

Expected: Following the Next.js config documentation, deploymentId can be either a string or a function. When it's a function, it should be treated similarly to how other parts of the codebase handle it: with a typeof deploymentId === 'string' type check before using it.

The fix adds the proper type check to match the pattern used elsewhere in base-server.ts (lines 525-526, 534-535) and next-server.ts (lines 299-300, 734-735).

Fix on Vercel

@philwolstenholme
Copy link
Contributor

philwolstenholme commented Jan 3, 2026

@brookemosby Is this a step towards allowing Next projects built locally/on a non-Vercel CI with npx vercel build && npx vercel deploy --prebuilt to use skew protection and mitigate the downsides described at https://vercel.com/docs/cli/deploy#when-not-to-use---prebuilt ? 👀 👀 👀 Or is it for non-Vercel platforms that want to implement skew protection?

If it's relevant to Vercel customers then we'd be happy to offer feedback, we're a Vercel enterprise customer that uses --prebuilt and have always been disappointed that it prevents us from using skew protection, rolling releases etc. We're in the #enterprise-cooperative-group (or something like that) channel on the Vercel Slack.

brookemosby and others added 4 commits January 5, 2026 08:52
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
Copy link
Contributor

@vercel vercel bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

deploymentId function not evaluated before string interpolation in cookie header

View Details
📝 Patch Details
diff --git a/packages/next/src/lib/load-custom-routes.ts b/packages/next/src/lib/load-custom-routes.ts
index 3ea01ce13a..2108696acd 100644
--- a/packages/next/src/lib/load-custom-routes.ts
+++ b/packages/next/src/lib/load-custom-routes.ts
@@ -726,12 +726,16 @@ export default async function loadCustomRoutes(
   }
 
   if (config.experimental?.useSkewCookie && config.deploymentId) {
+    const deploymentId =
+      typeof config.deploymentId === 'function'
+        ? config.deploymentId()
+        : config.deploymentId
     headers.unshift({
       source: '/:path*',
       headers: [
         {
           key: 'Set-Cookie',
-          value: `__vdpl=${config.deploymentId}; Path=/; HttpOnly`,
+          value: `__vdpl=${deploymentId}; Path=/; HttpOnly`,
         },
       ],
     })

Analysis

The code at packages/next/src/lib/load-custom-routes.ts line 734 directly interpolates config.deploymentId into a template literal for the Set-Cookie header value. The type of config.deploymentId is string | (() => string) | undefined. When a function is passed as the deploymentId, the template literal interpolation converts it to the string "() => string" or "[Function]" instead of executing the function to get the actual ID value.

This creates an invalid cookie header like __vdpl=() => 'git-hash-abc123'; Path=/; HttpOnly instead of the intended __vdpl=git-hash-abc123; Path=/; HttpOnly.

The fix checks if deploymentId is a function before using it in the template literal. If it is a function, it calls the function to get the string value. If it's already a string, it uses it directly. This ensures the cookie header always contains the actual deployment ID string value, not the function representation.

@brookemosby brookemosby requested a review from ijjk January 12, 2026 18:04
@brookemosby brookemosby requested a review from styfle January 12, 2026 21:25
@ijjk ijjk merged commit 0daf2b5 into canary Jan 12, 2026
160 checks passed
@ijjk ijjk deleted the brooke/addAutomaticDeploymentId branch January 12, 2026 21:27
ijjk pushed a commit that referenced this pull request Jan 12, 2026
ijjk added a commit that referenced this pull request Jan 22, 2026
…8496)

Original [PR](#88012) has a bug
that attempted to validate on environment variables. Edited function to
not run validations on environment variables, only on the user provided
deployment id.

<!-- Thanks for opening a PR! Your contribution is much appreciated.
To make sure your PR is handled as smoothly as possible we request that
you follow the checklist sections below.
Choose the right checklist for the change(s) that you're making:

## For Contributors

### Improving Documentation

- Run `pnpm prettier-fix` to fix formatting issues before opening the
PR.
- Read the Docs Contribution Guide to ensure your contribution follows
the docs guidelines:
https://nextjs.org/docs/community/contribution-guide

### Fixing a bug

- Related issues linked using `fixes #number`
- Tests added. See:
https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md

### Adding a feature

- Implements an existing feature request or RFC. Make sure the feature
request has been accepted for implementation before opening a PR. (A
discussion must be opened, see
https://github.com/vercel/next.js/discussions/new?category=ideas)
- Related issues/discussions are linked using `fixes #number`
- e2e tests added
(https://github.com/vercel/next.js/blob/canary/contributing/core/testing.md#writing-tests-for-nextjs)
- Documentation added
- Telemetry added. In case of a feature if it's used or not.
- Errors have a helpful link attached, see
https://github.com/vercel/next.js/blob/canary/contributing.md


## For Maintainers

- Minimal description (aim for explaining to someone not on the team to
understand the PR)
- When linking to a Slack thread, you might want to share details of the
conclusion
- Link both the Linear (Fixes NEXT-xxx) and the GitHub issues
- Add review comments if necessary to explain to the reviewer the logic
behind a change

### What?

### Why?

### How?

Closes NEXT-
Fixes #

-->

---------

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: JJ Kasper <jj@jjsweb.site>
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Jan 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

CI approved Approve running CI for fork Documentation Related to Next.js' official documentation. locked tests type: next

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants