Repository navigation
Conversation
…S_SECRET Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ut new options Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…secret Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On Vercel, or locally after `vercel env pull`, verify override tokens which the Vercel Toolbar signs with Vercel KMS, alongside FLAGS_SECRET tokens. Share the host project lookup and the JWKS cache with the platform encryption of the flag values. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Co-Authored-By: Luis Meyer <41523937+luismeyer@users.noreply.github.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lues Rename vercel-overrides to flag-overrides-platform, and use the same shape as flag-values-platform: an exported issuer, JWKS URL and frozen policy, a publicKeys cache, and the FromPlatform/ForPlatform names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Move the verifier of Vercel-signed tokens into vercel-platform, and the flags discovery issuer and policy into flags-discovery-platform, next to flag-overrides-platform and flag-values-platform. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Apps which read the vercel-flag-overrides cookie themselves call decryptOverrides, so it verifies overrides signed by Vercel KMS too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rcel-platform Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
chriswdmr
left a comment
There was a problem hiding this comment.
LGTM, nothing blocks this PR. Things to consider:
- Keep
verifyAccessworking where there is noprocessglobal (the cause isisVercelSignedTokenfrom #587) - Update the
verifyAccessdocs for the Vercel-signed token - Warn one time when there is no secret outside Vercel
The rest are three questions about the tokens and three test nits.
| authHeader.replace(/^Bearer /i, ''), | ||
| secret, | ||
| ); | ||
| if (isVercelSignedToken(token)) { |
There was a problem hiding this comment.
issue (non-blocking): verifyAccess throws when there is no process global
isVercelSignedToken reads process.env before it checks the token. In a runtime with no process global, verifyAccess(header, secret) now throws ReferenceError: process is not defined. On the base branch, the same call returns true for a valid proof. I ran the built package with process removed to confirm this.
The cause is in vercel-platform.ts from #587, so the fix can go there:
return (
token.split('.').length === 3 &&
typeof process !== 'undefined' &&
Boolean(process.env?.VERCEL || process.env?.VERCEL_OIDC_TOKEN)
);| "flags": minor | ||
| --- | ||
|
|
||
| `verifyAccess` accepts short-lived tokens which Vercel signs with Vercel KMS, so the Flags Discovery Endpoint (`/.well-known/vercel/flags`) works on Vercel without a `FLAGS_SECRET`. These tokens are signed by a separate Vercel KMS issuer, not the issuer of signed overrides. |
There was a problem hiding this comment.
suggestion (non-blocking): Update the verifyAccess docs
apps/docs/content/docs/api-reference/core/core.mdx still says that verifyAccess works "based on encrypted tokens (JWEs)". Can we add these points?
- The token which Vercel signs
FLAGS_SECRETis optional on Vercel- The result is
false, not an error, when there is no secret
The createFlagsDiscoveryEndpoint section in next.mdx can use the same note. #586 did the same for encryptFlagValues.
| } | ||
|
|
||
| return valid; | ||
| if (!secret) return false; |
There was a problem hiding this comment.
suggestion (non-blocking): Warn one time when there is no secret outside Vercel
Before, an app with no FLAGS_SECRET got a clear error. Now the caller gets a 401 with no hint. This applies in these cases:
- A self-hosted app with no
FLAGS_SECRET - Local development with no
vercel env pull - A project that does not expose the system environment variables
A one-time console.warn here would keep the hint. Use it only when there is no secret and the host cannot verify signed tokens (no VERCEL and no VERCEL_OIDC_TOKEN). Then a request with a random header on Vercel does not cause a wrong warning.
| * You can use verifyAccess to keep this endpoint private, to avoid public access of your feature flag definitions through that endpoint. | ||
| * | ||
| * It accepts a proof encrypted with the FLAGS_SECRET, and, on Vercel (or locally after `vercel env pull`), | ||
| * a short-lived token which Vercel signs for this project and environment. The latter works without a FLAGS_SECRET. |
There was a problem hiding this comment.
question: Apps on an older flags version
An app on an older flags version that has FLAGS_SECRET treats a signed token as an encrypted proof, and responds with 401. Do such apps continue to get the encrypted proof?
| */ | ||
| export const FLAGS_DISCOVERY_POLICY = Object.freeze({ | ||
| algorithm: 'ES256', | ||
| audience: 'vercel:flags-discovery', |
There was a problem hiding this comment.
question: Is this audience only for discovery tokens?
The PR description says that more than one signer uses this issuer. The verifier keeps tokens apart only by aud, pur and ver. Can you confirm that only discovery tokens get aud: 'vercel:flags-discovery'?
| }), | ||
| ], | ||
| [ | ||
| 'a different environment', |
There was a problem hiding this comment.
question: Custom environments
The host reads VERCEL_TARGET_ENV before VERCEL_ENV. On a custom environment, for example staging, a token with environments: ['preview'] is rejected, and only ['staging'] is accepted. I confirmed this with a local test. Does the token scope contain the name of the custom environment? If yes, a test case here would record that contract.
| const OVERRIDES_ISSUER = | ||
| 'https://kms.vercel.com/64af96a2-ba60-42ef-91af-459fcef6f864'; | ||
|
|
||
| // The id of the flags-keys-issuer is not known yet, see |
There was a problem hiding this comment.
nitpick (non-blocking): Use the real issuer
The real issuer id is now set, so this comment is stale. fetch is already stubbed, so the mock is not necessary. I removed the mock locally and used the real constant, and all 31 tests pass. Then the test uses the flags-keys issuer (line 239) can go too, because it only checks a constant. The same applies to sveltekit/without-secret.test.ts:23.
| }); | ||
| }); | ||
|
|
||
| describe('createFlagsDiscoveryEndpoint without FLAGS_SECRET', () => { |
There was a problem hiding this comment.
nitpick (non-blocking): Test placement
The createFlagsDiscoveryEndpoint tests for Next.js fit better in src/next/index.test.ts. The test that calls verifyVercelSignedToken with an incorrect issuer (line 248) checks code from #587, so it fits better with the verifier tests there.
| version: 1, | ||
| maxLifetimeSeconds: 300, | ||
| clockToleranceSeconds: 5, | ||
| maxTokenLength: 16_384, |
There was a problem hiding this comment.
nitpick (non-blocking): No test for maxTokenLength
I changed this value to 10 MB locally, and all tests continued to pass. The other values of this policy each have a test. One case with a token above 16 KiB would close the gap.
What / why
Lets v4 apps serve
/.well-known/vercel/flagsto Vercel withoutFLAGS_SECRET.verifyAccessnow also accepts a short-lived JWS that Vercel KMS signs. The existing FLAGS_SECRET JWE proof path still works the same way.verifyAccess(authHeader, secret = process.env.FLAGS_SECRET): the signature is unchanged. It stripsBeareras before. IfisVercelSignedTokensays the token is a JWS the app can verify, the token goes to the KMS discovery verifier, whether or not FLAGS_SECRET is set. Any other token goes toverifyAccessProofas before.verifyAccessnow returnsfalseinstead of throwing.vercel-platform.ts: the shared verifierverifyVercelSignedToken(token, issuer, policy)(withisVercelSignedToken, the per-issuer JWKS caches, header checks andisInScope).flag-overrides-platform.ts([flags] accept Vercel-signed override tokens #587) calls it withFLAG_OVERRIDES_POLICY; the newflags-discovery-platform.tsholdsVERCEL_FLAGS_DISCOVERY_ISSUERandFLAGS_DISCOVERY_POLICY, whichverifyAccesspasses in. Overrides behave the same as before: they still use theflags-explorer-issuer.flags-keys-issuer(VERCEL_FLAGS_DISCOVERY_ISSUER). Both the toolbar and the internal flags-keys endpoint sign with it. There is one cached JWKS loader per issuer (${issuer}/jwks.json), so one issuer's keys never verify the other's tokens, and theissclaim must match exactly.createHandle: removed theif (!secret) return 401guard in front ofverifyAccess, so a no-secret app can serve discovery. The Next and SvelteKitcreateFlagsDiscoveryEndpointneeded no change. No other caller requires FLAGS_SECRET on the discovery path.Token contract
flags-keys-issuer(VERCEL_FLAGS_DISCOVERY_ISSUER=https://kms.vercel.com/d0efbb30-566d-48b6-b173-0062e54ec49c). Overrides stay onflags-explorer-issuerhttps://kms.vercel.com/64af96a2-ba60-42ef-91af-459fcef6f864. Thekidheader is required. Tokens withjku,jwk,x5uorcritare rejected.iss,iatandexpare required, withaud: 'vercel:flags-discovery',pur: 'flags-discovery'andver: 1.scope: { teamId, projectIds, environments }uses the sameisInScopeas overrides. The app's project must be inprojectIds. The team must match when it is known (see teamId below). The app's environment must be inenvironments. Unknown keys are rejected.exp - iat <= 300andmaxTokenAge300), with 5s clock tolerance and a max token length of 16 KiB.standard,src/overrides/vercel.ts), checked by reading the code: it rejects discovery tokens. jose checksaudience: 'vercel:flag-overrides'exactly,pur === 'flag-overrides'is required, and anoverridesobject is required.teamId cannot be enforced on deployments
isInScopeonly checksscope.teamIdwhen the team is known. The only source isVERCEL_OIDC_TOKENinprocess.env, which exists locally aftervercel env pulland during builds. On a deployment at runtime, the OIDC token is only a per-requestx-vercel-oidc-tokenheader, not an env var. None of the Vercel system env vars (VERCEL_PROJECT_ID,VERCEL_ENV,VERCEL_TARGET_ENV,VERCEL_DEPLOYMENT_ID,VERCEL_URL,VERCEL_GIT_*, …) contains the team id. So on deployments the team is not checked. The project id is globally unique and comes from the host, never from the token. The project id and the environment still bind the token, and both are enforced. This is now documented in code atisInScope, and nothing was weakened: the team is still enforced whenever it is known.Tests
packages/flags:pnpm type-check: okpnpm check(biome): no issuespnpm test: 15 files, 228 tests passedpnpm build: okpnpm attw: no problems foundNew
verify-access.test.ts(the JWKS fetch is mocked with a locally generated ES256 key):jwk/jkuheader, overrides token, wrong project/env/team, extra scope key, unknown project.verifyAccess, and an overrides token from the flags-keys issuer is rejected byverifyFlagOverridesFromPlatform, even when both JWKS serve the same key. Each issuer's keys are fetched from its own JWKS URL.VERCEL_FLAGS_DISCOVERY_ISSUERwith a test UUID issuer.falsewithout throwing.sveltekit/without-secret.test.ts:createHandleandcreateFlagsDiscoveryEndpointreturn 200 for a KMS token and 401 without one, all without a secret.Open questions
Should the team be read from the per-request
x-vercel-oidc-tokenheader (@vercel/request-context)? It would only add a restriction, but it isn't always present (OIDC can be disabled, and there are no headers at module scope). Itsowner_idwould also have to match what the signer puts inscope.teamId, for example on Hobby accounts. This PR doesn't do that.Behaviour change:
verifyAccesswithout a secret used to throw and now returnsfalse. Custom routes that relied on the throw now get a 401 instead of an error.Outside Vercel, without
VERCEL/VERCEL_OIDC_TOKEN, a JWS still goes to the JWE path, as in [flags] accept Vercel-signed override tokens #587. That means localnext devwithoutvercel env pullcan't use KMS tokens.SvelteKit
createFlagsDiscoveryEndpointstill falls back toprocess.env.FLAGS_SECRETrather than$env/static/private. That behaviour was already there and is unchanged.Snapshot:
flags@4.5.0-a5fdf7a-20261109093837, with the real issuer.🤖 Generated with Claude Code