Vercel Env Management — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited Vercel Env Management (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Vercel is the source of truth for environment variables; your local .env* files are a cache of it, not the other way around. This skill keeps that cache honest: pull the right values for the right environment, add and remove vars without leaking secrets into git, and use OIDC so you never paste a long-lived cloud key anywhere. Get this wrong and you ship a preview build talking to a production database, or a NEXT_PUBLIC_ key that is stale because it was baked in at build.
This is one step in the vercel-platform ship-a-Next.js-app workflow. It is deliberately not a transcription of the vercel env man page — it sequences the commands into the order a real project needs them and flags the three mistakes that actually cost people an afternoon. Pair it with vercel-deploy-pipeline (which consumes these vars at build) and vercel-ai-gateway (which replaces a pile of per-provider keys with one).
Trigger eagerly: "sync my env", "pull env vars", "set a secret", "env var is undefined", "different value per environment", "my NEXT_PUBLIC var won't update", "keyless AWS/GCP access", "OIDC token".
Vercel scopes every variable to one or more environments:
vercel dev and pulled into your local .env.local.preview value to a single git branch with --git-branch.
staging) — named, long-lived targets you createin the dashboard; addressed by name everywhere a built-in environment is.
The same name (DATABASE_URL) holds a different value per environment. That is the whole point: preview points at a branch database, production at the real one.
Every vercel env command operates on the linked project. Link before anything else; it writes .vercel/ (which is gitignored by the CLI):
vercel linkvercel env pull writes the development variables to .env.local by default (Next.js, Astro, SvelteKit, Nuxt all read .env.local locally):
vercel env pull # -> .env.local (development env)
vercel env pull .env.local # explicit, same thing
vercel env pull --yes # overwrite without the confirm prompt (CI)To inspect what production or preview actually has — to debug an "it works locally but not in prod" gap — pull that environment into a throwaway file and diff:
vercel env pull .env.production.local --environment=production
vercel pull --environment=preview --git-branch=my-feature # branch-scoped previewvercel pull (no env) also pulls project settings + env into .vercel/; use it in CI before vercel build. For just the dotenv file locally, vercel env pull is the one you want.
Never hand-edit a value into .env.local and expect it to deploy — that file is a local cache and is gitignored. Add it to Vercel:
vercel env add DATABASE_URL production # prompts for the value
vercel env add DATABASE_URL preview # a different value for preview
vercel env add DATABASE_URL development # and for local `vercel dev`Mark anything credential-shaped sensitive so its value can never be read back out of the dashboard or API (write-only after creation):
vercel env add STRIPE_SECRET_KEY production --sensitivePipe a value in for scripting (avoids the interactive prompt), and --force to overwrite an existing one:
printf '%s' "$VALUE" | vercel env add API_TOKEN production --sensitive
vercel env add API_TOKEN production --force # replace existing valueAfter adding or changing a var, re-pull so your local cache matches: vercel env pull.
vercel env ls # all vars, which environments each targets
vercel env ls production # filter to one environment
vercel env rm OLD_TOKEN production # remove from one environment
vercel env rm OLD_TOKEN --yes # skip the confirm promptRemoving a var does not affect already-built deployments — it takes effect on the next build (see Step 6 for the redeploy rule).
Do not put a long-lived AWS/GCP/Azure key in an env var if you can avoid it. With Secure Backend Access enabled, Vercel issues a short-lived VERCEL_OIDC_TOKEN (present at build time and as a request header at runtime) that you exchange for temporary cloud credentials. Nothing long-lived is stored, and tokens rotate automatically. As of 2026 this is the recommended pattern for talking to a cloud provider from a Vercel Function.
Pull the token for local dev like any other dev var:
vercel link
vercel env pull # brings down VERCEL_OIDC_TOKEN among the dev varsExchange it for AWS credentials with the first-party provider — no AWS_ACCESS_KEY_ID in your env at all:
// app/api/report/route.ts
import { S3Client } from '@aws-sdk/client-s3';
import { awsCredentialsProvider } from '@vercel/oidc-aws-credentials-provider';
const s3 = new S3Client({
region: process.env.AWS_REGION,
credentials: awsCredentialsProvider({
roleArn: process.env.AWS_ROLE_ARN, // the IAM role Vercel's OIDC may assume
}),
});(As of 2026 the AWS provider lives in @vercel/oidc-aws-credentials-provider — npm i @aws-sdk/client-s3 @vercel/oidc-aws-credentials-provider. The older @vercel/functions/oidc import has been moved out.)
For GCP, feed getVercelOidcToken to a workload-identity external account client:
import { getVercelOidcToken } from '@vercel/oidc';
import { ExternalAccountClient } from 'google-auth-library';
const authClient = ExternalAccountClient.fromJSON({
type: 'external_account',
audience: `//iam.googleapis.com/projects/${process.env.GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/${process.env.GCP_WORKLOAD_IDENTITY_POOL_ID}/providers/${process.env.GCP_WORKLOAD_IDENTITY_POOL_PROVIDER_ID}`,
subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
token_url: 'https://sts.googleapis.com/v1/token',
service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${process.env.GCP_SERVICE_ACCOUNT_EMAIL}:generateAccessToken`,
subject_token_supplier: { getSubjectToken: getVercelOidcToken },
});The cloud-side setup (an IAM role / workload-identity pool that trusts Vercel's OIDC issuer with your team+project as the subject) is one-time and lives in your cloud console, not here. Once it trusts Vercel, you delete the stored key.
Aside: for AI providers specifically, you usually do not need any of this — the AI Gateway already accepts VERCEL_OIDC_TOKEN on Vercel and an AI_GATEWAY_API_KEY locally, so one credential covers every model. That belongs to vercel-ai-gateway.
This is the single most common env surprise. `NEXT_PUBLIC_`-prefixed variables are inlined into the client bundle at build time — their values are literally string-replaced into the JavaScript when the build runs. Consequences:
NEXT_PUBLIC_ value in the Vercel dashboard does nothing to adeployment that already built. You must redeploy to bake in the new value.
NEXT_PUBLIC_ var ships to the browser. Neverprefix a secret. (Marking it --sensitive does not make a NEXT_PUBLIC_ var private; the prefix wins.)
process.env inside Functions/Middleware, so updating them + redeploying — or in some cases just a new deployment — picks up the change without a rebuild caveat.
When someone reports "I updated the env var but the site still shows the old value," 90% of the time it is a NEXT_PUBLIC_ var and the fix is redeploy, not re-pull. Use the verifier below to catch leaks and stale-risk before they ship.
An env setup is done right only when all hold:
.env.local (and any pulled .env.*.local) is gitignored and was produced byvercel env pull, never hand-authored as the source of truth.
in — a value present only in production silently becomes undefined in preview.
--sensitive; no secret carries a NEXT_PUBLIC_prefix.
removed once the role trust is in place.
NEXT_PUBLIC_ var, the app was redeployed(not just re-pulled) before anyone trusts the new value.
.env.local / .env.production.local. They hold real secrets andare gitignored for a reason; pulling them is fine, committing them is a leak.
(vercel env add), then vercel env pull to refresh the cache.
NEXT_PUBLIC_ — it is shipped to every browser, and--sensitive cannot save it.
NEXT_PUBLIC_ value change to take effect without a redeploy; itwas inlined at build time.
a preview build with a production DATABASE_URL writes to prod.
does the same job with rotating short-lived credentials.
vercel-deploy-pipeline.vercel-edge-and-isr), WAF /bot rules (vercel-firewall-and-botid), or bundle/runtime perf (next-on-vercel-perf) — this skill stops at the variables themselves.
Self-contained Node script, no dependencies. Save as env_check.mjs and run from your repo root with node env_check.mjs. It parses .env.local, flags secrets that are publicly inlined, secrets that are not gitignored, and (optionally) any variable your code reads via process.env.X that is missing from the pulled file.
// Vercel env wiring linter. Run from repo root: node env_check.mjs
// Reads .env.local (or arg 1), checks it against your source for missing vars.
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join, extname } from 'node:path';
const envFile = process.argv[2] || '.env.local';
const SECRETY = /(SECRET|TOKEN|KEY|PASSWORD|PRIVATE|DSN|CREDENTIAL)/i;
function parseDotenv(text) {
const out = {};
for (const line of text.split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=/i);
if (m && !line.trimStart().startsWith('#')) out[m[1]] = true;
}
return out;
}
function gitignored(name) {
if (!existsSync('.gitignore')) return false;
const ig = readFileSync('.gitignore', 'utf8');
return ig.split('\n').some((l) => l.trim() === name || l.trim() === '.env*.local' || l.trim() === '.env.local');
}
function walk(dir, acc = []) {
for (const e of readdirSync(dir)) {
if (e === 'node_modules' || e === '.next' || e === '.git' || e === '.vercel') continue;
const p = join(dir, e);
const st = statSync(p);
if (st.isDirectory()) walk(p, acc);
else if (['.ts', '.tsx', '.js', '.jsx', '.mjs'].includes(extname(p))) acc.push(p);
}
return acc;
}
const problems = [];
const notes = [];
if (!existsSync(envFile)) {
console.error(`No ${envFile} found. Run: vercel link && vercel env pull`);
process.exit(1);
}
const present = parseDotenv(readFileSync(envFile, 'utf8'));
const names = Object.keys(present);
// 1) Secrets that are publicly inlined.
for (const n of names) {
if (n.startsWith('NEXT_PUBLIC_') && SECRETY.test(n)) {
problems.push(`${n}: secret-shaped but NEXT_PUBLIC_ -> inlined into the browser bundle.`);
}
}
// 2) The env file itself must be gitignored.
if (!gitignored(envFile)) {
problems.push(`${envFile} is not gitignored. Add ".env*.local" to .gitignore before committing.`);
}
// 3) Vars the code reads but the pulled file is missing.
const referenced = new Set();
for (const file of walk(process.cwd())) {
const src = readFileSync(file, 'utf8');
for (const m of src.matchAll(/process\.env\.([A-Z0-9_]+)/g)) referenced.add(m[1]);
}
const SYSTEM = /^(NODE_ENV|VERCEL|VERCEL_|CI|npm_)/;
for (const r of referenced) {
if (!present[r] && !SYSTEM.test(r)) {
notes.push(`${r}: read via process.env but absent from ${envFile}. Set it: vercel env add ${r} <env>, then re-pull.`);
}
}
console.log(`Checked ${names.length} vars in ${envFile}, ${referenced.size} process.env refs in source.`);
if (problems.length) {
console.log('\nPROBLEMS (fix before deploy):');
for (const p of problems) console.log(' x ' + p);
}
if (notes.length) {
console.log('\nMISSING LOCALLY (set on Vercel + re-pull, or these are server-only prod vars):');
for (const n of notes) console.log(' ? ' + n);
}
if (!problems.length && !notes.length) console.log('\nOK: no leaked secrets, file is gitignored, every referenced var is present.');
process.exit(problems.length ? 1 : 0);Given a .env.local that contains NEXT_PUBLIC_STRIPE_KEY=sk_live_... (a secret wrong-prefixed) and source that reads process.env.DATABASE_URL (never pulled):
Checked 6 vars in .env.local, 4 process.env refs in source.
PROBLEMS (fix before deploy):
x NEXT_PUBLIC_STRIPE_KEY: secret-shaped but NEXT_PUBLIC_ -> inlined into the browser bundle.
MISSING LOCALLY (set on Vercel + re-pull, or these are server-only prod vars):
? DATABASE_URL: read via process.env but absent from .env.local. Set it: vercel env add DATABASE_URL <env>, then re-pull.Read it: rename NEXT_PUBLIC_STRIPE_KEY to STRIPE_SECRET_KEY, re-add it as --sensitive, and read it only in a Function — never the client. Then vercel env add DATABASE_URL development (and preview/production) and vercel env pull so the local cache is complete. A clean run is the gate before you hand off to vercel-deploy-pipeline.
Keep this table in the repo (README or docs/env.md) so every var's scope, sensitivity, and inlining are documented. The verifier checks wiring; this checks intent.
VERCEL ENV INVENTORY. [FILL: project name]
NAME DEV PREVIEW PROD SENSITIVE CLIENT?(NEXT_PUBLIC) SOURCE
[FILL: DATABASE_URL] y y(branch) y y n Neon (Marketplace)
[FILL: STRIPE_SECRET_KEY] y y y y n Stripe dashboard
[FILL: NEXT_PUBLIC_URL] y y y n y (redeploy on change) computed per env
[FILL: AWS_ROLE_ARN] y y y n n OIDC role (no key stored)
RULES
- SENSITIVE=y -> added with `--sensitive`, write-only, never echoed.
- CLIENT=y -> NEXT_PUBLIC_, public + inlined at build; REDEPLOY to change.
- Any cloud credential -> prefer OIDC role over a stored key.
- .env*.local is gitignored; Vercel is the source of truth.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.