隨著專案進入前後端串接的階段,我們的 API 終於要面對真實世界的考驗了。說到前後端分離,開發者一定都遇過一堵無形的牆——跨來源資源共用(CORS)。
你可能會想:
「這有什麼難的?在 NestJS 裡只要呼叫 app.enableCors() 不就搞定了嗎?」
今天,我們就來破解這個阻擋無數前後端順利通靈(串接)的經典魔咒!
在前後端串接時,前端回報請求需要攜帶 Cookie(例如 Session 或 JWT Refresh Token)。為了開發方便與省事,你隨手在 NestJS 的 main.ts 加上了這段設定:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// 地雷:同時允許所有來源與 Credentials
app.enableCors({
origin: '*',
credentials: true,
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
原本想說 origin 給了 * 通通放行,credentials: true 也補上了,這樣設定應該就沒問題了。
結果前端一呼叫 API,瀏覽器的 Console 當場跳出一大串紅字:
Access to fetch at 'http://localhost:3000/user/me' from origin 'http://localhost:5173' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'.
明明設定了 CORS,後端 Log 也完全沒有跳出任何錯誤,為什麼前端卻完全拿不到回應?
*造成這個報錯的根本原因,在於瀏覽器的同源策略(Same-Origin Policy)與 CORS 安全規範。CORS 的核心目的,是控制某個來源(Origin)的 JavaScript 能否讀取另一個來源的回應資料(Response),藉此防止敏感資料跨網域洩漏。
想像一個情境,你正坐在電腦前,插著讀卡機與金融卡,準備在網銀官網修改密碼。而你的瀏覽器其實同時開著另一個分頁——你剛剛隨手點開的心理測驗網站(惡意網站)。
如果這時網銀後端 API 的設定是這樣的:
credentials: true:網銀系統確認了讀卡機裡插著你的實體卡片(Cookie/憑證),允許執行需要驗證身分的敏感操作。origin: '*':網銀系統對請求來源完全不設防,接受來自任何網頁的請求,並允許對方獲取處理後的敏感資料。
這就是引發災難的破口!那個看似有趣無害的「心理測驗網站」背後其實藏有惡意腳本。它會趁著你的卡片還插在讀卡機裡(身分驗證處於有效狀態),偷偷在背景向網銀系統發送指令。如果沒有安全機制的保護,你的錢就在一邊測「你是哪種小動物」的同時,一邊被背景的心理測驗網站給偷偷轉走了。
為了不讓這種悲劇發生,身為保全的「瀏覽器」會立刻站出來擋在前面,對後端發出嚴厲警告:
「等一下!你既然啟用了金融卡驗證(credentials: true),就必須指定只有官方網銀才能存取!你設定成不限來源(origin: *),不就等於讓惡意網站拿著用戶的憑證自由進出?這不但會看光敏感資料,要是系統沒做好 CSRF 防護,連錢都會被直接偷轉走!」

因此,為了杜絕這種資安災難,W3C 標準直接在瀏覽器層級下達了死命令:
「只要你想攜帶私密憑證(Credentials),存取回應的來源(Origin)就絕對不能是萬用字元 *!」
也就是說,就算後端順利處理完請求也回傳了資料,只要 CORS 檢查沒過,瀏覽器就會直接把回應死死扣住攔下,絕對不讓前端讀取。
origin: '*' 和 origin: true 一樣嗎?有些敏銳的開發者在查閱 NestJS 的底層文件時,會發現 origin 除了填寫字串,還可以填寫布林值。
這兩者有什麼本質上的區別?
// 1. 萬用字元
app.enableCors({ origin: '*' });
// 2. 動態反映
app.enableCors({ origin: true });
origin |
實際回傳的 Access-Control-Allow-Origin 標頭值 |
支援 credentials: true 嗎? |
安全性與適用場景 |
|---|---|---|---|
'*' (Wildcard) |
永遠固定回傳 * |
不支援 (瀏覽器會報錯) | 適合完全公開、不涉及用戶隱私的公共 API。 |
true (Dynamic) |
自動複製請求來源的網域(req.header('Origin')) |
支援 | 適合開發環境,但不建議直接用於生產環境,因為這等同於對全部的網域開門,容易造成資料洩漏。 |
⚠️ 注意:
設定origin: true雖然能讓瀏覽器正常運作,但這只是「將大門鑰匙掛在門口」的偷懶做法,在正式環境中依然存在極大的安全隱患。
最直接的方式,就是明確列舉哪些網域是可以攜帶憑證的信任白名單。
在 main.ts 中,請把 * 換成前端真實的所在網址(包含通訊協定與 Port 號):
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ['http://localhost:5173'], // 明確指定允許的來源
credentials: true,
methods: ['GET', 'POST', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
解決了基本設定後,在實務開發與專案推進時,我們通常會面臨更複雜的架構挑戰:
admin.my-app.com 與 www.my-app.com,或是需區分本機、測試、正式環境。如果寫死在程式碼裡,每次部署都要修改程式碼,容易出錯。進階做法:使用函式形式的 origin 結合環境變數
NestJS 的 origin 設定非常強大,它除了接受字串與陣列,更接受一個 函式(Function)!搭配環境變數,我們可以做出極具彈性且兼顧安全的防護網:
// 從環境變數讀取逗號分隔的白名單字串,並轉為陣列
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
app.enableCors({
origin: (origin, callback) => {
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true);
return;
}
callback(null, false);
},
credentials: true,
});
現在,你只需要在正式環境的 .env 檔案中配置:
# .env.production
ALLOWED_ORIGINS=https://www.my-app.com,https://admin.my-app.com
而在本地開發環境的 .env 配置:
# .env.development
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
之後無論要切換環境還是新增網域,都只要改改環境變數就好。
credentials: true)時,origin 絕對不能設為萬用字元 *,否則會違反規範而被瀏覽器直接攔截回應。origin: true 雖能自動反射請求來源,但在生產環境中等同於無差別信任所有網域,極易造成敏感資料洩漏的風險。origin 搭配環境變數,能彈性支援多環境部署與多子網域,是兼顧安全性與維護性的最佳實踐。