You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Remix+Go后端JWT静默登录:Refresh Token回写Cookie难题

Remix + Go后端Token刷新正确实现方案

核心问题拆解

  • 竞态根源:多个后端请求(action/loader)同时检测到ACCESS_TOKEN过期,会重复触发REFRESH_TOKEN刷新请求,导致Redis中旧token被多次删除、新token覆盖混乱,且Set-Cookie头传递冲突
  • Remix后端无法直接操作客户端Cookie,必须通过响应头Set-Cookie传递,因此需要统一刷新逻辑,避免并发冲突

统一Token刷新逻辑(后端全局拦截)

不要在单个action/loader中零散处理刷新,而是封装全局Axios实例,结合锁机制避免竞态,统一管理session和Cookie。

1. 封装带刷新逻辑的Axios实例

import axios from "axios";
import { createCookieSessionStorage } from "@remix-run/node";
import mem from "mem"; // 单实例部署用mem做内存锁,多实例建议用Redis分布式锁

// 初始化Session存储,配置Cookie参数
const sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "auth_session",
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 7 * 24 * 60 * 60, // 与Go后端REFRESH_TOKEN有效期保持一致
  },
});

// 封装去重的refresh请求:同一旧REFRESH_TOKEN短时间内只触发一次刷新
const refreshTokens = mem(
  async (oldRefreshToken: string): Promise<{ accessToken: string; refreshToken: string; setCookie: string }> => {
    // 调用Go后端的/refresh接口
    const response = await axios.post("http://your-go-backend/refresh", {
      refresh_token: oldRefreshToken,
    });
    const newAccessToken = response.data.access_token;
    const newRefreshToken = response.data.refresh_token;

    // 更新Session并生成Set-Cookie头
    const session = await sessionStorage.getSession();
    session.set("access_token", newAccessToken);
    session.set("refresh_token", newRefreshToken);
    const setCookieHeader = await sessionStorage.commitSession(session);

    return { accessToken: newAccessToken, refreshToken: newRefreshToken, setCookie: setCookieHeader };
  },
  {
    key: (oldToken) => `refresh_lock_${oldToken}`,
    maxAge: 5000, // 5秒内同一旧Token只允许一次刷新请求
  }
);

// 创建全局API客户端
const apiClient = axios.create({ baseURL: "http://your-go-backend" });

// 请求拦截器:自动携带ACCESS_TOKEN
apiClient.interceptors.request.use(async (config) => {
  // 从请求头传入Remix的request对象,用于获取Session
  const remixRequest = config.headers["remix-request"] as Request;
  const session = await sessionStorage.getSession(remixRequest.headers.get("Cookie"));
  const accessToken = session.get("access_token");
  
  if (accessToken) {
    config.headers.Authorization = `Bearer ${accessToken}`;
  }
  return config;
});

// 响应拦截器:处理TOKEN过期
apiClient.interceptors.response.use(
  (res) => res,
  async (error) => {
    const originalRequest = error.config;
    // 仅处理401且未重试过的请求
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // 标记已重试,避免无限循环
      
      const remixRequest = originalRequest.headers["remix-request"] as Request;
      const session = await sessionStorage.getSession(remixRequest.headers.get("Cookie"));
      const oldRefreshToken = session.get("refresh_token");

      if (!oldRefreshToken) {
        // 无有效REFRESH_TOKEN,直接返回错误
        return Promise.reject(error);
      }

      try {
        // 获取新Token和Set-Cookie头
        const { accessToken, setCookie } = await refreshTokens(oldRefreshToken);
        // 更新原请求的Authorization头
        originalRequest.headers.Authorization = `Bearer ${accessToken}`;
        // 把Set-Cookie头暂存,后续统一返回给客户端
        originalRequest._setCookie = setCookie;
        // 重新发送请求
        return apiClient(originalRequest);
      } catch (refreshErr) {
        // 刷新失败,清除Session并引导登录
        const session = await sessionStorage.getSession(remixRequest.headers.get("Cookie"));
        const clearCookie = await sessionStorage.destroySession(session);
        return Promise.reject({
          ...refreshErr,
          _clearCookie: clearCookie,
        });
      }
    }
    return Promise.reject(error);
  }
);

export default apiClient;

2. 在Action/Loader中使用封装好的客户端

import { ActionArgs, json, redirect } from "@remix-run/node";
import apiClient from "~/utils/api-client";

export const action = async ({ request }: ActionArgs) => {
  try {
    // 传入Remix的request对象,供拦截器获取Session
    const resA = await apiClient.get("/getA", { headers: { "remix-request": request } });
    const resB = await apiClient.get("/getB", { headers: { "remix-request": request } });
    const resC = await apiClient.get("/getC", { headers: { "remix-request": request } });

    // 收集所有需要设置的Cookie头
    const headers = new Headers();
    // 检查是否有刷新后生成的Set-Cookie
    const setCookie = resA.config._setCookie || resB.config._setCookie || resC.config._setCookie;
    if (setCookie) {
      headers.append("Set-Cookie", setCookie);
    }

    return json(
      { dataA: resA.data, dataB: resB.data, dataC: resC.data },
      { headers }
    );
  } catch (error) {
    // 处理刷新失败的情况,清除Cookie并跳转登录
    if (error._clearCookie) {
      return redirect("/login", {
        headers: { "Set-Cookie": error._clearCookie },
      });
    }
    return json({ error: "请求失败" }, { status: 500 });
  }
};

竞态问题解决关键点

  • 用mem做请求去重:同一旧REFRESH_TOKEN在5秒内只触发一次刷新,避免并发请求冲突;多实例部署时替换为Redis分布式锁
  • 标记_retry字段:防止单个请求因刷新失败陷入无限循环
  • 统一通过SessionStorage管理Cookie:避免手动拼接Set-Cookie头出错,确保Cookie的安全性和正确性

Go后端配合注意事项

  • /refresh接口处理完刷新后,立即从Redis中删除旧的REFRESH_TOKEN,防止重复使用
  • 无需在Go后端设置Set-Cookie头,完全由Remix后端处理Session和Cookie

内容的提问来源于stack exchange,提问作者SamuelTJackson

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 16:34:59