在上一篇文章中,我們使用 JWT (JSON Web Token) 讓使用者能登入並通過驗證。
但問題來了:
若 Access Token 只有效 10 分鐘,用戶是不是得一直重新登入?
這樣體驗會很差,所以通常會搭配:
讓使用者在 Access Token 過期時,自動用 Refresh Token 延長Token時效,而不必重登。
Token 類型 | 有效時間 | 儲存位置 | 用途 |
---|---|---|---|
Access Token | 短(5–15 分鐘) | LocalStorage / Cookie | 用來呼叫 API的驗證機制 |
Refresh Token | 長(7–30 天) | HttpOnly Cookie | 用來換新的 Access Token |
運作流程如下 :
[使用者登入] → Server 發 Access Token + Refresh Token
|
↓
Access 過期 → 前端自動呼叫 /refresh
|
↓
Server 驗證 Refresh Token → 回傳新的 Access Token
|
↓
Refresh Token 過期 → 要重新登入
了解運作的概念與機制後,我們就來動手實作開發看看!
建立一個新專案:
mkdir jwt-refresh-demo && cd jwt-refresh-demo
npm init -y
npm install express mongoose bcrypt jsonwebtoken cookie-parser cors
// server.js
import express from "express";
import mongoose from "mongoose";
import cors from "cors";
import cookieParser from "cookie-parser";
import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(cors({ origin: "http://localhost:5173", credentials: true }));
await mongoose.connect("mongodb://localhost:27017/jwt_demo");
console.log("✅ MongoDB Connected");
// models/User.js
import mongoose from "mongoose";
import bcrypt from "bcrypt";
const userSchema = new mongoose.Schema({
email: { type: String, unique: true, required: true },
password: { type: String, required: true },
});
// 儲存前自動加密密碼
userSchema.pre("save", async function (next) {
if (this.isModified("password")) {
this.password = await bcrypt.hash(this.password, 10);
}
next();
});
export const User = mongoose.model("User", userSchema);
這裡我們只追蹤目前有效的 Refresh Token,過期後由 Mongo 自動清除:
// models/RefreshToken.js
import mongoose from "mongoose";
const refreshTokenSchema = new mongoose.Schema({
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
tokenId: { type: String, required: true },
token: { type: String, required: true },
expiresAt: { type: Date, required: true },
isValid: { type: Boolean, default: true },
});
// TTL 索引:到期自動刪除
refreshTokenSchema.index({ expiresAt: 1 }, { expireAfterSeconds: 0 });
export const RefreshToken = mongoose.model("RefreshToken", refreshTokenSchema);
登入成功後,同時發出 Access Token(短期)與 Refresh Token(長期)。
import { User } from "./models/User.js";
import { RefreshToken } from "./models/RefreshToken.js";
const ACCESS_SECRET = "ACCESS_SECRET_KEY";
const REFRESH_SECRET = "REFRESH_SECRET_KEY";
function createAccessToken(userId, tokenId) {
return jwt.sign({ id: userId, tokenId }, ACCESS_SECRET, { expiresIn: "10m" });
}
function createRefreshToken(userId, tokenId) {
return jwt.sign({ id: userId, tokenId }, REFRESH_SECRET, { expiresIn: "7d" });
}
//登入
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user) return res.status(404).json({ error: "使用者不存在" });
const valid = await bcrypt.compare(password, user.password);
if (!valid) return res.status(401).json({ error: "密碼錯誤" });
const tokenId = uuidv4();
const accessToken = createAccessToken(user._id, tokenId);
const refreshToken = createRefreshToken(user._id, tokenId);
await RefreshToken.create({
userId: user._id,
tokenId,
token: refreshToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // TTL 清除依據
});
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
sameSite: "strict",
});
res.json({ message: "登入成功", accessToken });
});
當 Access Token 過期時,前端會自動發送 /refresh
來換新的。
app.post("/refresh", async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ error: "沒有 Refresh Token" });
try {
const decoded = jwt.verify(token, REFRESH_SECRET);
const dbToken = await RefreshToken.findOne({ token });
if (!dbToken || !dbToken.isValid)
return res.status(403).json({ error: "Refresh Token 無效或過期" });
const newTokenId = uuidv4();
const newAccess = createAccessToken(decoded.id, newTokenId);
res.json({ accessToken: newAccess });
} catch {
res.status(403).json({ error: "Refresh Token 過期或無效" });
}
});
app.post("/logout", async (req, res) => {
const token = req.cookies.refreshToken;
if (token) {
await RefreshToken.deleteOne({ token });
res.clearCookie("refreshToken");
}
res.json({ message: "登出成功" });
});
function verifyAccessToken(req, res, next) {
const auth = req.headers.authorization;
if (!auth) return res.status(401).json({ error: "缺少授權標頭" });
const token = auth.split(" ")[1];
jwt.verify(token, ACCESS_SECRET, (err, decoded) => {
if (err) return res.status(403).json({ error: "Access Token 無效或過期" });
req.user = decoded;
next();
});
}
app.get("/profile", verifyAccessToken, async (req, res) => {
const user = await User.findById(req.user.id).select("-password");
res.json({ user });
});
┌────────────────────────────┐
│ 使用者登入 │
│ POST /login (email, pwd) │
└──────────────┬─────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Server 驗證帳密 │
│ - 建立 tokenId(UUID) │
│ - 產生 Access Token (10m) │
│ - 產生 Refresh Token (7d) │
│ - 存入 MongoDB(含 tokenId, expiresAt) │
└──────────────┬───────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Client 儲存 Token │
│ - Access Token → Header │
│ - Refresh Token → Cookie │
└──────────────┬─────────────┘
│
▼
┌────────────────────────────┐
│ 呼叫受保護 API │
│ Authorization: Bearer ... │
└──────────────┬─────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Server 驗證 Access Token │
│ - 驗證簽章與 exp │
│ → 通過:允許存取 │
│ → 失敗:回傳 403 (過期) │
└──────────────┬───────────────────────────────┘
│
┌─────┴──────┐
│ Token 過期? │
└─────┬──────┘
│ 是
▼
┌──────────────────────────────────────────────┐
│ Client 呼叫 /refresh │
│ - 附上 Cookie(refreshToken) │
│ Server 驗證 Refresh Token │
│ - 驗證簽章 + 查 DB 是否有效 │
│ - 產生新 tokenId、Access Token │
└──────────────┬───────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Client 更新 Token │
│ Access Token ← 新值 │
│ Refresh Token ← 視情況換新 │
└──────────────┬─────────────┘
│
▼
┌────────────────────────────┐
│ 登出 /logout │
│ - 刪除 DB Refresh Token │
│ - 清除 Cookie │
└────────────────────────────┘
今天我們實作了一套 JWT 雙Token驗證系統
strict
,防止 CSRF 跨站請求偽造expiresAt
自動刪除過期資料,無需手動清理/refresh
→ 換發新 Access Token