面试通关:JWT 认证与双 Token 机制深度解析

面试通关: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 的当前值。

这又是一个展示深度理解的好机会。

存储方式 优点 缺点 适合场景
localStorage API 简单,不随请求自动发送 XSS 攻击可直接读取 SPA + 手动管理 Header
HttpOnly Cookie JS 无法读取,防 XSS 自动随请求发送(CSRF 风险) 服务端渲染、同域应用
内存变量 绝对安全 刷新页面即丢失 高安全场景、配合 refresh_token

"选 localStorage 还是 Cookie,核心区别在于两个安全风险的取舍:localStorage 怕 XSS,Cookie 怕 CSRF。本项目选 localStorage 是因为作为 SPA 应用,Token 需要由 JS 主动管理并拼接到 Authorization Header 中,而 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 也要滚动更新"、"为什么前端守卫不能替代后端守卫"这几个问题讲清楚,你就已经不是在背答案了,而是在展示你对系统设计的真实思考。

相关推荐
kyriewen3 小时前
你还在手动敲命令部署?GitHub Actions 让你 push 即上线,摸鱼时间翻倍
前端·面试·github
怕浪猫3 小时前
荒岛原始无工业、无电力、无设备,从零搭建最基础计算机体系
人工智能·设计模式·面试
想学习java初学者4 小时前
SpringBoot整合Vertx-Mqtt多租户(优化版)
java·spring boot·后端
Csvn5 小时前
Python 性能优化与 Profiling 工具
后端·python
不减20斤不改头像5 小时前
手机一句话开发贪吃蛇!TRAE SOLO 移动端 AI 编程实测
前端·后端
明月_清风6 小时前
K8s 从入门到上手:核心概念+常用工具全解析
后端·kubernetes
随风,奔跑6 小时前
Nginx
服务器·后端·nginx·web
小村儿8 小时前
给 AI Agent 装上"长期记忆":Karpathy 的 LLM Wiki 思想,我做成了工具
前端·后端·ai编程
何陋轩8 小时前
Spring AI实战指南:在Java项目中集成大语言模型
人工智能·后端·机器学习