面试通关:JWT 认证与双 Token 机制深度解析
本文专为面试准备,以问答形式拆解 JWT 认证体系的核心考点。每个问题都包含"面试怎么说"和"代码怎么写的"两个层面,让你既能侃侃而谈,也能落笔有物。
开篇:为什么面试官爱问 JWT?
在前后端分离成为主流的今天,身份认证 是每个系统必须解决的第一道门槛。JWT(JSON Web Token)作为无状态认证方案的代表,几乎出现在每一份后端/全栈岗位的 JD 中。面试官问 JWT,通常是在考察三个层面的理解:
| 层面 | 考察点 | 典型问题 |
|---|---|---|
| 原理层 | 你真的懂 JWT 是什么吗? | "JWT 的结构是怎样的?签名有什么用?" |
| 架构层 | 你知道为什么这么设计吗? | "为什么需要双 Token?为什么不直接用 Session?" |
| 工程层 | 你踩过坑吗?会怎么解决? | "Token 过期了怎么办?Token 被窃取了怎么处理?" |
下面我们逐层深入。
第一章 JWT 的本质:不只是"一串加密字符串"
1.1 面试官问:"能说说 JWT 是什么吗?"
不要这么答(减分回答):
"JWT 就是一个加密的 token,用来做用户认证的。"
这个回答有三个问题:JWT 不是加密的(它是签名而非加密)、混淆了认证和授权的概念、没有展示任何深度理解。
可以这样答(加分回答):
"JWT 全称是 JSON Web Token,它是一种基于 JSON 的开放标准(RFC 7519),用于在各方之间安全地传输信息。核心特点是------JWT 是签名的,不是加密的。也就是说,任何人都能解码看到里面的内容,但没人能篡改它,因为篡改后签名就对不上了。
在认证场景中,JWT 解决的核心问题是:在无状态的分布式系统中,服务端如何在不查数据库的情况下确认'你就是你'。"
1.2 面试官追问:"JWT 由哪几部分组成?"
JWT 的结构用一句话记住:三段 Base64,用点分隔。
css
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│ │ │
│ Header(头部) │ Payload(负载) │ Signature(签名)
逐段拆解:
Header --- 描述 Token 的元信息
json
{
"alg": "HS256", // 签名算法:HMAC-SHA256
"typ": "JWT" // 令牌类型
}
用 base64 编码后就变成了第一段:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Payload --- 要传递的数据
json
{
"sub": "1", // subject:用户唯一标识(JWT 标准字段)
"name": "zhangsan", // 自定义字段:用户名
"iat": 1715000000, // issued at:签发时间(JWT 标准字段)
"exp": 1715000900 // expiration:过期时间(JWT 标准字段)
}
Signature --- 防篡改的核心
scss
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
签名计算的本质是:将 header 和 payload 拼接后,用密钥做一次 HMAC-SHA256 哈希。任何人修改了 header 或 payload 中的哪怕一个字符,生成的签名就会完全不一样------而攻击者没有密钥,无法重新生成有效签名。
1.3 项目中怎么实现的?
在我们的 NestJS 后端中,Token 签发逻辑在 AuthService.generateTokens() 中:
typescript
// auth.service.ts
private async generateTokens(id: string, name: string) {
const payload = { sub: id, name }; // sub 是 JWT 规范中的 subject 字段
const [access_token, refresh_token] = await Promise.all([
this.jwtService.signAsync(payload, { expiresIn: '15m' }),
this.jwtService.signAsync(payload, { expiresIn: '7d' }),
]);
return { access_token, refresh_token };
}
值得注意的设计选择:
sub字段 :遵循了 JWT 规范,使用sub(subject/主体)存储用户 ID,而非自定义字段名如userId。这在多系统对接时更容易被理解。Promise.all并发签发:两个 Token 的签发没有依赖关系,并行执行减少等待时间。- 同一个 payload :两个 Token 携带的信息完全相同,唯一的区别是
exp(过期时间)。这个设计保证了用 refresh_token 换取新的 access_token 时,无需重新查询数据库。
第二章 JWT vs Session:经典对决
2.1 面试官问:"JWT 和 Session 的区别是什么?什么时候用哪个?"
这是 JWT 面试的"必考题"。面试官想听的不是"JWT 比较新所以用 JWT",而是你对两种方案的取舍理解。
核心差异:状态存在哪里
arduino
Session 方案 JWT 方案
─────────────── ─────────
状态存在服务端 状态存在客户端
┌──────────┐ ┌──────────┐
│ 浏览器 │ │ 浏览器 │
│ Session ID│ │ 完整的 │
│ = "abc" │ │ JWT Token│
└─────┬─────┘ └─────┬─────┘
│ │
携带 Session ID 携带完整 JWT
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 服务端 │ │ 服务端 │
│ │ │ │
│ Session 表 │ │ 无需查询任何表 │
│ "abc" → { │ │ 直接用密钥验签 │
│ userId:1, │ │ 即可确认身份 │
│ name:"zs" │ │ │
│ } │ │ │
└──────────────┘ └──────────────┘
有状态(Stateful) 无状态(Stateless)
一句话总结:Session 是"服务端记账",JWT 是"服务端看过你身份证就行"。
完整对比表:
| 维度 | Session | JWT |
|---|---|---|
| 存储位置 | 服务端(内存/Redis/DB) | 客户端(浏览器) |
| 扩展性 | 需要共享 Session 存储(Redis 集群) | 天然支持水平扩展 |
| 注销控制 | 即时(删除服务端记录即可) | 难以即时失效(需配合黑名单) |
| CSRF 风险 | 存在(Cookie 自动携带) | 较低(需手动附加 Header) |
| XSS 风险 | HttpOnly Cookie 可防御 | localStorage 易被 XSS 读取 |
| 每次请求开销 | 查一次 Redis/DB | 做一次 HMAC 验签(CPU 运算) |
| 多设备登录 | 难以天然支持 | 每设备独立签发 Token |
| 适合场景 | 单体应用、强实时注销需求 | 微服务、移动端、跨域 API |
选择建议(面试脱口而出版):
"选 Session 还是 JWT,核心看两个维度:一是系统是否分布式,二是对即时注销的需求有多强。
如果是一个单体的企业内部系统,需要管理员能随时踢人下线,Session 更好------服务端删一条 Redis 记录就完事了。
如果是微服务架构、同时有 Web 端和移动端,JWT 更合适------不需要所有服务都依赖同一个 Redis 来做 Session 校验,每个服务拿到 JWT 自己验签就行。"
2.2 面试官追问:"JWT 最大的缺点是什么?你怎么解决的?"
这个问题也是高频题。JWT 最大的痛点是 Token 无法主动失效。
"JWT 一旦签发,在过期之前它就是有效的。Session 方案中,管理员可以在服务端直接删除 Session,用户立刻就被踢出去了。但 JWT 做不到------因为状态在客户端,除非你引入额外的机制。"
三种常见的补救方案:
| 方案 | 实现方式 | 代价 |
|---|---|---|
| 黑名单 | Redis 中维护一个已失效 Token 列表 | 每次请求多一次 Redis 查询,部分丧失无状态优势 |
| 短有效期 + 刷新 | access_token 设 15 分钟,用 refresh_token 续期 | 不能做到"即时"失效,最多等 15 分钟 |
| 版本号 | 用户表中加一个 tokenVersion 字段,JWT 中携带版本号 |
需要查数据库,依然丧失无状态优势 |
我们的项目选择了方案二------短有效期 + 刷新机制,同时这也是引出双 Token 机制的绝佳过渡。
第三章 双 Token 机制:面试中的进阶考点
3.1 面试官问:"你们的系统为什么设计两个 Token?"
如果前面的 JWT 基础是 60 分的及格线,双 Token 机制就是拉开分差的关键题。
面试话术:
"这是一个安全性和用户体验的经典权衡。核心思路是:用两个不同生命周期的 Token,分别服务于两个不同的使用场景。
access_token 是'工作证',只有 15 分钟有效期。它高频使用------挂在每个 API 请求的
Authorization头里。十几分钟的有效期意味着即使它在传输过程中被中间人截获,攻击窗口也只有 15 分钟。refresh_token 是'身份证',有效期 7 天。它低频使用------只在 access_token 过期后才拿出来用一次,换取新的一对 Token。因为传输频率低,暴露的风险就小。
如果只有一个长期 Token,一旦被窃取,攻击者可以冒充用户整整 7 天。如果只有一个短期 Token,用户每 15 分钟就要重新输入密码。双 Token 机制在这两个极端之间找到了平衡点。"
3.2 面试官追问:"具体怎么实现双 Token 的签发和刷新?"
签发(Login 时):
typescript
// auth.service.ts --- login 方法
async login(loginDto: LoginDto) {
// 1. 查用户
const user = await this.prisma.user.findUnique({
where: { name: loginDto.name }
});
// 2. 验密码
if (!user || !(await bcrypt.compare(loginDto.password, user.password))) {
throw new UnauthorizedException('用户名或密码错误');
}
// 3. 签发双 Token:用 Promise.all 并行生成,不浪费时间
return {
...(await this.generateTokens(String(user.id), user.name)),
user: { id: String(user.id), name: user.name },
};
}
// 核心:两个 Token,共享 payload,不同的过期时间
private async generateTokens(id: string, name: string) {
const payload = { sub: id, name };
return {
access_token: await this.jwtService.signAsync(payload, { expiresIn: '15m' }),
refresh_token: await this.jwtService.signAsync(payload, { expiresIn: '7d' }),
};
}
刷新(access_token 过期后):
typescript
// auth.service.ts --- refreshToken 方法
async refreshToken(rt: string) {
try {
// 验证 refresh_token 是否仍然有效
const payload = await this.jwtService.verifyAsync(rt, {
secret: process.env.TOKEN_SECRET,
});
// 有效 → 签发全新的一对 Token(滚动更新)
return this.generateTokens(payload.sub, payload.name);
} catch (e) {
throw new UnauthorizedException('Refresh Token 已失效,请重新登录');
}
}
滚动更新的巧妙之处 :注意 refreshToken() 不是只返回新的 access_token,而是调用 generateTokens() 返回全新的一对 Token。这意味着每次刷新:
- 旧的
access_token被替换 ✓ - 旧的
refresh_token也被替换 ✓
这带来的安全收益是:如果攻击者窃取了某个 refresh_token,一旦合法用户正常使用并触发了一次刷新,攻击者手中的 refresh_token 就变成了废纸。
3.3 前端如何配合双 Token 机制?
存储:Token 通过 Zustand + persist 中间件持久化到 localStorage。
typescript
// useUserStore.ts
const useUserStore = create<UserState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
user: null,
isLogin: false,
login: async (credentials) => {
const res = await doLogin(credentials); // POST /api/auth/login
set({
accessToken: res.access_token,
refreshToken: res.refresh_token,
user: res.user,
isLogin: true,
});
},
}),
{
name: 'user-store', // localStorage key
partialize: (state) => ({ // 只持久化这些,login 方法不会被序列化
accessToken: state.accessToken,
refreshToken: state.refreshToken,
user: state.user,
isLogin: state.isLogin,
}),
}
)
);
自动携带 :Axios 请求拦截器从 Store 中读取 accessToken,注入到每个请求的 Authorization 头。
typescript
// api/config.ts
axios.interceptors.request.use(config => {
const token = useUserStore.getState().accessToken; // 用 getState() 而非 hook
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
面试加分点 :解释为什么用
getState()而非useUserStore()------ 拦截器是在 React 组件树之外执行的,不能用 hook,而getState()是 Zustand 提供的命令式 API,可以在任何地方读取 Store 的当前值。
3.4 面试官可能会追问:"为什么不把 Token 存在 Cookie 里?"
这又是一个展示深度理解的好机会。
| 存储方式 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| localStorage | API 简单,不随请求自动发送 | XSS 攻击可直接读取 | SPA + 手动管理 Header |
| HttpOnly Cookie | JS 无法读取,防 XSS | 自动随请求发送(CSRF 风险) | 服务端渲染、同域应用 |
| 内存变量 | 绝对安全 | 刷新页面即丢失 | 高安全场景、配合 refresh_token |
"选 localStorage 还是 Cookie,核心区别在于两个安全风险的取舍:localStorage 怕 XSS,Cookie 怕 CSRF。本项目选 localStorage 是因为作为 SPA 应用,Token 需要由 JS 主动管理并拼接到
AuthorizationHeader 中,而 HttpOnly Cookie 对 JS 是不可见的。安全性的补强是通过**缩短 access_token 的有效期(15 分钟)**来降低 XSS 泄漏后的风险窗口。"
第四章 AuthGuard:路由守卫的两层防线
4.1 面试官问:"后端怎么保护需要认证的接口?"
话术:
"后端用的是 NestJS 的 Guard 机制,具体实现是一个叫
JwtAuthGuard的守卫类。它本质上是一个中间的拦截层------在请求到达 Controller 之前把它拦下来,验证身份。如果没带 Token 或者 Token 无效,直接返回 401,Controller 根本不会执行。"
源码拆解:
typescript
// guard/jwt-auth.guard.ts --- 守卫类本身(一句话实现)
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
就一行代码?没错。真正的验证逻辑在 JwtStrategy 中:
typescript
// jwt.strategy.ts --- Passport 策略,定义了"怎么验证"
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
// ① Token 从哪里来:从 Authorization: Bearer <token> 中提取
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// ② 过期 Token 怎么处理:直接拒绝,绝不放行
ignoreExpiration: false,
// ③ 用什么密钥验签:和签发时用的同一个 secret
secretOrKey: process.env.TOKEN_SECRET || ""
});
}
// ④ 验证通过后,从 payload 中提取什么信息放到 request.user
async validate(payload: any) {
return {
id: payload.sub, // sub → id 的字段映射
name: payload.name,
};
}
}
执行流程可视化:
css
请求进入
│
▼
┌─ JwtAuthGuard ──────────────────────────────────────┐
│ ① 从 Authorization header 中提取 Bearer token │
│ ② 检查 token 是否存在 │
│ 不存在 → 401 Unauthorized │
│ 存在 → 交给 JwtStrategy │
└────────────────────┬────────────────────────────────┘
│
▼
┌─ JwtStrategy ───────────────────────────────────────┐
│ ③ 用 TOKEN_SECRET 验证签名 │
│ 签名不匹配 → 401 Unauthorized(token 被篡改过) │
│ 签名匹配 → 继续 │
│ ④ 检查是否过期(ignoreExpiration: false) │
│ 已过期 → 401 Unauthorized │
│ 未过期 → 继续 │
│ ⑤ 调用 validate(payload) │
│ return { id: payload.sub, name: payload.name } │
│ → 挂载到 request.user │
└────────────────────┬────────────────────────────────┘
│
▼
Controller 方法
req.user = { id: "1", name: "zhangsan" }
4.2 面试官问:"前端也有路由守卫,和后端的有什么区别?"
这是一个考察安全意识的问题。
后端守卫(真正安全的):
typescript
// 装饰器声明:这个接口需要认证
@Get('mine')
@UseGuards(JwtAuthGuard)
async getMyPosts(@Req() req) {
// 在这里 req.user 一定是真实有效的
// 因为这个方法能被调用的唯一前提是 JwtAuthGuard 放行了
return this.postsService.findByUser(req.user.id);
}
前端守卫(提升体验的):
typescript
// App.tsx --- 体验层面的守卫
const needsLoginPath = ['/mine', '/order', '/chat'];
useEffect(() => {
if (!isLogin && needsLoginPath.includes(pathname)) {
navigate('/login'); // 没登录就走你!
}
}, [isLogin, pathname]);
关键论述:
"前端守卫和后端守卫解决的是两个完全不同的问题。前端守卫是用户体验 问题------防止用户看到一个全是报错的空白页面。后端守卫是安全问题------防止未授权的请求真的访问到数据。
一个常见的误区是:'前端已经做了路由守卫,后端可以放松一点。'这是大错特错的。因为前端代码运行在用户的浏览器中,我可以打开 DevTools 把
isLogin改成true,可以用curl直接发包绕过整个前端。前端守卫本质上是一扇没锁的门------它引导好人走正门,但挡不住坏人翻墙。真正的安全防线永远是后端的
JwtAuthGuard,它不信任任何客户端数据,只信任自己验签的结果。"
4.3 面试官追问:"JwtAuthGuard 是怎么注册到 NestJS 的模块系统中的?"
考察对 NestJS 依赖注入的理解:
typescript
// auth.module.ts
@Module({
imports: [
JwtModule.register({
secret: process.env.TOKEN_SECRET // 全局注册 JWT 模块和密钥
})
],
controllers: [AuthController], // 登录、刷新接口
providers: [AuthService, JwtStrategy, JwtAuthGuard], // 守卫注册为 Provider
exports: [JwtAuthGuard] // 导出给其他模块使用
})
export class AuthModule {}
"
JwtAuthGuard被注册为AuthModule的 provider 并被导出。其他模块(比如PostsModule)只需要在imports中引入AuthModule,就可以在 Controller 上通过@UseGuards(JwtAuthGuard)直接使用。这是 NestJS 模块系统的一个设计优势------守卫作为服务可以被注入和共享。"
第五章 完整面试问答模拟
第一回合:基础认知
Q: "你们的系统怎么做用户认证的?"
"用的是基于 JWT 的无状态认证方案。用户登录成功后,后端签发两个 Token------一个 15 分钟有效的 access_token 用于日常请求鉴权,一个 7 天有效的 refresh_token 用于过期后续期。前端拿到后用 Zustand 管理状态并持久化到 localStorage,每次发请求时通过 Axios 拦截器自动携带 Bearer Token。后端通过 NestJS 的 JwtAuthGuard 守卫保护需要认证的接口,守卫内部用 Passport 的 JWT 策略做签名验证和过期检查。"
第二回合:双 Token 深度
Q: "为什么要用两个 Token?一个长有效期的 Token 不行吗?"
"纯粹从功能上讲,一个长期 Token 也能跑。但安全性上会有严重问题:这个 Token 会被高频传输在网络上,一旦被中间人截获,攻击者就拥有了长达 N 天的冒充权限。
双 Token 的本质思路是通过频率隔离来降低风险。access_token 高频使用但短期有效------即使泄露,15 分钟后自动失效。refresh_token 长期有效但低频使用------大部分时间躺在 localStorage 里,很少在网络中传输,暴露概率大幅降低。
这就像你不会把身份证天天挂在脖子上出门------日常用门禁卡就够了,门禁卡丢了损失有限;身份证只在必要时才拿出来。"
第三回合:守卫机制
Q: "AuthGuard 具体是怎么工作的?"
"NestJS 的 AuthGuard 是基于 Passport.js 的封装。它做的事情本质上是一个验证链:
第一步,从请求的
Authorization头中提取 Bearer Token 字符串。 第二步,用TOKEN_SECRET密钥验证 JWT 签名,确保 Token 没有被篡改。 第三步,检查 Token 的exp字段,判断是否过期。 第四步,调用validate()方法,将 JWT payload 中的应用字段(如sub)映射为业务中更好理解的对象(如request.user.id),然后把它挂载到request对象上。任何一步失败,整个请求直接 401,Controller 里的业务逻辑根本不会执行。"
第四回合:安全攻防
Q: "如果有人窃取了 access_token,你怎么防御?"
"分三层来答:
第一层是事前预防:access_token 只有 15 分钟有效期,把攻击窗口压缩到最小。这是最核心的防线。
第二层是事中检测:可以通过 IP 变更、设备指纹等异常检测来感知 token 可能被盗用。不过这需要额外的基础设施。
第三层是事后止损:如果确实发现 token 泄露,目前可以通过让用户修改密码并配合 token 版本号机制来批量失效所有已签发的 token。
但也要坦诚地说------JWT 无状态的天性决定了它无法做到像 Session 那样即时撤销。这是选择 JWT 方案时必须接受的 trade-off。如果系统对'即时踢人下线'有硬性要求,应该考虑 Session 方案或为 JWT 补充黑名单机制。"
第五回合:实战踩坑
Q: "做这个认证系统时遇到过什么问题?"
"说一个比较典型的------Token 过期后的静默处理。
用户正在填一个表单,15 分钟过去了,access_token 悄悄过期了。当他点击提交时,后端返回 401,前端直接抛错,用户一脸茫然。
理想的处理方案是在 Axios 的响应拦截器中捕获 401 错误,自动用 refresh_token 去换新的 access_token,然后透明地重试原来的请求。用户全程无感知。
这看上去简单,但实际实现时有几个细节需要注意:一是并发请求时会同时触发多个刷新,需要给刷新操作加锁,确保只发一次刷新请求;二是要考虑刷新失败的降级策略------可能是 refresh_token 也过期了,这时就该引导用户重新登录。"
第六章 面试加分要点速记表
| 主题 | 关键词 | 一句话要点 |
|---|---|---|
| JWT 本质 | 签名 ≠ 加密 | JWT 的内容可被任何人解码查看,但签名保证了不可篡改 |
| JWT 结构 | Header.Payload.Signature | 三段 Base64,点分隔,签名 = HMAC(header.payload, secret) |
| 无状态 | 服务端不存状态 | 每次验签即认证,无需查数据库 |
| 双 Token | 频率隔离 | 高频短效 + 低频长效 = 安全与体验的平衡 |
| 15 分钟 | 攻击窗口 | access_token 过期越快,被窃取后的损失越小 |
| 滚动刷新 | refresh 也换新 | 每次刷新同时颁发新 access_token 和新 refresh_token |
| AuthGuard | 验证链 | 提取 Token → 验签 → 查过期 → 注入 req.user |
| 前后端守卫 | 体验 ≠ 安全 | 前端守卫治未病(引导体验),后端守卫治已病(真正安全) |
| XSS vs CSRF | 存储选择 | localStorage 怕 XSS,HttpOnly Cookie 怕 CSRF,选哪个看你能防哪个 |
| 即时失效 | JWT 的死穴 | 签发即有效,无法服务端单方面撤销,需黑名单或版本号补充 |
结语
JWT 认证之所以成为面试中的高频话题,不是因为它有多复杂,而是因为它踩中了分布式系统身份认证中最核心的几个矛盾:安全与便捷、有状态与无状态、即时撤销与自主校验。一套成熟的双 Token + AuthGuard 方案,本质上是对这些矛盾的务实的折中。
面试时如果能把"为什么是 15 分钟而不是 10 分钟或 30 分钟"、"为什么 refresh_token 也要滚动更新"、"为什么前端守卫不能替代后端守卫"这几个问题讲清楚,你就已经不是在背答案了,而是在展示你对系统设计的真实思考。