[{"data":1,"prerenderedAt":1449},["ShallowReactive",2],{"article:a-password-gate-for-a-static-site-in-one-edge-function":3,"\u002Fblog\u002Fa-password-gate-for-a-static-site-in-one-edge-function-surround":1442},{"id":4,"title":5,"archived":6,"author":7,"body":8,"date":1428,"description":1429,"extension":1430,"image":1431,"meta":1432,"minRead":364,"navigation":96,"path":1433,"published":96,"seo":1434,"sitemap":1435,"stem":1436,"tags":1437,"__hash__":1441},"blog\u002Fblog\u002Fa-password-gate-for-a-static-site-in-one-edge-function.md","A Password Gate for a Static Site in One Edge Function",false,"Baljeet Singh",{"type":9,"value":10,"toc":1420},"minimark",[11,15,18,21,24,29,37,148,151,177,180,184,191,574,585,591,594,757,764,771,950,968,971,979,993,997,1002,1076,1079,1089,1261,1264,1290,1309,1322,1326,1329,1343,1346,1350,1353,1363,1391,1397,1403,1407,1410,1413,1416],[12,13,14],"p",{},"I built a set of printable A4 forms for my household. Habit trackers, a weekly plan, a grocery sheet. Print, fill in by hand, throw away.",[12,16,17],{},"It's a static site. It has one user, or two if my wife counts, and there's nothing sensitive on it. But I didn't want it indexed, and I did not want a stranger stumbling onto my family's meal plan.",[12,19,20],{},"What I wanted was one password. Not accounts, not sign-up, not a database, not Auth0. Type a password once, get in for a month.",[12,22,23],{},"The whole gate is about 150 lines in one edge function, most of it the login page's markup. What follows is every part that carries weight — the config, the cookie, the verification and the two things I got wrong — rather than a listing you scroll past.",[25,26,28],"h2",{"id":27},"the-shape","The Shape",[12,30,31,32,36],{},"A Netlify edge function on ",[33,34,35],"code",{},"path: \"\u002F*\""," runs before every request:",[38,39,44],"pre",{"className":40,"code":41,"language":42,"meta":43,"style":43},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","import type { Config, Context } from \"@netlify\u002Fedge-functions\";\n\nexport const config: Config = {\n  path: \"\u002F*\",\n};\n","ts","",[33,45,46,91,98,123,142],{"__ignoreMap":43},[47,48,51,55,58,62,66,69,72,75,78,81,85,88],"span",{"class":49,"line":50},"line",1,[47,52,54],{"class":53},"s7zQu","import",[47,56,57],{"class":53}," type",[47,59,61],{"class":60},"sMK4o"," {",[47,63,65],{"class":64},"sTEyZ"," Config",[47,67,68],{"class":60},",",[47,70,71],{"class":64}," Context",[47,73,74],{"class":60}," }",[47,76,77],{"class":53}," from",[47,79,80],{"class":60}," \"",[47,82,84],{"class":83},"sfazB","@netlify\u002Fedge-functions",[47,86,87],{"class":60},"\"",[47,89,90],{"class":60},";\n",[47,92,94],{"class":49,"line":93},2,[47,95,97],{"emptyLinePlaceholder":96},true,"\n",[47,99,101,104,108,111,114,117,120],{"class":49,"line":100},3,[47,102,103],{"class":53},"export",[47,105,107],{"class":106},"spNyl"," const",[47,109,110],{"class":64}," config",[47,112,113],{"class":60},":",[47,115,65],{"class":116},"sBMFI",[47,118,119],{"class":60}," =",[47,121,122],{"class":60}," {\n",[47,124,126,130,132,134,137,139],{"class":49,"line":125},4,[47,127,129],{"class":128},"swJcz","  path",[47,131,113],{"class":60},[47,133,80],{"class":60},[47,135,136],{"class":83},"\u002F*",[47,138,87],{"class":60},[47,140,141],{"class":60},",\n",[47,143,145],{"class":49,"line":144},5,[47,146,147],{"class":60},"};\n",[12,149,150],{},"It does three things:",[152,153,154,162,171],"ol",{},[155,156,157,161],"li",{},[158,159,160],"strong",{},"Cookie present and valid?"," Return nothing — the request passes through to the static site.",[155,163,164,170],{},[158,165,166,169],{},[33,167,168],{},"POST \u002F__login"," with the right password?"," Set a signed cookie, redirect.",[155,172,173,176],{},[158,174,175],{},"Anything else?"," Serve a login page.",[12,178,179],{},"No session store, because the cookie carries its own proof.",[25,181,183],{"id":182},"the-cookie-is-the-session","The Cookie Is the Session",[12,185,186,187,190],{},"The cookie value is ",[33,188,189],{},"\u003Cexpiry>.\u003Csignature>",", where the signature is an HMAC of the expiry using a server-side secret:",[38,192,194],{"className":40,"code":193,"language":42,"meta":43,"style":43},"async function hmac(secret: string, message: string): Promise\u003Cstring> {\n  const enc = new TextEncoder();\n  const key = await crypto.subtle.importKey(\n    \"raw\",\n    enc.encode(secret),\n    { name: \"HMAC\", hash: \"SHA-256\" },\n    false,\n    [\"sign\"],\n  );\n  const sig = await crypto.subtle.sign(\"HMAC\", key, enc.encode(message));\n  return btoa(String.fromCharCode(...new Uint8Array(sig)))\n    .replace(\u002F=\u002Fg, \"\").replace(\u002F\\+\u002Fg, \"-\").replace(\u002F\\\u002F\u002Fg, \"_\");\n}\n",[33,195,196,246,267,296,308,327,362,371,389,397,449,484,568],{"__ignoreMap":43},[47,197,198,201,204,208,211,215,217,220,222,225,227,229,232,235,238,241,244],{"class":49,"line":50},[47,199,200],{"class":106},"async",[47,202,203],{"class":106}," function",[47,205,207],{"class":206},"s2Zo4"," hmac",[47,209,210],{"class":60},"(",[47,212,214],{"class":213},"sHdIc","secret",[47,216,113],{"class":60},[47,218,219],{"class":116}," string",[47,221,68],{"class":60},[47,223,224],{"class":213}," message",[47,226,113],{"class":60},[47,228,219],{"class":116},[47,230,231],{"class":60},"):",[47,233,234],{"class":116}," Promise",[47,236,237],{"class":60},"\u003C",[47,239,240],{"class":116},"string",[47,242,243],{"class":60},">",[47,245,122],{"class":60},[47,247,248,251,254,256,259,262,265],{"class":49,"line":93},[47,249,250],{"class":106},"  const",[47,252,253],{"class":64}," enc",[47,255,119],{"class":60},[47,257,258],{"class":60}," new",[47,260,261],{"class":206}," TextEncoder",[47,263,264],{"class":128},"()",[47,266,90],{"class":60},[47,268,269,271,274,276,279,282,285,288,290,293],{"class":49,"line":100},[47,270,250],{"class":106},[47,272,273],{"class":64}," key",[47,275,119],{"class":60},[47,277,278],{"class":53}," await",[47,280,281],{"class":64}," crypto",[47,283,284],{"class":60},".",[47,286,287],{"class":64},"subtle",[47,289,284],{"class":60},[47,291,292],{"class":206},"importKey",[47,294,295],{"class":128},"(\n",[47,297,298,301,304,306],{"class":49,"line":125},[47,299,300],{"class":60},"    \"",[47,302,303],{"class":83},"raw",[47,305,87],{"class":60},[47,307,141],{"class":60},[47,309,310,313,315,318,320,322,325],{"class":49,"line":144},[47,311,312],{"class":64},"    enc",[47,314,284],{"class":60},[47,316,317],{"class":206},"encode",[47,319,210],{"class":128},[47,321,214],{"class":64},[47,323,324],{"class":128},")",[47,326,141],{"class":60},[47,328,330,333,336,338,340,343,345,347,350,352,354,357,359],{"class":49,"line":329},6,[47,331,332],{"class":60},"    {",[47,334,335],{"class":128}," name",[47,337,113],{"class":60},[47,339,80],{"class":60},[47,341,342],{"class":83},"HMAC",[47,344,87],{"class":60},[47,346,68],{"class":60},[47,348,349],{"class":128}," hash",[47,351,113],{"class":60},[47,353,80],{"class":60},[47,355,356],{"class":83},"SHA-256",[47,358,87],{"class":60},[47,360,361],{"class":60}," },\n",[47,363,365,369],{"class":49,"line":364},7,[47,366,368],{"class":367},"sfNiH","    false",[47,370,141],{"class":60},[47,372,374,377,379,382,384,387],{"class":49,"line":373},8,[47,375,376],{"class":128},"    [",[47,378,87],{"class":60},[47,380,381],{"class":83},"sign",[47,383,87],{"class":60},[47,385,386],{"class":128},"]",[47,388,141],{"class":60},[47,390,392,395],{"class":49,"line":391},9,[47,393,394],{"class":128},"  )",[47,396,90],{"class":60},[47,398,400,402,405,407,409,411,413,415,417,419,421,423,425,427,429,431,433,435,437,439,441,444,447],{"class":49,"line":399},10,[47,401,250],{"class":106},[47,403,404],{"class":64}," sig",[47,406,119],{"class":60},[47,408,278],{"class":53},[47,410,281],{"class":64},[47,412,284],{"class":60},[47,414,287],{"class":64},[47,416,284],{"class":60},[47,418,381],{"class":206},[47,420,210],{"class":128},[47,422,87],{"class":60},[47,424,342],{"class":83},[47,426,87],{"class":60},[47,428,68],{"class":60},[47,430,273],{"class":64},[47,432,68],{"class":60},[47,434,253],{"class":64},[47,436,284],{"class":60},[47,438,317],{"class":206},[47,440,210],{"class":128},[47,442,443],{"class":64},"message",[47,445,446],{"class":128},"))",[47,448,90],{"class":60},[47,450,452,455,458,460,463,465,468,470,473,476,478,481],{"class":49,"line":451},11,[47,453,454],{"class":53},"  return",[47,456,457],{"class":206}," btoa",[47,459,210],{"class":128},[47,461,462],{"class":64},"String",[47,464,284],{"class":60},[47,466,467],{"class":206},"fromCharCode",[47,469,210],{"class":128},[47,471,472],{"class":60},"...new",[47,474,475],{"class":206}," Uint8Array",[47,477,210],{"class":128},[47,479,480],{"class":64},"sig",[47,482,483],{"class":128},")))\n",[47,485,487,490,493,495,498,501,503,507,509,512,514,516,518,520,522,525,527,529,531,533,536,538,540,542,544,546,548,551,553,555,557,559,562,564,566],{"class":49,"line":486},12,[47,488,489],{"class":60},"    .",[47,491,492],{"class":206},"replace",[47,494,210],{"class":128},[47,496,497],{"class":60},"\u002F",[47,499,500],{"class":83},"=",[47,502,497],{"class":60},[47,504,506],{"class":505},"sbssI","g",[47,508,68],{"class":60},[47,510,511],{"class":60}," \"\"",[47,513,324],{"class":128},[47,515,284],{"class":60},[47,517,492],{"class":206},[47,519,210],{"class":128},[47,521,497],{"class":60},[47,523,524],{"class":64},"\\+",[47,526,497],{"class":60},[47,528,506],{"class":505},[47,530,68],{"class":60},[47,532,80],{"class":60},[47,534,535],{"class":83},"-",[47,537,87],{"class":60},[47,539,324],{"class":128},[47,541,284],{"class":60},[47,543,492],{"class":206},[47,545,210],{"class":128},[47,547,497],{"class":60},[47,549,550],{"class":64},"\\\u002F",[47,552,497],{"class":60},[47,554,506],{"class":505},[47,556,68],{"class":60},[47,558,80],{"class":60},[47,560,561],{"class":83},"_",[47,563,87],{"class":60},[47,565,324],{"class":128},[47,567,90],{"class":60},[47,569,571],{"class":49,"line":570},13,[47,572,573],{"class":60},"}\n",[12,575,576,577,580,581,584],{},"That last line is base64url — strip the padding, swap ",[33,578,579],{},"+\u002F"," for ",[33,582,583],{},"-_",". Cookies don't take raw base64 happily, and you'll save yourself an hour by doing it up front.",[12,586,587,590],{},[33,588,589],{},"crypto.subtle"," is in the runtime already. No dependency, nothing to install, nothing to keep patched.",[12,592,593],{},"Verifying is the same operation backwards:",[38,595,597],{"className":40,"code":596,"language":42,"meta":43,"style":43},"const [expStr, sig] = value.split(\".\");\nconst exp = parseInt(expStr, 10);\nif (!Number.isFinite(exp) || Date.now() \u002F 1000 > exp) return false;\nconst expected = await hmac(secret, expStr);\nreturn timingSafeEqual(sig, expected);\n",[33,598,599,638,662,717,740],{"__ignoreMap":43},[47,600,601,604,607,610,612,614,616,618,621,623,626,628,630,632,634,636],{"class":49,"line":50},[47,602,603],{"class":106},"const",[47,605,606],{"class":60}," [",[47,608,609],{"class":64},"expStr",[47,611,68],{"class":60},[47,613,404],{"class":64},[47,615,386],{"class":60},[47,617,119],{"class":60},[47,619,620],{"class":64}," value",[47,622,284],{"class":60},[47,624,625],{"class":206},"split",[47,627,210],{"class":64},[47,629,87],{"class":60},[47,631,284],{"class":83},[47,633,87],{"class":60},[47,635,324],{"class":64},[47,637,90],{"class":60},[47,639,640,642,645,647,650,653,655,658,660],{"class":49,"line":93},[47,641,603],{"class":106},[47,643,644],{"class":64}," exp ",[47,646,500],{"class":60},[47,648,649],{"class":206}," parseInt",[47,651,652],{"class":64},"(expStr",[47,654,68],{"class":60},[47,656,657],{"class":505}," 10",[47,659,324],{"class":64},[47,661,90],{"class":60},[47,663,664,667,670,673,676,678,681,684,687,690,692,695,698,700,703,706,709,712,715],{"class":49,"line":100},[47,665,666],{"class":53},"if",[47,668,669],{"class":64}," (",[47,671,672],{"class":60},"!",[47,674,675],{"class":64},"Number",[47,677,284],{"class":60},[47,679,680],{"class":206},"isFinite",[47,682,683],{"class":64},"(exp) ",[47,685,686],{"class":60},"||",[47,688,689],{"class":64}," Date",[47,691,284],{"class":60},[47,693,694],{"class":206},"now",[47,696,697],{"class":64},"() ",[47,699,497],{"class":60},[47,701,702],{"class":505}," 1000",[47,704,705],{"class":60}," >",[47,707,708],{"class":64}," exp) ",[47,710,711],{"class":53},"return",[47,713,714],{"class":367}," false",[47,716,90],{"class":60},[47,718,719,721,724,726,728,730,733,735,738],{"class":49,"line":125},[47,720,603],{"class":106},[47,722,723],{"class":64}," expected ",[47,725,500],{"class":60},[47,727,278],{"class":53},[47,729,207],{"class":206},[47,731,732],{"class":64},"(secret",[47,734,68],{"class":60},[47,736,737],{"class":64}," expStr)",[47,739,90],{"class":60},[47,741,742,744,747,750,752,755],{"class":49,"line":144},[47,743,711],{"class":53},[47,745,746],{"class":206}," timingSafeEqual",[47,748,749],{"class":64},"(sig",[47,751,68],{"class":60},[47,753,754],{"class":64}," expected)",[47,756,90],{"class":60},[12,758,759,760,763],{},"Check expiry ",[158,761,762],{},"before"," computing the HMAC. An expired cookie is rejected on a cheap integer comparison rather than a signing operation.",[12,765,766,767,770],{},"That last line is not ",[33,768,769],{},"===",", and this is the one place the distinction earns its keep. The expiry in the cookie is attacker-chosen, so an attacker can ask the gate to check a signature over a message they picked. If the comparison returns early on the first wrong byte, the time it takes tells them how much of a signature they have right — and they can recover a valid one byte at a time without ever knowing the password. So compare every byte:",[38,772,774],{"className":40,"code":773,"language":42,"meta":43,"style":43},"function timingSafeEqual(a: string, b: string): boolean {\n  if (a.length !== b.length) return false;\n  let diff = 0;\n  for (let i = 0; i \u003C a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);\n  return diff === 0;\n}\n",[33,775,776,808,840,855,933,946],{"__ignoreMap":43},[47,777,778,781,783,785,788,790,792,794,797,799,801,803,806],{"class":49,"line":50},[47,779,780],{"class":106},"function",[47,782,746],{"class":206},[47,784,210],{"class":60},[47,786,787],{"class":213},"a",[47,789,113],{"class":60},[47,791,219],{"class":116},[47,793,68],{"class":60},[47,795,796],{"class":213}," b",[47,798,113],{"class":60},[47,800,219],{"class":116},[47,802,231],{"class":60},[47,804,805],{"class":116}," boolean",[47,807,122],{"class":60},[47,809,810,813,815,817,819,822,825,827,829,831,834,836,838],{"class":49,"line":93},[47,811,812],{"class":53},"  if",[47,814,669],{"class":128},[47,816,787],{"class":64},[47,818,284],{"class":60},[47,820,821],{"class":64},"length",[47,823,824],{"class":60}," !==",[47,826,796],{"class":64},[47,828,284],{"class":60},[47,830,821],{"class":64},[47,832,833],{"class":128},") ",[47,835,711],{"class":53},[47,837,714],{"class":367},[47,839,90],{"class":60},[47,841,842,845,848,850,853],{"class":49,"line":100},[47,843,844],{"class":106},"  let",[47,846,847],{"class":64}," diff",[47,849,119],{"class":60},[47,851,852],{"class":505}," 0",[47,854,90],{"class":60},[47,856,857,860,862,865,868,870,872,875,877,880,883,885,887,889,891,894,896,899,902,904,906,909,911,914,916,919,921,923,925,927,929,931],{"class":49,"line":125},[47,858,859],{"class":53},"  for",[47,861,669],{"class":128},[47,863,864],{"class":106},"let",[47,866,867],{"class":64}," i",[47,869,119],{"class":60},[47,871,852],{"class":505},[47,873,874],{"class":60},";",[47,876,867],{"class":64},[47,878,879],{"class":60}," \u003C",[47,881,882],{"class":64}," a",[47,884,284],{"class":60},[47,886,821],{"class":64},[47,888,874],{"class":60},[47,890,867],{"class":64},[47,892,893],{"class":60},"++",[47,895,833],{"class":128},[47,897,898],{"class":64},"diff",[47,900,901],{"class":60}," |=",[47,903,882],{"class":64},[47,905,284],{"class":60},[47,907,908],{"class":206},"charCodeAt",[47,910,210],{"class":128},[47,912,913],{"class":64},"i",[47,915,833],{"class":128},[47,917,918],{"class":60},"^",[47,920,796],{"class":64},[47,922,284],{"class":60},[47,924,908],{"class":206},[47,926,210],{"class":128},[47,928,913],{"class":64},[47,930,324],{"class":128},[47,932,90],{"class":60},[47,934,935,937,939,942,944],{"class":49,"line":144},[47,936,454],{"class":53},[47,938,847],{"class":64},[47,940,941],{"class":60}," ===",[47,943,852],{"class":505},[47,945,90],{"class":60},[47,947,948],{"class":49,"line":329},[47,949,573],{"class":60},[12,951,952,953,956,957,960,961,963,964,967],{},"Six lines, no dependency. ",[33,954,955],{},"crypto.subtle.verify"," does the same job if you would rather not hand-roll it — pass ",[33,958,959],{},"[\"sign\", \"verify\"]"," to ",[33,962,292],{}," instead of ",[33,965,966],{},"[\"sign\"]",", or it throws when you try.",[12,969,970],{},"And the cookie flags matter more than the crypto:",[38,972,977],{"className":973,"code":975,"language":976},[974],"language-text","Path=\u002F; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000\n","text",[33,978,975],{"__ignoreMap":43},[12,980,981,984,985,988,989,992],{},[33,982,983],{},"HttpOnly"," keeps JavaScript away from it, ",[33,986,987],{},"Secure"," keeps it off plain HTTP, ",[33,990,991],{},"SameSite=Lax"," handles the CSRF case for a form like this.",[25,994,996],{"id":995},"two-small-things-that-are-easy-to-get-wrong","Two Small Things That Are Easy to Get Wrong",[12,998,999],{},[158,1000,1001],{},"Fail closed on missing configuration.",[38,1003,1005],{"className":40,"code":1004,"language":42,"meta":43,"style":43},"if (!password || !secret) {\n  return new Response(\"Auth not configured. Set AUTH_PASS and AUTH_SECRET.\", {\n    status: 503,\n  });\n}\n",[33,1006,1007,1029,1051,1063,1072],{"__ignoreMap":43},[47,1008,1009,1011,1013,1015,1018,1020,1023,1026],{"class":49,"line":50},[47,1010,666],{"class":53},[47,1012,669],{"class":64},[47,1014,672],{"class":60},[47,1016,1017],{"class":64},"password ",[47,1019,686],{"class":60},[47,1021,1022],{"class":60}," !",[47,1024,1025],{"class":64},"secret) ",[47,1027,1028],{"class":60},"{\n",[47,1030,1031,1033,1035,1038,1040,1042,1045,1047,1049],{"class":49,"line":93},[47,1032,454],{"class":53},[47,1034,258],{"class":60},[47,1036,1037],{"class":206}," Response",[47,1039,210],{"class":128},[47,1041,87],{"class":60},[47,1043,1044],{"class":83},"Auth not configured. Set AUTH_PASS and AUTH_SECRET.",[47,1046,87],{"class":60},[47,1048,68],{"class":60},[47,1050,122],{"class":60},[47,1052,1053,1056,1058,1061],{"class":49,"line":100},[47,1054,1055],{"class":128},"    status",[47,1057,113],{"class":60},[47,1059,1060],{"class":505}," 503",[47,1062,141],{"class":60},[47,1064,1065,1068,1070],{"class":49,"line":125},[47,1066,1067],{"class":60},"  }",[47,1069,324],{"class":128},[47,1071,90],{"class":60},[47,1073,1074],{"class":49,"line":144},[47,1075,573],{"class":60},[12,1077,1078],{},"Without this, a deploy that loses its environment variables serves the whole site publicly, and everything still looks fine. The failure has to be loud.",[12,1080,1081,1084,1085,1088],{},[158,1082,1083],{},"Sanitise the redirect target."," The login form carries a ",[33,1086,1087],{},"next"," parameter so you land where you were going. That is an open redirect waiting to happen:",[38,1090,1092],{"className":40,"code":1091,"language":42,"meta":43,"style":43},"function safeNext(raw: string): string {\n  \u002F\u002F Strip first, then validate. The other order is the bug.\n  const clean = raw.replace(\u002F[\\u0000-\\u001F\\u007F]\u002Fg, \"\");\n  if (!clean.startsWith(\"\u002F\")) return \"\u002F\";\n  if (clean.startsWith(\"\u002F\u002F\") || clean.startsWith(\"\u002F\\\\\")) return \"\u002F\";\n  return clean;\n}\n",[33,1093,1094,1115,1121,1158,1195,1249,1257],{"__ignoreMap":43},[47,1095,1096,1098,1101,1103,1105,1107,1109,1111,1113],{"class":49,"line":50},[47,1097,780],{"class":106},[47,1099,1100],{"class":206}," safeNext",[47,1102,210],{"class":60},[47,1104,303],{"class":213},[47,1106,113],{"class":60},[47,1108,219],{"class":116},[47,1110,231],{"class":60},[47,1112,219],{"class":116},[47,1114,122],{"class":60},[47,1116,1117],{"class":49,"line":93},[47,1118,1120],{"class":1119},"sHwdD","  \u002F\u002F Strip first, then validate. The other order is the bug.\n",[47,1122,1123,1125,1128,1130,1133,1135,1137,1139,1142,1145,1148,1150,1152,1154,1156],{"class":49,"line":100},[47,1124,250],{"class":106},[47,1126,1127],{"class":64}," clean",[47,1129,119],{"class":60},[47,1131,1132],{"class":64}," raw",[47,1134,284],{"class":60},[47,1136,492],{"class":206},[47,1138,210],{"class":128},[47,1140,1141],{"class":60},"\u002F[",[47,1143,1144],{"class":83},"\\u0000-\\u001F\\u007F",[47,1146,1147],{"class":60},"]\u002F",[47,1149,506],{"class":505},[47,1151,68],{"class":60},[47,1153,511],{"class":60},[47,1155,324],{"class":128},[47,1157,90],{"class":60},[47,1159,1160,1162,1164,1166,1169,1171,1174,1176,1178,1180,1182,1185,1187,1189,1191,1193],{"class":49,"line":125},[47,1161,812],{"class":53},[47,1163,669],{"class":128},[47,1165,672],{"class":60},[47,1167,1168],{"class":64},"clean",[47,1170,284],{"class":60},[47,1172,1173],{"class":206},"startsWith",[47,1175,210],{"class":128},[47,1177,87],{"class":60},[47,1179,497],{"class":83},[47,1181,87],{"class":60},[47,1183,1184],{"class":128},")) ",[47,1186,711],{"class":53},[47,1188,80],{"class":60},[47,1190,497],{"class":83},[47,1192,87],{"class":60},[47,1194,90],{"class":60},[47,1196,1197,1199,1201,1203,1205,1207,1209,1211,1214,1216,1218,1220,1222,1224,1226,1228,1230,1232,1235,1237,1239,1241,1243,1245,1247],{"class":49,"line":144},[47,1198,812],{"class":53},[47,1200,669],{"class":128},[47,1202,1168],{"class":64},[47,1204,284],{"class":60},[47,1206,1173],{"class":206},[47,1208,210],{"class":128},[47,1210,87],{"class":60},[47,1212,1213],{"class":83},"\u002F\u002F",[47,1215,87],{"class":60},[47,1217,833],{"class":128},[47,1219,686],{"class":60},[47,1221,1127],{"class":64},[47,1223,284],{"class":60},[47,1225,1173],{"class":206},[47,1227,210],{"class":128},[47,1229,87],{"class":60},[47,1231,497],{"class":83},[47,1233,1234],{"class":64},"\\\\",[47,1236,87],{"class":60},[47,1238,1184],{"class":128},[47,1240,711],{"class":53},[47,1242,80],{"class":60},[47,1244,497],{"class":83},[47,1246,87],{"class":60},[47,1248,90],{"class":60},[47,1250,1251,1253,1255],{"class":49,"line":329},[47,1252,454],{"class":53},[47,1254,1127],{"class":64},[47,1256,90],{"class":60},[47,1258,1259],{"class":49,"line":364},[47,1260,573],{"class":60},[12,1262,1263],{},"Three things, and I got the first one wrong at first.",[12,1265,1266,1269,1270,1273,1274,1276,1277,1280,1281,1285,1286,1289],{},[158,1267,1268],{},"Strip before you validate, not after."," If you check the string and then strip it, the strip can produce the thing you just rejected. ",[33,1271,1272],{},"next=\u002F%0A\u002Fevil.com"," starts with ",[33,1275,497],{}," and its second character is a newline, not a slash — so it passes a ",[33,1278,1279],{},"startsWith(\"\u002F\u002F\")"," check, and ",[1282,1283,1284],"em",{},"then"," the strip turns it into ",[33,1287,1288],{},"\u002F\u002Fevil.com",". The sanitiser manufactures the vulnerability. Order is the whole fix.",[12,1291,1292,1297,1298,1300,1301,1304,1305,1308],{},[158,1293,1294,1296],{},[33,1295,1213],{}," is not the only protocol-relative spelling."," ",[33,1299,1288],{}," is the one people know about. ",[33,1302,1303],{},"\u002F\\evil.com"," is the one they miss: the URL parser normalises a backslash in the authority to a forward slash, so Chrome and Firefox both send you to ",[33,1306,1307],{},"evil.com",". Reject both prefixes.",[12,1310,1311,1317,1318,1321],{},[158,1312,1313,1314,284],{},"Strip every control character, not just ",[33,1315,1316],{},"\\r\\n"," A tab gets the same bypass, and browsers strip it for you. Note that on Deno and Netlify the ",[33,1319,1320],{},"Headers"," object already throws on CR\u002FLF in a value, so the strip is not what stands between you and header injection — it is there to stop a control character sneaking past the prefix checks.",[25,1323,1325],{"id":1324},"the-login-page-is-inline-html","The Login Page Is Inline HTML",[12,1327,1328],{},"The login page is a template literal inside the function. No build step, no framework, no separate route.",[12,1330,1331,1332,1335,1336,1339,1340,284],{},"That sounds crude and it is exactly right: this page has to work when nothing else does. It is the only thing an unauthenticated visitor ever sees, so it must not depend on the site it is guarding. One ",[33,1333,1334],{},"\u003Cstyle>"," block, one form, ",[33,1337,1338],{},"Cache-Control: no-store",", and ",[33,1341,1342],{},"\u003Cmeta name=\"robots\" content=\"noindex, nofollow\">",[12,1344,1345],{},"Styling it properly is worth ten minutes. It is the front door.",[25,1347,1349],{"id":1348},"what-this-does-not-do","What This Does Not Do",[12,1351,1352],{},"The honest section, because a post that hands you an auth pattern owes you its limits.",[12,1354,1355,1358,1359,1362],{},[158,1356,1357],{},"The password comparison is not constant-time."," The signature check is, for the reason above. ",[33,1360,1361],{},"submitted === password"," on the login path still is not, and I have left it that way: the attacker controls the guess but gets one answer per request over the network, which is the same channel the missing rate limiting already leaves wide open. Fix the rate limiting first; that is the bound that actually binds.",[12,1364,1365,1372,1373,1376,1377,1379,1380,1383,1384,1390],{},[158,1366,1367,1368,1371],{},"Rotating ",[33,1369,1370],{},"AUTH_PASS"," does not log anybody out."," This is the one I had backwards, and it is worth being precise about. The cookie is ",[33,1374,1375],{},"\u003Cexpiry>.\u003CHMAC(AUTH_SECRET, expiry)>"," — ",[33,1378,1370],{}," appears nowhere in it and is never consulted again after login. Changing it only changes what the form accepts from that point on. Every cookie already issued keeps working for the rest of its ",[33,1381,1382],{},"Max-Age",", which here is thirty days. ",[158,1385,1386,1387],{},"The revocation lever is ",[33,1388,1389],{},"AUTH_SECRET",": rotate that and every existing cookie fails its signature check immediately. So if you share the password with someone and later want them out, rotate the secret, not the password — and accept that it logs everyone out, because there are no users to log out individually.",[12,1392,1393,1396],{},[158,1394,1395],{},"There is no rate limiting."," Nothing stops someone trying passwords as fast as the edge will serve them. A long random password is doing all the work here — if the password is guessable, this is not protecting anything.",[12,1398,1399,1402],{},[158,1400,1401],{},"It is obscurity plus a password, not authorisation."," Everyone who gets in sees everything. There are no roles, and there is nothing stopping a person who has the password from sharing it.",[25,1404,1406],{"id":1405},"when-to-use-it","When to Use It",[12,1408,1409],{},"When the site genuinely has one audience and no secrets worth stealing. A household tool, a staging deploy, a draft nobody should stumble into, a tracker you want to open on your phone without it being public.",[12,1411,1412],{},"The moment you need per-person access, an audit trail, or revocation for one user, this is the wrong tool and you should reach for real auth.",[12,1414,1415],{},"But that moment arrives much later than people assume. I have reused this same function on every site since, changing only the cookie name and the colours of the login page.",[1417,1418,1419],"style",{},"html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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 .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sfNiH, html code.shiki .sfNiH{--shiki-light:#FF5370;--shiki-default:#FF9CAC;--shiki-dark:#FF9CAC}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}",{"title":43,"searchDepth":93,"depth":93,"links":1421},[1422,1423,1424,1425,1426,1427],{"id":27,"depth":93,"text":28},{"id":182,"depth":93,"text":183},{"id":995,"depth":93,"text":996},{"id":1324,"depth":93,"text":1325},{"id":1348,"depth":93,"text":1349},{"id":1405,"depth":93,"text":1406},"2026-05-12","Sometimes you want a site on the internet but not of the internet — one password, no accounts, no database. An HMAC-signed cookie and about 150 lines of Netlify edge function does it, and it is worth knowing exactly what that buys you and what it doesn't.","md","\u002Fimg\u002Fedge-function-auth.jpg",{},"\u002Fblog\u002Fa-password-gate-for-a-static-site-in-one-edge-function",{"title":5,"description":1429},{"loc":1433},"blog\u002Fa-password-gate-for-a-static-site-in-one-edge-function",[1438,1439,1440],"Netlify","Edge Functions","TypeScript","V7ljmjVhRAHPWRgMN5UNYmDixadsTiPQ812iipswlxk",[1443,1444],null,{"title":1445,"path":1446,"stem":1447,"description":1448,"children":-1},"Adding SEO support to Angular 6 application","\u002Fblog\u002Fadding-seo-support-to-angular-6-application","blog\u002Fadding-seo-support-to-angular-6-application","In this article we will take a look at how we can add SEO support in our Angular 6 Application",1790075472115]