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

8 阅读17分钟

面试通关: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,用点分隔

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
│                                              │                                                         │
│          Header(头部)                        │              Payload(负载)                              │          Signature(签名)

逐段拆解:

Header — 描述 Token 的元信息

{
  "alg": "HS256",    // 签名算法:HMAC-SHA256
  "typ": "JWT"       // 令牌类型
}

用 base64 编码后就变成了第一段:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

Payload — 要传递的数据

{
  "sub": "1",          // subject:用户唯一标识(JWT 标准字段)
  "name": "zhangsan",  // 自定义字段:用户名
  "iat": 1715000000,   // issued at:签发时间(JWT 标准字段)
  "exp": 1715000900    // expiration:过期时间(JWT 标准字段)
}

Signature — 防篡改的核心

HMACSHA256(
    base64UrlEncode(header) + "." + base64UrlEncode(payload),
    secret
)

签名计算的本质是:将 header 和 payload 拼接后,用密钥做一次 HMAC-SHA256 哈希。任何人修改了 header 或 payload 中的哪怕一个字符,生成的签名就会完全不一样——而攻击者没有密钥,无法重新生成有效签名。

1.3 项目中怎么实现的?

在我们的 NestJS 后端中,Token 签发逻辑在 AuthService.generateTokens() 中:

// 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",而是你对两种方案的取舍理解

核心差异:状态存在哪里

Session 方案                          JWT 方案
───────────────                      ─────────
状态存在服务端                          状态存在客户端
┌──────────┐                        ┌──────────┐
│  浏览器    │                        │  浏览器    │
│  Session ID│                       │  完整的    │
│  = "abc"  │                        │  JWT Token│
└─────┬─────┘                        └─────┬─────┘
      │                                    │
      携带 Session ID                       携带完整 JWT
      │                                    │
      ▼                                    ▼
┌──────────────┐                     ┌──────────────┐
│   服务端       │                     │   服务端       │
│              │                     │              │
│  Session 表   │                     │  无需查询任何表 │
│  "abc" → {   │                     │  直接用密钥验签 │
│    userId:1, │                     │  即可确认身份   │
│    name:"zs" │                     │              │
│  }           │                     │              │
└──────────────┘                     └──────────────┘
   有状态(Stateful)                     无状态(Stateless)

一句话总结:Session 是"服务端记账",JWT 是"服务端看过你身份证就行"。

完整对比表

维度SessionJWT
存储位置服务端(内存/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 时)

// 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 过期后)

// 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。

// 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 头。

// 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 里?"

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

存储方式优点缺点适合场景
localStorageAPI 简单,不随请求自动发送XSS 攻击可直接读取SPA + 手动管理 Header
HttpOnly CookieJS 无法读取,防 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 根本不会执行。"

源码拆解

// guard/jwt-auth.guard.ts — 守卫类本身(一句话实现)
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

就一行代码?没错。真正的验证逻辑在 JwtStrategy 中:

// 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,
        };
    }
}

执行流程可视化

请求进入
    │
    ▼
┌─ 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 面试官问:"前端也有路由守卫,和后端的有什么区别?"

这是一个考察安全意识的问题。

后端守卫(真正安全的):

// 装饰器声明:这个接口需要认证
@Get('mine')
@UseGuards(JwtAuthGuard)
async getMyPosts(@Req() req) {
    // 在这里 req.user 一定是真实有效的
    // 因为这个方法能被调用的唯一前提是 JwtAuthGuard 放行了
    return this.postsService.findByUser(req.user.id);
}

前端守卫(提升体验的):

// 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 依赖注入的理解:

// 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 也要滚动更新"、"为什么前端守卫不能替代后端守卫"这几个问题讲清楚,你就已经不是在背答案了,而是在展示你对系统设计的真实思考。