[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"post-\u002Fblog\u002Fjwt-storage-security":3},{"id":4,"title":5,"body":6,"category":731,"date":732,"description":733,"draft":734,"extension":735,"meta":736,"navigation":417,"path":737,"readingTime":738,"seo":739,"stem":740,"tags":741,"__hash__":744},"blog\u002Fblog\u002Fjwt-storage-security.md","JWT 要存哪裡：從 localStorage 到 Cookie + CSRF Token",{"type":7,"value":8,"toc":720},"minimark",[9,18,21,26,34,37,80,83,87,94,206,213,216,220,223,226,236,240,251,258,269,273,280,357,360,490,496,500,503,506,528,531,535,541,544,547,634,637,640,643,699,702,716],[10,11,12,13,17],"p",{},"我的第一個全端專案「遊戲揪團平台」，是把 JWT 存在 Pinia，再持久化到 localStorage。做法簡單，每次請求放進 ",[14,15,16],"code",{},"Authorization"," header 就好。當時我對權限這塊的理解還比較薄弱，沒有想太多。",[10,19,20],{},"後來做 LINE OA 商家管理平台，後台可以管理多間店家的內容和帳號權限，登入憑證被偷的影響大很多，所以我改成 HttpOnly Cookie 加 CSRF Token。這篇整理兩種做法的風險，以及實作時遇到的問題。",[22,23,25],"h2",{"id":24},"jwt-localstorage-的風險xss","JWT + localStorage 的風險：XSS",[10,27,28,29,33],{},"localStorage 的優點是簡單，缺點是",[30,31,32],"strong",{},"任何在頁面上執行的 JavaScript 都讀得到","。",[10,35,36],{},"XSS（跨站腳本攻擊）是指攻擊者讓自己的腳本在你的網站上執行，例如透過沒有處理好的使用者輸入，或被植入惡意程式的第三方套件。一旦發生，偷走 token 只要一行：",[38,39,44],"pre",{"className":40,"code":41,"language":42,"meta":43,"style":43},"language-js shiki shiki-themes github-dark","const token = localStorage.getItem('token')\n","js","",[14,45,46],{"__ignoreMap":43},[47,48,51,55,59,62,66,70,73,77],"span",{"class":49,"line":50},"line",1,[47,52,54],{"class":53},"snl16","const",[47,56,58],{"class":57},"sDLfK"," token",[47,60,61],{"class":53}," =",[47,63,65],{"class":64},"s95oV"," localStorage.",[47,67,69],{"class":68},"svObZ","getItem",[47,71,72],{"class":64},"(",[47,74,76],{"class":75},"sU2Wk","'token'",[47,78,79],{"class":64},")\n",[10,81,82],{},"攻擊者把 token 傳回自己的伺服器後，就能在別的地方假冒使用者，直到 token 過期。JWT 通常是無狀態的，簽發後伺服器很難提前作廢，所以有效期越長，風險越高。",[22,84,86],{"id":85},"改放-httponly-cookie","改放 HttpOnly Cookie",[10,88,89,90,93],{},"加上 ",[14,91,92],{},"HttpOnly"," 的 Cookie，JavaScript 讀不到，瀏覽器會在請求時自動帶上：",[38,95,97],{"className":40,"code":96,"language":42,"meta":43,"style":43},"export const accessTokenCookieConfig = {\n  httpOnly: true, \u002F\u002F JavaScript 讀不到\n  secure: process.env.NODE_ENV === 'production', \u002F\u002F 只走 HTTPS\n  sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',\n  maxAge: 15 * 60 * 1000,\n}\n",[14,98,99,115,131,151,178,200],{"__ignoreMap":43},[47,100,101,104,107,110,112],{"class":49,"line":50},[47,102,103],{"class":53},"export",[47,105,106],{"class":53}," const",[47,108,109],{"class":57}," accessTokenCookieConfig",[47,111,61],{"class":53},[47,113,114],{"class":64}," {\n",[47,116,118,121,124,127],{"class":49,"line":117},2,[47,119,120],{"class":64},"  httpOnly: ",[47,122,123],{"class":57},"true",[47,125,126],{"class":64},", ",[47,128,130],{"class":129},"sAwPA","\u002F\u002F JavaScript 讀不到\n",[47,132,134,137,140,143,146,148],{"class":49,"line":133},3,[47,135,136],{"class":64},"  secure: process.env.",[47,138,139],{"class":57},"NODE_ENV",[47,141,142],{"class":53}," ===",[47,144,145],{"class":75}," 'production'",[47,147,126],{"class":64},[47,149,150],{"class":129},"\u002F\u002F 只走 HTTPS\n",[47,152,154,157,159,161,163,166,169,172,175],{"class":49,"line":153},4,[47,155,156],{"class":64},"  sameSite: process.env.",[47,158,139],{"class":57},[47,160,142],{"class":53},[47,162,145],{"class":75},[47,164,165],{"class":53}," ?",[47,167,168],{"class":75}," 'none'",[47,170,171],{"class":53}," :",[47,173,174],{"class":75}," 'lax'",[47,176,177],{"class":64},",\n",[47,179,181,184,187,190,193,195,198],{"class":49,"line":180},5,[47,182,183],{"class":64},"  maxAge: ",[47,185,186],{"class":57},"15",[47,188,189],{"class":53}," *",[47,191,192],{"class":57}," 60",[47,194,189],{"class":53},[47,196,197],{"class":57}," 1000",[47,199,177],{"class":64},[47,201,203],{"class":49,"line":202},6,[47,204,205],{"class":64},"}\n",[10,207,208,209,212],{},"前端的 axios 要開啟 ",[14,210,211],{},"withCredentials: true","，跨網域請求才會帶上 Cookie。",[10,214,215],{},"這樣 XSS 就偷不走 token 了。但「瀏覽器會自動帶上」這個特性，帶來了另一個問題。",[22,217,219],{"id":218},"cookie-的風險csrf","Cookie 的風險：CSRF",[10,221,222],{},"CSRF（跨站請求偽造）是使用者已經登入你的網站時，瀏覽惡意網站，對方偷偷發出一個請求到你的 API。因為 Cookie 會自動帶上，伺服器會以為是使用者本人操作。",[10,224,225],{},"攻擊者不需要讀到 Cookie，只要讓瀏覽器幫忙送出就好。",[10,227,228,231,232,235],{},[14,229,230],{},"SameSite"," 可以擋掉大部分情況，但我的 Cookie 在正式環境設成 ",[14,233,234],{},"SameSite=None","，這個設定不會限制跨站請求，所以還需要 CSRF Token。",[22,237,239],{"id":238},"safari前後端要放在同一個網域","Safari：前後端要放在同一個網域",[10,241,242,243,246,247,250],{},"這個問題是我用手機測試時才發現的。前後端在完全不同的網域時，例如前端在 ",[14,244,245],{},"xxx.pages.dev","、API 在 ",[14,248,249],{},"xxx.onrender.com","，對瀏覽器來說這就是第三方 Cookie。",[10,252,253,254,257],{},"Safari 預設會封鎖第三方 Cookie，就算設了 ",[14,255,256],{},"SameSite=None; Secure"," 也一樣。結果是登入 API 回傳成功，Cookie 卻沒有被存下來，一直是未登入狀態。",[10,259,260,261,264,265,268],{},"後來我把網域改掉，讓前後端使用同一個主網域，例如 ",[14,262,263],{},"admin.example.com"," 和 ",[14,266,267],{},"api.example.com","。這樣 Cookie 就不算第三方，Safari 也能正常運作。",[22,270,272],{"id":271},"csrf-token-怎麼運作","CSRF Token 怎麼運作",[10,274,275,276,279],{},"登入時後端產生一組隨機值，放在一個",[30,277,278],{},"前端可以取得","的地方，之後每次修改資料的請求都要放進自訂 header：",[38,281,283],{"className":40,"code":282,"language":42,"meta":43,"style":43},"\u002F\u002F 前端：每個請求帶上 CSRF Token\napi.interceptors.request.use((config) => {\n  if (authStore.csrfToken) {\n    config.headers['x-csrf-token'] = authStore.csrfToken\n  }\n  return config\n})\n",[14,284,285,290,313,321,338,343,351],{"__ignoreMap":43},[47,286,287],{"class":49,"line":50},[47,288,289],{"class":129},"\u002F\u002F 前端：每個請求帶上 CSRF Token\n",[47,291,292,295,298,301,305,308,311],{"class":49,"line":117},[47,293,294],{"class":64},"api.interceptors.request.",[47,296,297],{"class":68},"use",[47,299,300],{"class":64},"((",[47,302,304],{"class":303},"s9osk","config",[47,306,307],{"class":64},") ",[47,309,310],{"class":53},"=>",[47,312,114],{"class":64},[47,314,315,318],{"class":49,"line":133},[47,316,317],{"class":53},"  if",[47,319,320],{"class":64}," (authStore.csrfToken) {\n",[47,322,323,326,329,332,335],{"class":49,"line":153},[47,324,325],{"class":64},"    config.headers[",[47,327,328],{"class":75},"'x-csrf-token'",[47,330,331],{"class":64},"] ",[47,333,334],{"class":53},"=",[47,336,337],{"class":64}," authStore.csrfToken\n",[47,339,340],{"class":49,"line":180},[47,341,342],{"class":64},"  }\n",[47,344,345,348],{"class":49,"line":202},[47,346,347],{"class":53},"  return",[47,349,350],{"class":64}," config\n",[47,352,354],{"class":49,"line":353},7,[47,355,356],{"class":64},"})\n",[10,358,359],{},"後端比對 Cookie 和 header 裡的值是否相同：",[38,361,363],{"className":40,"code":362,"language":42,"meta":43,"style":43},"const a = Buffer.from(String(req.cookies.csrf_token))\nconst b = Buffer.from(String(req.headers['x-csrf-token']))\n\nif (a.length !== b.length || !timingSafeEqual(a, b)) {\n  return res.status(403).json({ success: false, message: 'CSRF invalid' })\n}\n",[14,364,365,388,413,419,450,486],{"__ignoreMap":43},[47,366,367,369,372,374,377,380,382,385],{"class":49,"line":50},[47,368,54],{"class":53},[47,370,371],{"class":57}," a",[47,373,61],{"class":53},[47,375,376],{"class":64}," Buffer.",[47,378,379],{"class":68},"from",[47,381,72],{"class":64},[47,383,384],{"class":68},"String",[47,386,387],{"class":64},"(req.cookies.csrf_token))\n",[47,389,390,392,395,397,399,401,403,405,408,410],{"class":49,"line":117},[47,391,54],{"class":53},[47,393,394],{"class":57}," b",[47,396,61],{"class":53},[47,398,376],{"class":64},[47,400,379],{"class":68},[47,402,72],{"class":64},[47,404,384],{"class":68},[47,406,407],{"class":64},"(req.headers[",[47,409,328],{"class":75},[47,411,412],{"class":64},"]))\n",[47,414,415],{"class":49,"line":133},[47,416,418],{"emptyLinePlaceholder":417},true,"\n",[47,420,421,424,427,430,433,436,438,441,444,447],{"class":49,"line":153},[47,422,423],{"class":53},"if",[47,425,426],{"class":64}," (a.",[47,428,429],{"class":57},"length",[47,431,432],{"class":53}," !==",[47,434,435],{"class":64}," b.",[47,437,429],{"class":57},[47,439,440],{"class":53}," ||",[47,442,443],{"class":53}," !",[47,445,446],{"class":68},"timingSafeEqual",[47,448,449],{"class":64},"(a, b)) {\n",[47,451,452,454,457,460,462,465,468,471,474,477,480,483],{"class":49,"line":180},[47,453,347],{"class":53},[47,455,456],{"class":64}," res.",[47,458,459],{"class":68},"status",[47,461,72],{"class":64},[47,463,464],{"class":57},"403",[47,466,467],{"class":64},").",[47,469,470],{"class":68},"json",[47,472,473],{"class":64},"({ success: ",[47,475,476],{"class":57},"false",[47,478,479],{"class":64},", message: ",[47,481,482],{"class":75},"'CSRF invalid'",[47,484,485],{"class":64}," })\n",[47,487,488],{"class":49,"line":202},[47,489,205],{"class":64},[10,491,492,493,495],{},"惡意網站雖然能讓瀏覽器帶上 Cookie，但讀不到 Token，也就寫不進自訂 header。這裡用 ",[14,494,446],{}," 比對，避免從回應時間猜出內容。",[22,497,499],{"id":498},"access-token-與-refresh-token","Access Token 與 Refresh Token",[10,501,502],{},"Access Token 我設定 15 分鐘，就算外洩，影響時間也短。為了不讓使用者一直重新登入，再搭配 7 天的 Refresh Token。",[10,504,505],{},"Refresh Token 我多做了幾件事：",[507,508,509,516,522],"ul",{},[510,511,512,515],"li",{},[30,513,514],{},"資料庫只存 SHA-256 雜湊","：一開始是明碼存入，後來才改掉。就算資料庫外洩，也拿不到能用的 token。",[510,517,518,521],{},[30,519,520],{},"每次更新都輪替","：舊的 Refresh Token 用過就失效。",[510,523,524,527],{},[30,525,526],{},"限制同時登入的數量","：超過上限時移除最舊的一筆。",[10,529,530],{},"JWT 無法提前作廢的問題，也因此得到緩解：把資料庫裡的 Refresh Token 刪掉，使用者最多 15 分鐘後就要重新登入。",[22,532,534],{"id":533},"踩到的坑多支請求同時-401","踩到的坑：多支請求同時 401",[10,536,537,538,33],{},"頁面通常會同時發出多支 API。Access Token 過期時，這些請求會同時收到 401，然後同時呼叫 ",[14,539,540],{},"\u002Frefresh",[10,542,543],{},"因為 Refresh Token 每次都會輪替，第一支成功後，其他請求拿的是舊 token，就會被拒絕，接著觸發登出，把 CSRF Token 也清掉，後面的請求全部失敗。",[10,545,546],{},"解法是讓同一時間只有一次 refresh，其他請求共用結果：",[38,548,550],{"className":40,"code":549,"language":42,"meta":43,"style":43},"let refreshPromise = null\n\nrefreshPromise ??= authApi.post('\u002Frefresh').finally(() => {\n  refreshPromise = null\n})\nconst { data } = await refreshPromise\n",[14,551,552,565,569,600,609,613],{"__ignoreMap":43},[47,553,554,557,560,562],{"class":49,"line":50},[47,555,556],{"class":53},"let",[47,558,559],{"class":64}," refreshPromise ",[47,561,334],{"class":53},[47,563,564],{"class":57}," null\n",[47,566,567],{"class":49,"line":117},[47,568,418],{"emptyLinePlaceholder":417},[47,570,571,574,577,580,583,585,588,590,593,596,598],{"class":49,"line":133},[47,572,573],{"class":64},"refreshPromise ",[47,575,576],{"class":53},"??=",[47,578,579],{"class":64}," authApi.",[47,581,582],{"class":68},"post",[47,584,72],{"class":64},[47,586,587],{"class":75},"'\u002Frefresh'",[47,589,467],{"class":64},[47,591,592],{"class":68},"finally",[47,594,595],{"class":64},"(() ",[47,597,310],{"class":53},[47,599,114],{"class":64},[47,601,602,605,607],{"class":49,"line":153},[47,603,604],{"class":64},"  refreshPromise ",[47,606,334],{"class":53},[47,608,564],{"class":57},[47,610,611],{"class":49,"line":180},[47,612,356],{"class":64},[47,614,615,617,620,623,626,628,631],{"class":49,"line":202},[47,616,54],{"class":53},[47,618,619],{"class":64}," { ",[47,621,622],{"class":57},"data",[47,624,625],{"class":64}," } ",[47,627,334],{"class":53},[47,629,630],{"class":53}," await",[47,632,633],{"class":64}," refreshPromise\n",[10,635,636],{},"另一個坑是多分頁。如果每次登入都產生新的 CSRF Token，其他分頁手上的值就對不上了，所以我改成已經有的話就沿用。",[22,638,639],{"id":639},"這樣就安全了嗎",[10,641,642],{},"沒有絕對安全的做法。HttpOnly Cookie 只是讓 token 不會被偷走，如果網站真的有 XSS，攻擊者還是能在頁面上直接發送請求。所以輸入處理、依賴套件管理這些基本功還是要做。",[644,645,646,662],"table",{},[647,648,649],"thead",{},[650,651,652,656,659],"tr",{},[653,654,655],"th",{},"做法",[653,657,658],{},"主要風險",[653,660,661],{},"需要的防護",[663,664,665,677,688],"tbody",{},[650,666,667,671,674],{},[668,669,670],"td",{},"JWT + localStorage",[668,672,673],{},"XSS 偷走 token",[668,675,676],{},"防 XSS、縮短有效期",[650,678,679,682,685],{},[668,680,681],{},"JWT + HttpOnly Cookie",[668,683,684],{},"CSRF",[668,686,687],{},"SameSite、CSRF Token",[650,689,690,693,696],{},[668,691,692],{},"再加 Refresh Token 機制",[668,694,695],{},"實作較複雜",[668,697,698],{},"輪替、雜湊儲存、處理並發",[22,700,701],{"id":701},"這次學到的事",[507,703,704,707,710,713],{},[510,705,706],{},"沒有最好的存法，只有不同的風險。localStorage 防不了 XSS，Cookie 要處理 CSRF。",[510,708,709],{},"我做揪團平台時對權限這塊還比較薄弱，那個專案也沒有改回來。後來做管理多間店家資料的後台，才真正開始思考憑證被偷的影響。",[510,711,712],{},"只在電腦上測試不夠。這個問題是拿手機實測才發現的，Cookie 這類行為要連 Safari 一起測。",[510,714,715],{},"機制越多，邊界情況越多。真正花時間的不是加上 Cookie，而是處理並發和多分頁。",[717,718,719],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}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 pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":43,"searchDepth":117,"depth":117,"links":721},[722,723,724,725,726,727,728,729,730],{"id":24,"depth":117,"text":25},{"id":85,"depth":117,"text":86},{"id":218,"depth":117,"text":219},{"id":238,"depth":117,"text":239},{"id":271,"depth":117,"text":272},{"id":498,"depth":117,"text":499},{"id":533,"depth":117,"text":534},{"id":639,"depth":117,"text":639},{"id":701,"depth":117,"text":701},"quality","2026-09-30","比較 JWT 存在 localStorage 與 HttpOnly Cookie 的差異，說明 XSS 與 CSRF 的風險，並記錄實際專案改用 Cookie 加 CSRF Token 的做法與踩坑。",false,"md",{},"\u002Fblog\u002Fjwt-storage-security",9,{"title":5,"description":733},"blog\u002Fjwt-storage-security",[742,684,743],"JWT","XSS","0MSTWsXPii5S3XFzL8Gw6vNQJYLiOqaFkmfljY_sG_0"]