[{"data":1,"prerenderedAt":1463},["ShallowReactive",2],{"blog-how-to/deploy-vercel-securely":3},{"id":4,"title":5,"body":6,"category":1431,"date":1432,"dateModified":1432,"description":1433,"draft":1434,"extension":1435,"faq":1436,"featured":1434,"headerVariant":1448,"image":1449,"keywords":1450,"meta":1451,"navigation":300,"ogDescription":1452,"ogTitle":1449,"path":1453,"readTime":1454,"schemaOrg":1455,"schemaType":1456,"seo":1457,"sitemap":1458,"stem":1459,"tags":1460,"twitterCard":1461,"__hash__":1462},"blog/blog/how-to/deploy-vercel-securely.md","How to Deploy Vercel Securely (2026)",{"type":7,"value":8,"toc":1422},"minimark",[9,26,29,43,48,51,148,255,493,503,507,514,745,760,766,843,848,852,859,862,893,899,903,917,920,1002,1005,1008,1012,1023,1026,1228,1231,1241,1245,1248,1267,1312,1320,1324,1327,1330,1354,1357,1391,1410,1418],[10,11,12,13,17,18,21,22,25],"p",{},"In April 2026, a wave of Next.js apps deployed on Vercel were found leaking API keys through a single naming mistake: ",[14,15,16],"code",{},"NEXT_PUBLIC_OPENAI_KEY",". Every visitor's browser downloaded the key as plain text. Most of those developers had done everything else right. They used Vercel's environment variables panel, added ",[14,19,20],{},".env"," to ",[14,23,24],{},".gitignore",", kept secrets out of git. The NEXT_PUBLIC_ prefix is what got them.",[10,27,28],{},"This guide covers that issue and six others that security scanners commonly flag on fresh Vercel deployments.",[30,31,32],"tldr",{},[10,33,34,35,38,39,42],{},"The biggest Vercel security risk is ",[14,36,37],{},"NEXT_PUBLIC_","-prefixed secrets baked into your client bundle. Beyond that: preview deployments are public by default, security headers aren't set automatically, and API routes under ",[14,40,41],{},"/api/"," are open HTTP endpoints. Fix each in about 30 minutes before your first real users arrive.",[44,45,47],"h2",{"id":46},"step-1-audit-next_public_-environment-variables","Step 1: Audit NEXT_PUBLIC_ Environment Variables",[10,49,50],{},"This is the check that matters most. Run it before every production deploy.",[52,53,55,61,145],"step",{"number":54},"1",[10,56,57],{},[58,59,60],"strong",{},"Find every NEXT_PUBLIC_ variable in your codebase.",[62,63,68],"pre",{"className":64,"code":65,"language":66,"meta":67,"style":67},"language-bash shiki shiki-themes github-light github-dark","# Find variables the app reads from process.env with the public prefix\ngrep -r \"NEXT_PUBLIC_\" src/ --include=\"*.ts\" --include=\"*.tsx\" --include=\"*.js\" --include=\"*.jsx\"\ngrep -r \"NEXT_PUBLIC_\" app/ --include=\"*.ts\" --include=\"*.tsx\" 2>/dev/null\n","bash","",[14,69,70,79,118],{"__ignoreMap":67},[71,72,75],"span",{"class":73,"line":74},"line",1,[71,76,78],{"class":77},"sJ8bj","# Find variables the app reads from process.env with the public prefix\n",[71,80,82,86,90,94,97,100,103,105,108,110,113,115],{"class":73,"line":81},2,[71,83,85],{"class":84},"sScJk","grep",[71,87,89],{"class":88},"sj4cs"," -r",[71,91,93],{"class":92},"sZZnC"," \"NEXT_PUBLIC_\"",[71,95,96],{"class":92}," src/",[71,98,99],{"class":88}," --include=",[71,101,102],{"class":92},"\"*.ts\"",[71,104,99],{"class":88},[71,106,107],{"class":92},"\"*.tsx\"",[71,109,99],{"class":88},[71,111,112],{"class":92},"\"*.js\"",[71,114,99],{"class":88},[71,116,117],{"class":92},"\"*.jsx\"\n",[71,119,121,123,125,127,130,132,134,136,138,142],{"class":73,"line":120},3,[71,122,85],{"class":84},[71,124,89],{"class":88},[71,126,93],{"class":92},[71,128,129],{"class":92}," app/",[71,131,99],{"class":88},[71,133,102],{"class":92},[71,135,99],{"class":88},[71,137,107],{"class":92},[71,139,141],{"class":140},"szBVR"," 2>",[71,143,144],{"class":92},"/dev/null\n",[10,146,147],{},"List each one. For every hit, ask: is this value a secret, or is it genuinely public data?",[52,149,151,156,241],{"number":150},"2",[10,152,153],{},[58,154,155],{},"Classify each variable.",[157,158,159,175],"table",{},[160,161,162],"thead",{},[163,164,165,169,172],"tr",{},[166,167,168],"th",{},"Variable",[166,170,171],{},"Public?",[166,173,174],{},"Action",[176,177,178,192,204,217,229],"tbody",{},[163,179,180,186,189],{},[181,182,183],"td",{},[14,184,185],{},"NEXT_PUBLIC_SITE_URL",[181,187,188],{},"Yes",[181,190,191],{},"Fine as-is",[163,193,194,199,201],{},[181,195,196],{},[14,197,198],{},"NEXT_PUBLIC_GA_MEASUREMENT_ID",[181,200,188],{},[181,202,203],{},"Fine: analytics IDs are public",[163,205,206,211,214],{},[181,207,208],{},[14,209,210],{},"NEXT_PUBLIC_OPENAI_API_KEY",[181,212,213],{},"No",[181,215,216],{},"Remove prefix, add server-side route",[163,218,219,224,226],{},[181,220,221],{},[14,222,223],{},"NEXT_PUBLIC_SUPABASE_SERVICE_KEY",[181,225,213],{},[181,227,228],{},"Remove prefix immediately",[163,230,231,236,238],{},[181,232,233],{},[14,234,235],{},"NEXT_PUBLIC_STRIPE_SECRET_KEY",[181,237,213],{},[181,239,240],{},"Remove prefix, use Route Handler",[10,242,243,244,247,248,251,252,254],{},"The ",[14,245,246],{},"NEXT_PUBLIC_SUPABASE_ANON_KEY"," is a genuine exception: Supabase's anon key is designed to be public and enforced by Row Level Security. The ",[14,249,250],{},"service_role"," key must never have ",[14,253,37],{},".",[52,256,258,263,266,486],{"number":257},"3",[10,259,260],{},[58,261,262],{},"Move secret variables to Route Handlers.",[10,264,265],{},"If your client code calls OpenAI directly, move that call server-side:",[62,267,271],{"className":268,"code":269,"language":270,"meta":67,"style":67},"language-typescript shiki shiki-themes github-light github-dark","// app/api/chat/route.ts\nimport OpenAI from \"openai\";\n\nconst client = new OpenAI({\n  apiKey: process.env.OPENAI_API_KEY, // No NEXT_PUBLIC_ prefix\n});\n\nexport async function POST(request: Request) {\n  const { message } = await request.json();\n  const response = await client.chat.completions.create({\n    model: \"gpt-4o\",\n    messages: [{ role: \"user\", content: message }],\n  });\n  return Response.json({ reply: response.choices[0].message.content });\n}\n","typescript",[14,272,273,278,296,302,323,338,344,349,380,410,430,442,454,460,480],{"__ignoreMap":67},[71,274,275],{"class":73,"line":74},[71,276,277],{"class":77},"// app/api/chat/route.ts\n",[71,279,280,283,287,290,293],{"class":73,"line":81},[71,281,282],{"class":140},"import",[71,284,286],{"class":285},"sVt8B"," OpenAI ",[71,288,289],{"class":140},"from",[71,291,292],{"class":92}," \"openai\"",[71,294,295],{"class":285},";\n",[71,297,298],{"class":73,"line":120},[71,299,301],{"emptyLinePlaceholder":300},true,"\n",[71,303,305,308,311,314,317,320],{"class":73,"line":304},4,[71,306,307],{"class":140},"const",[71,309,310],{"class":88}," client",[71,312,313],{"class":140}," =",[71,315,316],{"class":140}," new",[71,318,319],{"class":84}," OpenAI",[71,321,322],{"class":285},"({\n",[71,324,326,329,332,335],{"class":73,"line":325},5,[71,327,328],{"class":285},"  apiKey: process.env.",[71,330,331],{"class":88},"OPENAI_API_KEY",[71,333,334],{"class":285},", ",[71,336,337],{"class":77},"// No NEXT_PUBLIC_ prefix\n",[71,339,341],{"class":73,"line":340},6,[71,342,343],{"class":285},"});\n",[71,345,347],{"class":73,"line":346},7,[71,348,301],{"emptyLinePlaceholder":300},[71,350,352,355,358,361,364,367,371,374,377],{"class":73,"line":351},8,[71,353,354],{"class":140},"export",[71,356,357],{"class":140}," async",[71,359,360],{"class":140}," function",[71,362,363],{"class":84}," POST",[71,365,366],{"class":285},"(",[71,368,370],{"class":369},"s4XuR","request",[71,372,373],{"class":140},":",[71,375,376],{"class":84}," Request",[71,378,379],{"class":285},") {\n",[71,381,383,386,389,392,395,398,401,404,407],{"class":73,"line":382},9,[71,384,385],{"class":140},"  const",[71,387,388],{"class":285}," { ",[71,390,391],{"class":88},"message",[71,393,394],{"class":285}," } ",[71,396,397],{"class":140},"=",[71,399,400],{"class":140}," await",[71,402,403],{"class":285}," request.",[71,405,406],{"class":84},"json",[71,408,409],{"class":285},"();\n",[71,411,413,415,418,420,422,425,428],{"class":73,"line":412},10,[71,414,385],{"class":140},[71,416,417],{"class":88}," response",[71,419,313],{"class":140},[71,421,400],{"class":140},[71,423,424],{"class":285}," client.chat.completions.",[71,426,427],{"class":84},"create",[71,429,322],{"class":285},[71,431,433,436,439],{"class":73,"line":432},11,[71,434,435],{"class":285},"    model: ",[71,437,438],{"class":92},"\"gpt-4o\"",[71,440,441],{"class":285},",\n",[71,443,445,448,451],{"class":73,"line":444},12,[71,446,447],{"class":285},"    messages: [{ role: ",[71,449,450],{"class":92},"\"user\"",[71,452,453],{"class":285},", content: message }],\n",[71,455,457],{"class":73,"line":456},13,[71,458,459],{"class":285},"  });\n",[71,461,463,466,469,471,474,477],{"class":73,"line":462},14,[71,464,465],{"class":140},"  return",[71,467,468],{"class":285}," Response.",[71,470,406],{"class":84},[71,472,473],{"class":285},"({ reply: response.choices[",[71,475,476],{"class":88},"0",[71,478,479],{"class":285},"].message.content });\n",[71,481,483],{"class":73,"line":482},15,[71,484,485],{"class":285},"}\n",[10,487,488,489,492],{},"Your client code calls ",[14,490,491],{},"/api/chat",". The key stays on the server.",[494,495,496],"warning-box",{},[10,497,498,499,502],{},"Verify the fix worked. After deploying, open your app in a browser, press F12, go to Sources, and search for your API key value. If it appears in any ",[14,500,501],{},".js"," file, the variable is still in your bundle. Check whether you missed a reference or whether a build cache is serving the old bundle.",[44,504,506],{"id":505},"step-2-configure-security-headers","Step 2: Configure Security Headers",[10,508,509,510,513],{},"Vercel sets no security headers by default. Add them via ",[14,511,512],{},"vercel.json"," in your project root:",[62,515,518],{"className":516,"code":517,"language":406,"meta":67,"style":67},"language-json shiki shiki-themes github-light github-dark","{\n  \"headers\": [\n    {\n      \"source\": \"/(.*)\",\n      \"headers\": [\n        { \"key\": \"X-Content-Type-Options\", \"value\": \"nosniff\" },\n        { \"key\": \"X-Frame-Options\", \"value\": \"DENY\" },\n        { \"key\": \"Referrer-Policy\", \"value\": \"strict-origin-when-cross-origin\" },\n        {\n          \"key\": \"Strict-Transport-Security\",\n          \"value\": \"max-age=31536000; includeSubDomains\"\n        },\n        {\n          \"key\": \"Permissions-Policy\",\n          \"value\": \"camera=(), microphone=(), geolocation=()\"\n        },\n        {\n          \"key\": \"Content-Security-Policy\",\n          \"value\": \"default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.openai.com\"\n        }\n      ]\n    }\n  ]\n}\n",[14,519,520,525,533,538,551,558,584,606,628,633,645,655,660,664,675,684,689,694,706,716,722,728,734,740],{"__ignoreMap":67},[71,521,522],{"class":73,"line":74},[71,523,524],{"class":285},"{\n",[71,526,527,530],{"class":73,"line":81},[71,528,529],{"class":88},"  \"headers\"",[71,531,532],{"class":285},": [\n",[71,534,535],{"class":73,"line":120},[71,536,537],{"class":285},"    {\n",[71,539,540,543,546,549],{"class":73,"line":304},[71,541,542],{"class":88},"      \"source\"",[71,544,545],{"class":285},": ",[71,547,548],{"class":92},"\"/(.*)\"",[71,550,441],{"class":285},[71,552,553,556],{"class":73,"line":325},[71,554,555],{"class":88},"      \"headers\"",[71,557,532],{"class":285},[71,559,560,563,566,568,571,573,576,578,581],{"class":73,"line":340},[71,561,562],{"class":285},"        { ",[71,564,565],{"class":88},"\"key\"",[71,567,545],{"class":285},[71,569,570],{"class":92},"\"X-Content-Type-Options\"",[71,572,334],{"class":285},[71,574,575],{"class":88},"\"value\"",[71,577,545],{"class":285},[71,579,580],{"class":92},"\"nosniff\"",[71,582,583],{"class":285}," },\n",[71,585,586,588,590,592,595,597,599,601,604],{"class":73,"line":346},[71,587,562],{"class":285},[71,589,565],{"class":88},[71,591,545],{"class":285},[71,593,594],{"class":92},"\"X-Frame-Options\"",[71,596,334],{"class":285},[71,598,575],{"class":88},[71,600,545],{"class":285},[71,602,603],{"class":92},"\"DENY\"",[71,605,583],{"class":285},[71,607,608,610,612,614,617,619,621,623,626],{"class":73,"line":351},[71,609,562],{"class":285},[71,611,565],{"class":88},[71,613,545],{"class":285},[71,615,616],{"class":92},"\"Referrer-Policy\"",[71,618,334],{"class":285},[71,620,575],{"class":88},[71,622,545],{"class":285},[71,624,625],{"class":92},"\"strict-origin-when-cross-origin\"",[71,627,583],{"class":285},[71,629,630],{"class":73,"line":382},[71,631,632],{"class":285},"        {\n",[71,634,635,638,640,643],{"class":73,"line":412},[71,636,637],{"class":88},"          \"key\"",[71,639,545],{"class":285},[71,641,642],{"class":92},"\"Strict-Transport-Security\"",[71,644,441],{"class":285},[71,646,647,650,652],{"class":73,"line":432},[71,648,649],{"class":88},"          \"value\"",[71,651,545],{"class":285},[71,653,654],{"class":92},"\"max-age=31536000; includeSubDomains\"\n",[71,656,657],{"class":73,"line":444},[71,658,659],{"class":285},"        },\n",[71,661,662],{"class":73,"line":456},[71,663,632],{"class":285},[71,665,666,668,670,673],{"class":73,"line":462},[71,667,637],{"class":88},[71,669,545],{"class":285},[71,671,672],{"class":92},"\"Permissions-Policy\"",[71,674,441],{"class":285},[71,676,677,679,681],{"class":73,"line":482},[71,678,649],{"class":88},[71,680,545],{"class":285},[71,682,683],{"class":92},"\"camera=(), microphone=(), geolocation=()\"\n",[71,685,687],{"class":73,"line":686},16,[71,688,659],{"class":285},[71,690,692],{"class":73,"line":691},17,[71,693,632],{"class":285},[71,695,697,699,701,704],{"class":73,"line":696},18,[71,698,637],{"class":88},[71,700,545],{"class":285},[71,702,703],{"class":92},"\"Content-Security-Policy\"",[71,705,441],{"class":285},[71,707,709,711,713],{"class":73,"line":708},19,[71,710,649],{"class":88},[71,712,545],{"class":285},[71,714,715],{"class":92},"\"default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.openai.com\"\n",[71,717,719],{"class":73,"line":718},20,[71,720,721],{"class":285},"        }\n",[71,723,725],{"class":73,"line":724},21,[71,726,727],{"class":285},"      ]\n",[71,729,731],{"class":73,"line":730},22,[71,732,733],{"class":285},"    }\n",[71,735,737],{"class":73,"line":736},23,[71,738,739],{"class":285},"  ]\n",[71,741,743],{"class":73,"line":742},24,[71,744,485],{"class":285},[10,746,747,748,751,752,759],{},"Adjust the CSP ",[14,749,750],{},"connect-src"," to match the third-party APIs your app actually calls. Deploy, then run your app through ",[753,754,758],"a",{"href":755,"rel":756},"https://securityheaders.com",[757],"nofollow","securityheaders.com"," or CheckYourVibe to verify.",[10,761,762,763,373],{},"For Next.js, you can also set headers in ",[14,764,765],{},"next.config.js",[62,767,771],{"className":768,"code":769,"language":770,"meta":67,"style":67},"language-javascript shiki shiki-themes github-light github-dark","// next.config.js\nconst nextConfig = {\n  async headers() {\n    return [\n      {\n        source: \"/:path*\",\n        headers: [\n          { key: \"X-Frame-Options\", value: \"DENY\" },\n          { key: \"X-Content-Type-Options\", value: \"nosniff\" },\n        ],\n      },\n    ];\n  },\n};\n","javascript",[14,772,773,778,783,788,793,798,803,808,813,818,823,828,833,838],{"__ignoreMap":67},[71,774,775],{"class":73,"line":74},[71,776,777],{},"// next.config.js\n",[71,779,780],{"class":73,"line":81},[71,781,782],{},"const nextConfig = {\n",[71,784,785],{"class":73,"line":120},[71,786,787],{},"  async headers() {\n",[71,789,790],{"class":73,"line":304},[71,791,792],{},"    return [\n",[71,794,795],{"class":73,"line":325},[71,796,797],{},"      {\n",[71,799,800],{"class":73,"line":340},[71,801,802],{},"        source: \"/:path*\",\n",[71,804,805],{"class":73,"line":346},[71,806,807],{},"        headers: [\n",[71,809,810],{"class":73,"line":351},[71,811,812],{},"          { key: \"X-Frame-Options\", value: \"DENY\" },\n",[71,814,815],{"class":73,"line":382},[71,816,817],{},"          { key: \"X-Content-Type-Options\", value: \"nosniff\" },\n",[71,819,820],{"class":73,"line":412},[71,821,822],{},"        ],\n",[71,824,825],{"class":73,"line":432},[71,826,827],{},"      },\n",[71,829,830],{"class":73,"line":444},[71,831,832],{},"    ];\n",[71,834,835],{"class":73,"line":456},[71,836,837],{},"  },\n",[71,839,840],{"class":73,"line":462},[71,841,842],{},"};\n",[10,844,243,845,847],{},[14,846,512],{}," approach applies before Next.js processes the request. Use it for headers that should apply to all routes including static assets.",[44,849,851],{"id":850},"step-3-enable-deployment-protection-on-preview-deploys","Step 3: Enable Deployment Protection on Preview Deploys",[10,853,854,855,858],{},"Every pull request on Vercel generates a preview URL like ",[14,856,857],{},"my-app-git-pr-42-yourteam.vercel.app",". By default, anyone with that URL can access your app, including any staging or test data.",[10,860,861],{},"To lock it down:",[863,864,865,869,879,886],"ol",{},[866,867,868],"li",{},"Go to your Vercel project dashboard.",[866,870,871,872,875,876,254],{},"Click ",[58,873,874],{},"Settings"," > ",[58,877,878],{},"Deployment Protection",[866,880,881,882,885],{},"Enable ",[58,883,884],{},"Vercel Authentication",". Visitors must log in with a Vercel account to view any preview.",[866,887,888,889,892],{},"For extra protection on production, enable ",[58,890,891],{},"Password Protection"," (Pro plan feature).",[894,895,896],"tip-box",{},[10,897,898],{},"If your team uses preview deploys for stakeholder review and your stakeholders don't have Vercel accounts, use the \"Share\" link option in Vercel's dashboard instead of disabling protection entirely. Shared links have a time limit and don't expose the underlying URL.",[44,900,902],{"id":901},"step-4-scope-environment-variables-by-target","Step 4: Scope Environment Variables by Target",[10,904,905,906,334,909,912,913,916],{},"Vercel lets you assign each variable to one or more targets: ",[58,907,908],{},"Production",[58,910,911],{},"Preview",", and ",[58,914,915],{},"Development",". Most teams skip this and use the same key everywhere.",[10,918,919],{},"The right pattern:",[157,921,922,934],{},[160,923,924],{},[163,925,926,928,930,932],{},[166,927,168],{},[166,929,908],{},[166,931,911],{},[166,933,915],{},[176,935,936,950,970,986],{},[163,937,938,942,945,948],{},[181,939,940],{},[14,941,331],{},[181,943,944],{},"Live key",[181,946,947],{},"Test key",[181,949,947],{},[163,951,952,957,960,967],{},[181,953,954],{},[14,955,956],{},"STRIPE_SECRET_KEY",[181,958,959],{},"Live secret",[181,961,962,963,966],{},"Test secret (",[14,964,965],{},"sk_test_...",")",[181,968,969],{},"Test secret",[163,971,972,977,980,983],{},[181,973,974],{},[14,975,976],{},"DATABASE_URL",[181,978,979],{},"Prod DB",[181,981,982],{},"Staging DB",[181,984,985],{},"Local DB",[163,987,988,993,996,999],{},[181,989,990],{},[14,991,992],{},"NEXT_PUBLIC_GA_ID",[181,994,995],{},"Prod GA",[181,997,998],{},"Staging GA (or empty)",[181,1000,1001],{},"Empty",[10,1003,1004],{},"To set this in Vercel: when you add or edit a variable, click the checkbox for each environment. Uncheck Production for test credentials and uncheck Preview/Development for live credentials.",[10,1006,1007],{},"This limits blast radius. If a preview deploy is misconfigured and exposes a variable, it exposes a test key that can't charge real money or write to your production database.",[44,1009,1011],{"id":1010},"step-5-protect-public-api-routes","Step 5: Protect Public API Routes",[10,1013,1014,1015,1018,1019,1022],{},"Every file under ",[14,1016,1017],{},"app/api/"," or ",[14,1020,1021],{},"pages/api/"," is a public HTTP endpoint that anyone on the internet can call. A fresh Next.js app has several of these, and it's easy to forget they exist.",[10,1024,1025],{},"Minimum checks before every API route goes live:",[62,1027,1029],{"className":268,"code":1028,"language":270,"meta":67,"style":67},"// app/api/data/route.ts\nimport { NextRequest, NextResponse } from \"next/server\";\nimport { getCurrentUser } from \"@/lib/auth\"; // your auth helper\n\nexport async function GET(request: NextRequest) {\n  // 1. Check authentication before doing anything else\n  const user = await getCurrentUser(request);\n  if (!user) {\n    return NextResponse.json({ error: \"Unauthorized\" }, { status: 401 });\n  }\n\n  // 2. Only return data this user owns\n  const data = await db.query(\n    \"SELECT * FROM records WHERE user_id = $1\",\n    [user.id]  // parameterized query, not string interpolation\n  );\n\n  return NextResponse.json(data);\n}\n",[14,1030,1031,1036,1050,1068,1072,1094,1099,1116,1130,1155,1160,1164,1169,1189,1196,1204,1209,1213,1224],{"__ignoreMap":67},[71,1032,1033],{"class":73,"line":74},[71,1034,1035],{"class":77},"// app/api/data/route.ts\n",[71,1037,1038,1040,1043,1045,1048],{"class":73,"line":81},[71,1039,282],{"class":140},[71,1041,1042],{"class":285}," { NextRequest, NextResponse } ",[71,1044,289],{"class":140},[71,1046,1047],{"class":92}," \"next/server\"",[71,1049,295],{"class":285},[71,1051,1052,1054,1057,1059,1062,1065],{"class":73,"line":120},[71,1053,282],{"class":140},[71,1055,1056],{"class":285}," { getCurrentUser } ",[71,1058,289],{"class":140},[71,1060,1061],{"class":92}," \"@/lib/auth\"",[71,1063,1064],{"class":285},"; ",[71,1066,1067],{"class":77},"// your auth helper\n",[71,1069,1070],{"class":73,"line":304},[71,1071,301],{"emptyLinePlaceholder":300},[71,1073,1074,1076,1078,1080,1083,1085,1087,1089,1092],{"class":73,"line":325},[71,1075,354],{"class":140},[71,1077,357],{"class":140},[71,1079,360],{"class":140},[71,1081,1082],{"class":84}," GET",[71,1084,366],{"class":285},[71,1086,370],{"class":369},[71,1088,373],{"class":140},[71,1090,1091],{"class":84}," NextRequest",[71,1093,379],{"class":285},[71,1095,1096],{"class":73,"line":340},[71,1097,1098],{"class":77},"  // 1. Check authentication before doing anything else\n",[71,1100,1101,1103,1106,1108,1110,1113],{"class":73,"line":346},[71,1102,385],{"class":140},[71,1104,1105],{"class":88}," user",[71,1107,313],{"class":140},[71,1109,400],{"class":140},[71,1111,1112],{"class":84}," getCurrentUser",[71,1114,1115],{"class":285},"(request);\n",[71,1117,1118,1121,1124,1127],{"class":73,"line":351},[71,1119,1120],{"class":140},"  if",[71,1122,1123],{"class":285}," (",[71,1125,1126],{"class":140},"!",[71,1128,1129],{"class":285},"user) {\n",[71,1131,1132,1135,1138,1140,1143,1146,1149,1152],{"class":73,"line":382},[71,1133,1134],{"class":140},"    return",[71,1136,1137],{"class":285}," NextResponse.",[71,1139,406],{"class":84},[71,1141,1142],{"class":285},"({ error: ",[71,1144,1145],{"class":92},"\"Unauthorized\"",[71,1147,1148],{"class":285}," }, { status: ",[71,1150,1151],{"class":88},"401",[71,1153,1154],{"class":285}," });\n",[71,1156,1157],{"class":73,"line":412},[71,1158,1159],{"class":285},"  }\n",[71,1161,1162],{"class":73,"line":432},[71,1163,301],{"emptyLinePlaceholder":300},[71,1165,1166],{"class":73,"line":444},[71,1167,1168],{"class":77},"  // 2. Only return data this user owns\n",[71,1170,1171,1173,1176,1178,1180,1183,1186],{"class":73,"line":456},[71,1172,385],{"class":140},[71,1174,1175],{"class":88}," data",[71,1177,313],{"class":140},[71,1179,400],{"class":140},[71,1181,1182],{"class":285}," db.",[71,1184,1185],{"class":84},"query",[71,1187,1188],{"class":285},"(\n",[71,1190,1191,1194],{"class":73,"line":462},[71,1192,1193],{"class":92},"    \"SELECT * FROM records WHERE user_id = $1\"",[71,1195,441],{"class":285},[71,1197,1198,1201],{"class":73,"line":482},[71,1199,1200],{"class":285},"    [user.id]  ",[71,1202,1203],{"class":77},"// parameterized query, not string interpolation\n",[71,1205,1206],{"class":73,"line":686},[71,1207,1208],{"class":285},"  );\n",[71,1210,1211],{"class":73,"line":691},[71,1212,301],{"emptyLinePlaceholder":300},[71,1214,1215,1217,1219,1221],{"class":73,"line":696},[71,1216,465],{"class":140},[71,1218,1137],{"class":285},[71,1220,406],{"class":84},[71,1222,1223],{"class":285},"(data);\n",[71,1225,1226],{"class":73,"line":708},[71,1227,485],{"class":285},[10,1229,1230],{},"For unauthenticated public routes (contact forms, newsletter signups), add rate limiting. Without it, a simple loop can spam your database or trigger automated email sends.",[1232,1233,1234],"danger-box",{},[10,1235,1236,1237,1240],{},"Never build an API route that accepts a ",[14,1238,1239],{},"userId"," parameter from the request body and uses it without verifying the authenticated user owns that ID. This is an Insecure Direct Object Reference (IDOR), the most common API vulnerability in vibe-coded apps.",[44,1242,1244],{"id":1243},"step-6-custom-domain-and-https-setup","Step 6: Custom Domain and HTTPS Setup",[10,1246,1247],{},"When you connect a custom domain, Vercel provisions a TLS certificate automatically. A few things to check:",[52,1249,1250],{"number":54},[10,1251,1252,1255,1256,1259,1260,1263,1264,1266],{},[58,1253,1254],{},"Redirect www to apex (or vice versa)."," In Vercel's Domains settings, add both ",[14,1257,1258],{},"yourdomain.com"," and ",[14,1261,1262],{},"www.yourdomain.com",". Set one as the primary and Vercel will redirect the other automatically. Without this, ",[14,1265,1262],{}," may show a certificate warning if you only registered the apex domain.",[52,1268,1269,1278,1306],{"number":150},[10,1270,1271,1274,1275,1277],{},[58,1272,1273],{},"Set HSTS with a long max-age."," Add this to your ",[14,1276,512],{}," headers once you're confident HTTPS works everywhere:",[62,1279,1281],{"className":516,"code":1280,"language":406,"meta":67,"style":67},"{ \"key\": \"Strict-Transport-Security\", \"value\": \"max-age=31536000; includeSubDomains; preload\" }\n",[14,1282,1283],{"__ignoreMap":67},[71,1284,1285,1288,1290,1292,1294,1296,1298,1300,1303],{"class":73,"line":74},[71,1286,1287],{"class":285},"{ ",[71,1289,565],{"class":88},[71,1291,545],{"class":285},[71,1293,642],{"class":92},[71,1295,334],{"class":285},[71,1297,575],{"class":88},[71,1299,545],{"class":285},[71,1301,1302],{"class":92},"\"max-age=31536000; includeSubDomains; preload\"",[71,1304,1305],{"class":285}," }\n",[10,1307,243,1308,1311],{},[14,1309,1310],{},"preload"," directive submits your domain to browsers' built-in HSTS preload list. Once preloaded, browsers refuse plain HTTP connections even before the first visit. This prevents SSL stripping attacks on public Wi-Fi.",[52,1313,1314],{"number":257},[10,1315,1316,1319],{},[58,1317,1318],{},"Remove unused custom domains."," Every domain you add to Vercel can potentially serve your app. If you moved from an old domain, remove it from the project settings so it can't be claimed by someone else pointing DNS to Vercel.",[44,1321,1323],{"id":1322},"step-7-scan-before-going-public","Step 7: Scan Before Going Public",[10,1325,1326],{},"Before switching your DNS to point at the Vercel deployment, scan your preview URL. A scanner reads your app the way an attacker would: fetching the public pages, inspecting the JavaScript bundle, checking response headers.",[10,1328,1329],{},"CheckYourVibe specifically flags:",[1331,1332,1333,1338,1348,1351],"ul",{},[866,1334,1335,1337],{},[14,1336,37],{},"-prefixed variables visible in the bundle",[866,1339,1340,1341,334,1344,1347],{},"Missing ",[14,1342,1343],{},"Content-Security-Policy",[14,1345,1346],{},"X-Frame-Options",", and HSTS headers",[866,1349,1350],{},"Exposed API endpoints that return data without authentication",[866,1352,1353],{},"Common Next.js misconfigurations (open redirects in callback URLs, debug endpoints left active)",[10,1355,1356],{},"Run the scan on the preview URL first. That way, any findings are in a non-production environment, and you can fix them before real users arrive.",[1358,1359,1360,1367,1373,1379,1385],"faq-section",{},[1361,1362,1364],"faq-item",{"question":1363},"What does NEXT_PUBLIC_ actually do in Next.js on Vercel?",[10,1365,1366],{},"NEXT_PUBLIC_ tells Next.js to inline that variable's value into the client-side JavaScript bundle at build time. Vercel injects it during the CI/CD build, but it ends up as a plain-text string in the .js files served to every visitor. Any variable prefixed with NEXT_PUBLIC_ is public by design. Never use it for API keys, service account credentials, or any secret that isn't meant to be read by anyone.",[1361,1368,1370],{"question":1369},"Are Vercel preview deployments public by default?",[10,1371,1372],{},"Yes. Every preview deployment URL is publicly accessible without authentication. Anyone with the URL can load the app, which includes any NEXT_PUBLIC_-prefixed variables. Enable Deployment Protection under Project Settings to require Vercel login before a preview can be viewed.",[1361,1374,1376],{"question":1375},"Which security headers does Vercel add automatically?",[10,1377,1378],{},"None of the security-relevant ones. Vercel adds X-Vercel-Id for request tracking but does not set Content-Security-Policy, X-Frame-Options, HSTS, or X-Content-Type-Options by default. Configure all headers in vercel.json or next.config.js.",[1361,1380,1382],{"question":1381},"Should I use separate API keys for production and preview environments?",[10,1383,1384],{},"Yes. Use test credentials for Preview and Development targets, live credentials only in Production. This limits blast radius if a preview deploy is misconfigured and prevents accidental charges or writes to your production service during testing.",[1361,1386,1388],{"question":1387},"How do I add rate limiting to Vercel API routes?",[10,1389,1390],{},"Vercel has no built-in rate limiting. Use Upstash Redis with the @upstash/ratelimit package for serverless-compatible durable limiting, or use Cloudflare in front of Vercel and apply rate limit rules at the edge. For simple per-IP limiting on low-traffic routes, an in-memory sliding window in Edge Middleware works for small deployments.",[1392,1393,1394,1400,1405],"related-articles",{},[1395,1396],"related-card",{"description":1397,"href":1398,"title":1399},"Complete guide to locking down Vercel deployments after the April 2026 incident: NEXT_PUBLIC_ audit, preview deploy access control, headers, and env var hygiene.","/blog/best-practices/vercel","Vercel Security Best Practices",[1395,1401],{"description":1402,"href":1403,"title":1404},"Emergency guide for rotating a leaked Vercel API key: find NEXT_PUBLIC_ secrets in your bundle, rotate them, and move the call server-side.","/blog/how-to/fix-vercel-api-key-exposure","How to Fix Vercel API Key Exposure",[1395,1406],{"description":1407,"href":1408,"title":1409},"Pre-launch security checklist for Vercel deployments: env vars, preview protection, security headers, and API route hardening.","/blog/checklists/vercel-security-checklist","Vercel Security Checklist",[1411,1412,1415],"cta-box",{"href":1413,"label":1414},"/","Scan Your Vercel App",[10,1416,1417],{},"Check your Vercel deployment for NEXT_PUBLIC_ secrets in the bundle, missing security headers, and exposed API routes. Free scan, no signup required.",[1419,1420,1421],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":67,"searchDepth":81,"depth":81,"links":1423},[1424,1425,1426,1427,1428,1429,1430],{"id":46,"depth":81,"text":47},{"id":505,"depth":81,"text":506},{"id":850,"depth":81,"text":851},{"id":901,"depth":81,"text":902},{"id":1010,"depth":81,"text":1011},{"id":1243,"depth":81,"text":1244},{"id":1322,"depth":81,"text":1323},"how-to","2026-06-06","Seven steps to deploy your Vercel app securely: audit NEXT_PUBLIC_ leaks, configure security headers, lock down preview deployments, scope environment variables, and scan before launch.",false,"md",[1437,1439,1441,1443,1446],{"question":1363,"answer":1438},"NEXT_PUBLIC_ tells Next.js to inline that environment variable's value into the client-side JavaScript bundle at build time. Vercel injects it during the CI/CD build, but it ends up as a plain-text string in the .js files served to every visitor. Any variable prefixed with NEXT_PUBLIC_ is public by design. Never use it for API keys, service account credentials, or any secret that isn't meant to be readable by anyone.",{"question":1369,"answer":1440},"Yes. By default, every preview deployment URL (like my-app-git-feature-branch-team.vercel.app) is publicly accessible without authentication. Anyone with the URL can load the app, which includes your environment variables if any are NEXT_PUBLIC_-prefixed. Enable Deployment Protection under Project Settings to require Vercel login before a preview can be viewed.",{"question":1375,"answer":1442},"Vercel adds X-Vercel-Id for request tracking but does not add security headers like Content-Security-Policy, X-Frame-Options, or HSTS by default. You must configure all security headers yourself via vercel.json, next.config.js headers(), or Middleware. Missing headers will fail most security scanners and compliance audits.",{"question":1444,"answer":1445},"Should I use separate API keys for production and preview/development environments?","Yes. Use test/sandbox credentials for Preview and Development environments, and live/production credentials only in the Production target. This limits blast radius if a preview deploy is compromised and prevents accidental charges or data writes to your production service from a test run.",{"question":1387,"answer":1447},"Vercel doesn't include built-in rate limiting. Options: (1) Use Vercel's Edge Middleware with an in-memory sliding window for lightweight per-IP limiting; (2) Use Upstash Redis with @upstash/ratelimit for durable limiting across serverless function instances; (3) Use a reverse-proxy like Cloudflare in front of Vercel and apply rate limit rules there. For unauthenticated endpoints (contact forms, magic link requests), option 2 or 3 is more reliable.","yellow",null,"deploy vercel securely, vercel security setup, vercel next_public leak, vercel preview deploy protection, vercel security headers, vercel environment variables scoping, vercel deployment checklist 2026",{},"Deploy Vercel securely: audit NEXT_PUBLIC_ secrets, add vercel.json headers, enable Deployment Protection on previews, scope env vars by environment, and run a final scan.","/blog/how-to/deploy-vercel-securely","10 min read","[object Object]","HowTo",{"title":5,"description":1433},{"loc":1453},"blog/how-to/deploy-vercel-securely",[],"summary_large_image","5fH0ikqtUmNZJNIPgs4FjoztZTYyDY025ptawfeBZJs",1784736388826]