Skip to content

[flags] accept Vercel-signed tokens for flags discovery - #589

Open
luismeyer wants to merge 22 commits into
luismeyer/kms-signed-overridesfrom
luismeyer/kms-flags-discovery
Open

luismeyer wants to merge 22 commits into
luismeyer/kms-signed-overridesfrom
luismeyer/kms-flags-discovery

Conversation

@luismeyer

@luismeyer luismeyer commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Stacked on #587 (luismeyer/kms-signed-overrides). Once #587 merges, this will be retargeted to main.

What / why

Lets v4 apps serve /.well-known/vercel/flags to Vercel without FLAGS_SECRET. verifyAccess now 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 strips Bearer as before. If isVercelSignedToken says 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 to verifyAccessProof as before.
  • Without a secret, verifyAccess now returns false instead of throwing.
  • vercel-platform.ts: the shared verifier verifyVercelSignedToken(token, issuer, policy) (with isVercelSignedToken, the per-issuer JWKS caches, header checks and isInScope). flag-overrides-platform.ts ([flags] accept Vercel-signed override tokens #587) calls it with FLAG_OVERRIDES_POLICY; the new flags-discovery-platform.ts holds VERCEL_FLAGS_DISCOVERY_ISSUER and FLAGS_DISCOVERY_POLICY, which verifyAccess passes in. Overrides behave the same as before: they still use the flags-explorer-issuer.
  • Separate issuer for discovery: discovery tokens are verified against the new 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 the iss claim must match exactly.
  • SvelteKit createHandle: removed the if (!secret) return 401 guard in front of verifyAccess, so a no-secret app can serve discovery. The Next and SvelteKit createFlagsDiscoveryEndpoint needed no change. No other caller requires FLAGS_SECRET on the discovery path.

Token contract

  • JWS, ES256, issuer flags-keys-issuer (VERCEL_FLAGS_DISCOVERY_ISSUER = https://kms.vercel.com/d0efbb30-566d-48b6-b173-0062e54ec49c). Overrides stay on flags-explorer-issuer https://kms.vercel.com/64af96a2-ba60-42ef-91af-459fcef6f864. The kid header is required. Tokens with jku, jwk, x5u or crit are rejected.
  • iss, iat and exp are required, with aud: 'vercel:flags-discovery', pur: 'flags-discovery' and ver: 1.
  • scope: { teamId, projectIds, environments } uses the same isInScope as overrides. The app's project must be in projectIds. The team must match when it is known (see teamId below). The app's environment must be in environments. Unknown keys are rejected.
  • Max lifetime is 300s (exp - iat <= 300 and maxTokenAge 300), with 5s clock tolerance and a max token length of 16 KiB.
  • Purpose binding: the discovery verifier rejects overrides tokens and the overrides verifier rejects discovery tokens. Both directions are tested.
  • v5 (standard, src/overrides/vercel.ts), checked by reading the code: it rejects discovery tokens. jose checks audience: 'vercel:flag-overrides' exactly, pur === 'flag-overrides' is required, and an overrides object is required.

teamId cannot be enforced on deployments

isInScope only checks scope.teamId when the team is known. The only source is VERCEL_OIDC_TOKEN in process.env, which exists locally after vercel env pull and during builds. On a deployment at runtime, the OIDC token is only a per-request x-vercel-oidc-token header, 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 at isInScope, and nothing was weakened: the team is still enforced whenever it is known.

Tests

packages/flags:

  • pnpm type-check: ok
  • pnpm check (biome): no issues
  • pnpm test: 15 files, 228 tests passed
  • pnpm build: ok
  • pnpm attw: no problems found

New verify-access.test.ts (the JWKS fetch is mocked with a locally generated ES256 key):

  • A valid token is accepted, both without and with a secret.
  • Rejected tokens: expired, lifetime over 300s, wrong iss/aud/pur/ver, wrong key, HS256, jwk/jku header, overrides token, wrong project/env/team, extra scope key, unknown project.
  • The overrides verifier rejects a discovery token.
  • Issuer separation: a discovery token from the overrides issuer is rejected by verifyAccess, and an overrides token from the flags-keys issuer is rejected by verifyFlagOverridesFromPlatform, even when both JWKS serve the same key. Each issuer's keys are fetched from its own JWKS URL.
  • Empty or malformed issuers reject everything and fetch no keys. Tests mock VERCEL_FLAGS_DISCOVERY_ISSUER with a test UUID issuer.
  • The JWE proof works as before, with a secret passed in or from env; a proof made with a different secret is rejected.
  • No secret with a JWE returns false without throwing.
  • The Next endpoint returns 200/401 without FLAGS_SECRET.

sveltekit/without-secret.test.ts: createHandle and createFlagsDiscoveryEndpoint return 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-token header (@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). Its owner_id would also have to match what the signer puts in scope.teamId, for example on Hobby accounts. This PR doesn't do that.

  • Behaviour change: verifyAccess without a secret used to throw and now returns false. 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 local next dev without vercel env pull can't use KMS tokens.

  • SvelteKit createFlagsDiscoveryEndpoint still falls back to process.env.FLAGS_SECRET rather 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

luismeyer and others added 5 commits November 2, 2026 18:03
…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>
@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
flags-playground Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-dev Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-flagsmith Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-growthbook Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-launchdarkly Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-openfeature Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-posthog Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-reflag Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-snippets Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-statsig Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
flags-sdk-sveltekit-snippets Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
shirt-shop Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC
shirt-shop-api Ready Ready Preview, v0 Nov 11, 2026 11:25am UTC

@vercel
vercel Bot temporarily deployed to Preview – flags-sdk-statsig November 4, 2026 14:01 Inactive
@vercel
vercel Bot temporarily deployed to Preview – flags-sdk-posthog November 4, 2026 14:01 Inactive
@vercel
vercel Bot temporarily deployed to Preview – flags-sdk-growthbook November 4, 2026 14:01 Inactive
@vercel
vercel Bot temporarily deployed to Preview – flags-sdk-openfeature November 4, 2026 14:01 Inactive
@vercel
vercel Bot temporarily deployed to Preview – flags-sdk-vercel November 4, 2026 14:01 Inactive
luismeyer and others added 2 commits November 5, 2026 12:09
…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>
luismeyer and others added 7 commits November 6, 2026 16:00
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 chriswdmr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, nothing blocks this PR. Things to consider:

  • Keep verifyAccess working where there is no process global (the cause is isVercelSignedToken from #587)
  • Update the verifyAccess docs 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.

Comment thread packages/flags/src/lib/verify-access.ts Resolved
Comment thread .changeset/kms-flags-discovery.md Resolved
Comment thread packages/flags/src/lib/verify-access.ts Resolved
* 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes. Both senders look at the secret, not at the SDK version:

  • The service that discovers the flags of a deployment sends the encrypted proof when the deployment has a FLAGS_SECRET, and requests a signed token only when it has none
  • The toolbar gets no signed token for a project that has a FLAGS_SECRET

So an app with a secret continues to get the encrypted proof on each flags version.

*/
export const FLAGS_DISCOVERY_POLICY = Object.freeze({
algorithm: 'ES256',
audience: 'vercel:flags-discovery',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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'?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes. Two signers use this issuer: the service that discovers the flags of a deployment, and the toolbar. Both sign only discovery tokens, with aud: 'vercel:flags-discovery' and pur: 'flags-discovery'. Signed overrides use a different issuer.

luismeyer and others added 2 commits November 11, 2026 11:17
…s-discovery

Co-Authored-By: Claude Code <noreply@anthropic.com>
- Warn when verifyAccess has no secret on a host which can not verify
  signed tokens
- SvelteKit: load $env in createFlagsDiscoveryEndpoint, so signed tokens
  work in vite dev
- Tests use the real issuer; remove the issuer mock
- Move the Next.js endpoint tests to src/next/index.test.ts
- Tests for maxTokenLength and custom environments

Co-Authored-By: Claude Code <noreply@anthropic.com>

@chriswdmr chriswdmr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thank you! Nothing more must change in this PR. The docs follow in #596.

This branch was successfully deployed

14 active deployments
Preview – flags-sdk-dev — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-sveltekit-snippets — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-playground — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-openfeature — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – shirt-shop — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-launchdarkly — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-snippets — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-flagsmith — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-growthbook — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-reflag — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-vercel — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-posthog — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – flags-sdk-statsig — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Preview – shirt-shop-api — 192fb8b6 Deployed Nov 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants