前一天用 bcrypt 完成了登入前很重要的一步:確認使用者輸入的密碼是否正確。現在我們已經可以做到「使用者送出帳號密碼 → 查詢資料庫 → 用 bcrypt 比對 → 確認身分」,但登入成功後,還有一件事情要處理。
假設使用者登入成功,接著要取得自己的筆記:
GET /notes
這時候後端還是需要知道「這是哪一位使用者的請求」。因為每一次 API 請求都是獨立的,使用者剛剛登入成功這件事,不會自動延續到下一次請求。
所以登入成功後,我們還需要一種方法,讓使用者之後每次發送請求時,都能證明自己的身分。JWT 就是在這個情境下常見的一種做法。
常見的方式可以分成 Session 和 Token。
Session 是由後端保存登入狀態,使用者登入成功後,後端建立一筆 Session,裡面記錄使用者的 userId,再給前端一個 Session ID。
之後前端每次請求都帶著這個 ID 回來,後端再找到對應的 Session。
登入
↓
後端建立 Session
↓
前端保存 Session ID
↓
下一次請求帶上 Session ID
↓
後端找到 Session
↓
取得 userId
Session 很適合用在傳統網站,也可以使用在前後端分離的網站,所以它並沒有因為 JWT 出現就消失。
Token 則是登入成功後,由後端產生一組 Token 給前端保存,之後前端每次請求都帶著這組 Token,後端收到後再確認 Token 是否有效。
登入
↓
後端產生 Token
↓
前端保存 Token
↓
下一次請求帶上 Token
↓
後端確認 Token
↓
取得 userId
JWT 就是一種常見的 Token 格式。
當 API 開始變多,或系統需要前後端分開開發時,Token 會變得很方便。因為 Session 的登入狀態放在後端,如果今天只有一台 Server,處理起來很簡單:
Frontend
↓
Node.js
↓
Session
但如果之後變成多台 Server:
Frontend
↓
Load Balancer
↙ ↓ ↘
Server A Server B Server C
使用者登入時可能被分到 Server A,Session 也就存在 Server A。下一次請求如果到了 Server B,Server B 就找不到原本的 Session。
這時就需要另外準備一個大家都能存取的地方,例如 Redis:
Server A ─┐
Server B ─┼→ Redis
Server C ─┘
Token 的方式就比較簡單。Token 本身就帶著需要的資料,後端收到後可以直接確認它是不是有效,不需要每次去找某一台 Server 上的 Session。
Frontend
↓
Load Balancer
↙ ↓ ↘
Server A Server B Server C
↘ ↓ ↙
驗證 Token
所以在前後端分離、API 很多,或未來可能增加多台 Server 的情況下,Token 通常會比較方便。
不過這不代表 Session 比較舊,或 JWT 一定比較好。Session 一樣可以用在 API,只是當系統變大時,需要另外處理多台 Server 共用登入狀態的問題。Token 則可以讓每台 Server 自己確認 Token,因此在這類架構中常常比較容易使用。
JWT 通常會長得像一長串文字:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJ1c2VySWQiOjEyMywiZXhwIjoxNzU5NzAwMDAwfQ
.
abc123...
中間會用 . 分成三段:
Header.Payload.Signature
這三段各自有不同的用途。
Header 主要用來記錄這個 JWT 使用哪一種方式,以及它是哪一種 Token。
例如:
{
"alg": "HS256",
"typ": "JWT"
}
現在先記住 alg 和 typ 就好,不需要先深入了解這些設定。
Payload 是放資料的地方,例如使用者 ID 和 Token 的使用期限:
{
"userId": 123,
"exp": 1759700000
}
這些資料可以被讀取,所以不要把密碼或其他重要的私人資料直接放進去。
JWT 的 Payload 只是換成另一種格式保存,並沒有把內容藏起來。因此看到 JWT 裡面有一串看不懂的文字,也不要把它當成加密後的密碼。
Signature 是用來確認 JWT 有沒有被改過。
可以簡單想成,後端會拿 Header、Payload 和一組只有自己知道的 Secret,算出一個 Signature:
Header + Payload + Secret
↓
Signature
之後 JWT 回到後端時,後端會再算一次,看看結果是不是一樣。
如果有人偷偷修改了 Payload:
userId: 123
↓
userId: 456
算出來的 Signature 就會不同,後端也就能知道這個 Token 有問題。
所以目前可以先把 JWT 記成:
JWT
├── Header → 記錄基本設定
├── Payload → 放使用者相關資料
└── Signature → 確認內容有沒有被改過
JWT 的 Signature 會用到 Secret。
可以把 Secret 想成後端自己保管的一把鑰匙,只有後端知道。
例如:
JWT_SECRET=your-super-secret-key
程式再從環境變數取得:
const secret = process.env.JWT_SECRET;
不要直接把 Secret 寫在程式碼裡:
const secret = "your-super-secret-key";
也不要把 .env 放進 Git:
.env
因為 Secret 一旦外洩,別人就可能拿它做出假的 JWT。
JWT 通常會設定使用期限,避免一組 Token 永遠都能使用。
例如:
{
"userId": 123,
"exp": 1759700000
}
exp 代表到期時間。
使用 jsonwebtoken 時,可以直接設定:
const token = jwt.sign(
{
userId: user.id
},
process.env.JWT_SECRET,
{
expiresIn: "1h"
}
);
這裡的 "1h" 就代表這個 Token 可以使用 1 小時,時間到了之後,就算 Token 沒有被改過,後端也會拒絕它。所以登入流程會慢慢變成:
帳號密碼
↓
bcrypt 驗證
↓
登入成功
↓
建立 JWT
↓
設定 Secret
↓
設定使用期限
↓
交給前端保存
JWT 產生出來之後,還要決定放在哪裡。
前端常見的地方有 localStorage、sessionStorage 和 Cookie,不過如果 JWT 是拿來做登入驗證,就需要特別注意保存的位置。
localStorage 和 sessionStorage 都可以被網頁上的 JavaScript 讀取,如果網站有 XSS 漏洞,攻擊者就可能利用這個漏洞讀取 Token。
例如:
const token = localStorage.getItem("token");
所以登入用的 Token 不建議直接放在這兩個地方。
對瀏覽器登入來說,常見的另一種做法是把 JWT 放進 HttpOnly Cookie。
例如後端登入成功後:
res.cookie("access_token", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 1000
});
這樣 JWT 會由瀏覽器保存,JavaScript 不能直接讀取這個 Cookie,之後符合條件的請求,瀏覽器會自動把 Cookie 帶回後端。
這裡要注意,Cookie 和 JWT 是兩個不同的東西:
JWT
→ Token 的格式
Cookie
→ 瀏覽器保存資料的方式
所以「把 JWT 放進 Cookie」的意思,就是讓瀏覽器用 Cookie 保存這組 JWT。
現在可以把前一天學到的 bcrypt 和今天的 JWT 接在一起。
假設前端送出:
POST /auth/login
Content-Type: application/json
Request Body:
{
"email": "test@example.com",
"password": "123456"
}
後端的流程會是:
接收 email / password
↓
查詢 User
↓
bcrypt 比對密碼
↓
驗證成功
↓
建立 JWT
↓
放進 HttpOnly Cookie
↓
回傳登入成功
Controller 可以先這樣寫:
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
export const login = async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await userRepository.findOne({
where: { email }
});
if (!user) {
return res.status(401).json({
message: "帳號或密碼錯誤"
});
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) {
return res.status(401).json({
message: "帳號或密碼錯誤"
});
}
const token = jwt.sign(
{
userId: user.id
},
process.env.JWT_SECRET,
{
expiresIn: "1h"
}
);
res.cookie("access_token", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 1000
});
res.status(200).json({
message: "登入成功"
});
} catch (error) {
next(error);
}
};
這段程式可以分成三個部分來看。
先用 bcrypt 確認密碼:
const isMatch = await bcrypt.compare(password, user.password);
確認成功後,建立 JWT:
const token = jwt.sign(
{
userId: user.id
},
process.env.JWT_SECRET,
{
expiresIn: "1h"
}
);
最後放進 Cookie,httpOnly 可以避免 JavaScript 直接讀取 Cookie,secure 限制 Cookie 只能透過 HTTPS 傳送,sameSite 用來控制跨網站請求是否帶上 Cookie,maxAge 則設定 Cookie 的保存時間。
res.cookie("access_token", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 1000
});
這樣登入成功後,瀏覽器就會保存 JWT,之後符合條件的請求會自動帶上 Cookie。
到這裡,登入 API 已經可以完成「確認帳號密碼 → 建立 JWT → 保存 JWT」。
接下來使用者請求需要登入的 API,例如:
GET /notes
瀏覽器會把 Cookie 帶給後端,後端再從 Cookie 取得 JWT,檢查它有沒有被修改、是不是已經過期,最後從 Payload 取得 userId。
GET /notes
↓
Cookie
↓
JWT
↓
確認 JWT
↓
取得 userId
↓
查詢這個使用者的筆記
這樣登入和後續 API 就串起來了。
不過現在還缺少一個重要的部分:每支 API 都自己處理 JWT 驗證會很麻煩,因此通常會把這件事情交給 Middleware 統一處理。
這也會是下一篇要開始做的事情。
到這裡,登入流程又往前走了一步。從帳號密碼驗證,到登入成功後建立 JWT,使用者的身分終於可以延續到後面的 API。
下一篇,開始處理 JWT 的驗證。