REST/GraphQL单页应用(SPA)安全认证最佳实践咨询
你提到的困惑我太懂了——一堆文章只骂方案不好,却不给解决办法,着实头疼!我自己做过几个SPA+GraphQL项目,下面结合你的疑问逐个拆解痛点,再给出实际落地的方案。
先澄清一个关键误区
你说得对,GraphQL和REST在认证逻辑上没本质区别,核心都是「客户端证明身份」,不用被GraphQL的特性绑住手脚,很多REST的成熟方案完全可以复用。
针对你列出的三个方案,逐个解决痛点
1. Cookies + CSRF 防护:最稳妥的原生方案
你担心Cookies的CSRF问题,但只要做好防护,完全可以安心用——这是浏览器原生支持的方案,兼容性和安全性都拉满。
SPA里怎么实现CSRF防护?
- 后端设置
HttpOnly、Secure、SameSite=Strict/Lax的Session Cookie(HttpOnly是核心,能防止XSS窃取令牌)。 - 后端在登录成功后,把CSRF令牌返回给前端(可以放在GraphQL的返回数据里,比如
loginmutation返回csrfToken字段)。 - SPA把令牌存在内存状态里(比如Vuex/Redux,别存LocalStorage!),页面刷新后重新获取即可。
- 每次发送GraphQL请求时,把令牌放在自定义请求头(比如
X-CSRF-Token)里。 - 后端验证:收到POST请求时,检查请求头的CSRF令牌和Session中存储的是否一致。
内存存储的优势是:LocalStorage容易被XSS窃取,而内存里的令牌页面刷新就消失,风险低很多。
2. JWT:不是不能用,是别用它当会话存储
那些批判JWT的文章,核心槽点是「把JWT当Session存在LocalStorage里」——确实,JWT一旦签发就无法主动失效,且LocalStorage的XSS风险极高。但换一种用法,JWT也能发挥价值:
用JWT做短期访问令牌,搭配刷新令牌(存在HttpOnly Cookie里):
- 用户登录后,后端返回有效期15分钟左右的JWT(存在内存),同时设置一个HttpOnly的刷新令牌(有效期7天)。
- 每次GraphQL请求,把JWT放在
Authorization: Bearer <token>头里。 - JWT过期后,客户端用刷新令牌(浏览器自动携带Cookie)请求后端刷新接口,获取新的JWT。
- 后端在数据库里维护刷新令牌的状态,随时可以失效某个令牌,解决JWT无法主动失效的问题。
这种方案适合需要分布式部署的场景,但如果你不需要水平扩展,其实没必要折腾这个。
3. 自定义令牌(X-Auth-Token):适配SPA的改造思路
你说这个方案不适配SPA,其实是传统REST的存储方式有问题。改造一下:
- 令牌不要存LocalStorage,而是存在内存状态里,页面刷新后重新登录或用刷新令牌获取新令牌。
- 后端给令牌设置有效期,在数据库里维护令牌状态,随时可失效。
- 发送GraphQL请求时,把令牌放在
X-Auth-Token头里,后端验证即可。
但这种方案没有浏览器原生的自动携带机制,需要手动处理令牌的存储和携带,反而比Cookie方案麻烦,一般不优先推荐。
我自己的首选方案:Cookies + CSRF 防护
如果你的应用不需要大规模水平扩展,Cookies + CSRF防护绝对是最佳实践,原因如下:
- 浏览器原生支持,不用手动处理令牌的存储和携带,减少代码复杂度。
- HttpOnly Cookie能有效防止XSS窃取令牌。
- CSRF防护做好令牌验证,就能彻底规避风险。
- 后端可以轻松实现会话失效、踢下线等功能,比JWT灵活太多。
具体落地示例(React + Apollo Client)
- 后端设置Session Cookie(Node.js/Express):
app.use(session({ secret: 'your-strong-secret-key', resave: false, saveUninitialized: false, cookie: { httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'Strict', maxAge: 24 * 60 * 60 * 1000 // 1天有效期 } })); - 后端登录Mutation返回CSRF令牌:
type Mutation { login(username: String!, password: String!): LoginResponse } type LoginResponse { user: User csrfToken: String } - SPA把CSRF令牌存入Redux状态:
// 登录成功后保存令牌 dispatch(setCsrfToken(data.csrfToken)); - Apollo Client携带CSRF令牌:
const httpLink = createHttpLink({ uri: '/graphql', headers: { 'X-CSRF-Token': store.getState().auth.csrfToken } }); - 后端验证CSRF令牌:
app.use('/graphql', (req, res, next) => { if (req.method === 'POST') { const csrfToken = req.headers['x-csrf-token']; if (!csrfToken || csrfToken !== req.session.csrfToken) { return res.status(403).send('Invalid CSRF token'); } } next(); });
关于「无状态」的误解
很多文章鼓吹API要无状态,但99%的应用真的不需要——Session存在Redis或数据库里,完全不影响小规模水平扩展,而且可控性比无状态方案强太多。别被「无状态」的概念绑架,适合自己业务的才是最好的。
内容的提问来源于stack exchange,提问作者enumag

