给 `<img>` 的请求加上 Authorization 头?一个"看起来无解"的前端鉴权坑

<img> 的请求加上 Authorization 头?一个"看起来无解"的前端鉴权坑

背景

业务系统里有个常见场景:用户登录后,所有后端请求都要带 Authorization: Bearer <token> 鉴权。

axios 请求拦截器已经全局注入了鉴权头:

instance.interceptors.request.use((config) => {
  config.headers.Authorization = `Bearer ${getToken()}`;
  return config;
});

看起来万事大吉。直到安全测试反馈:文件接口 /file/getFile 可以匿名访问,越权获取任意文件。

打开 DevTools 一看,所有打到 /file/getFile 的请求,请求头里都没有 authorization

问题定位:为什么 token 没带上?

排查发现,文件接口在页面上有四种使用方式,axios 拦截器只覆盖其中一种:

使用方式 典型场景 经过 axios? 有鉴权头?
axios.get(...) 表单接口请求 ✅ 有
fetch(url) 预览组件拉文件流 ❌ 无
<a href={url} download> 点击下载附件 ❌ 无
<img src={url}> / <iframe> / CSS background-image 缩略图、头像、PDF 预览、背景图 ❌ 无

后三种绕过了 axios 拦截器,所以没带 token。这是问题根因。

第一个念头:给 URL 拼 token query 参数?

最朴素的想法——请求头加不上,那就把 token 拼进 URL:

/file/getFile?filePath=xxx&token=79c89176-...

但这有硬伤:

  • token 暴露在 URL 里,会被浏览器历史记录、代理日志、Referer 头记录,有安全隐患。
  • 主登录态的 access_token 不适合长期放 URL(query token 一般只用于短期外链凭证)。
  • 规范要求 token 走请求头。

需求明确:必须在请求头里加 Authorization

第二步:fetch 和 <a> 下载,改成带头的 blob

JS 发起的请求(fetch/axios)能加自定义头。所以对 fetch 预览和 <a> 下载,统一改成「带 Authorization 头拉 blob → 再用」:

// 带头拉文件流,供预览组件用
async function fetchFileBlob(url) {
  const token = getToken();
  const res = await fetch(url, {
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  });
  if (!res.ok) throw new Error(`文件加载失败:${res.status}`);
  return res.blob();
}

// 带头下载:fetch 拉 blob → <a download> 触发
async function downloadFileByAuth(url, fileName) {
  const blob = await fetchFileBlob(url);
  const objectUrl = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = objectUrl;
  link.download = fileName || "";
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(objectUrl);
}

把全项目所有 <a href={文件直链}> 下载、fetch 预览替换成这两个函数,这一档顺利解决。

真正的坑:<img> 怎么办?

剩下最棘手的一档:<img src> / <iframe src> / CSS background-image

这些是浏览器原生请求。你写 <img src={url}>,浏览器自己去请求那个 URL,JS 代码完全无法干预它的请求头。不像 fetch 可以传 headers 选项,<img> 没有任何 API 能让你塞一个 Authorization 进去:

<img src="/file/getFile?filePath=xxx" />  <!-- 浏览器发的请求,没有 Authorization -->

这就是为什么缩略图、头像这类,抓包永远看不到 authorization 头。

尝试一:改成 blob URL?

<img src> 改成「fetch 带头拉 blob → URL.createObjectURL → 绑到 src」:

const blob = await fetchFileBlob(url);  // 带头
imgEl.src = URL.createObjectURL(blob);

技术上可行,但代价大:

  • 全项目几十处用到文件图片的地方都要改,每个 <img>/<el-image> 都要包一层异步逻辑。
  • 图片失去浏览器原生的 HTTP 缓存、懒加载、并发优化。
  • 图片密集的列表页性能明显下降。

尝试二:Service Worker 网络层拦截,这个就是面试经常问的

Service Worker 本质上是一个运行在浏览器后台的 独立线程脚本,它充当网页和网络之间的代理层(拦截层),可以拦截和处理网络请求。

Service Worker 是浏览器提供的一个「网络代理」层,可以拦截页面发出的所有请求(包括 <img>/<iframe>/fetch),并修改请求头后重新发起。

这正是我们要的:在 Service Work 层统一拦截文件接口,注入 Authorization 头,业务代码零改动

// sw.js
self.addEventListener("fetch", (event) => {
  const { request } = event;
  if (request.method !== "GET") return;
  if (!/\/file\/getFile\b/.test(request.url)) return;
  // 已带头(axios/fetch)的跳过,不重复处理
  if (request.headers.get("Authorization")) return;

  event.respondWith(
    (async () => {
      const token = await getToken();  // 从 IndexedDB 读 token
      if (!token) return fetch(request);  // 免鉴权场景放行
      const headers = new Headers(request.headers);
      headers.set("Authorization", `Bearer ${token}`);
      return fetch(request, { headers, mode: "cors" });  // ← 关键:mode cors
    })()
  );
});

Service Worker 方案的三个连环坑

SW 思路对,但落地时踩了三个坑,一个比一个隐蔽。

坑一:<img> 的 no-cors 请求,header 被浏览器剥离

<img> 默认是 no-cors 模式。即使 SW 拦截后注入了 Authorization 头,浏览器在转发这个 no-cors 请求时,会按 no-cors 的安全策略剥离"非简单头",自定义的 Authorization 头可能就被扔掉了。

所以 SW 里必须强制 mode: "cors" 重新发起:

return fetch(request, { headers, mode: "cors" });

SW 内部的 fetch 不受原请求 no-cors 模式的约束,用 cors 模式重发,Authorization 头才能稳定带上。

坑二:ServiceWorker 没有 localStorage

SW 想注入 token,得先拿到 token。但 token 存在 localStorage 里——ServiceWorker 的运行环境没有 localStorage(它是独立的 worker 上下文,不共享 Window 的同步存储)。

解决:用 IndexedDB 做主线程 ↔ SW 的 token 中转。

主线程写:

async function setSwToken(token) {
  const db = await openDB();  // indexedDB.open(...)
  const tx = db.transaction("kv", "readwrite");
  tx.objectStore("kv").put(token, "access_token");
  await txDone(tx);
  db.close();
}

SW 读:

function getToken() {
  return new Promise((resolve) => {
    const req = indexedDB.open("sw-store", 1);
    req.onsuccess = () => {
      const tx = req.result.transaction("kv", "readonly");
      const getReq = tx.objectStore("kv").get("access_token");
      getReq.onsuccess = () => resolve(getReq.result || null);
    };
  });
}

主线程用 watch 监听登录态变化,同步写入:

watch(
  () => store.userinfo?.access_token,
  (token) => { if (token) setSwToken(token); },
  { immediate: true }
);

坑三(最坑):SW 注册代码根本没执行

这是最隐蔽的一个。SW 注册代码写在入口模块 main.ts 里:

// main.ts(最初写在这里)
navigator.serviceWorker.register(`${BASE_URL}sw.js`);

代码没错,sw.js 也能访问(HTTP 200 + 正确 MIME)。但浏览器里诊断:

await navigator.serviceWorker.getRegistrations();
// → 注册数: 0  !!

SW 根本没注册成功,且 Console 里连注册的日志都没有。

排查发现:main.ts 是 vite 的入口模块。vite dev 的 HMR 不会重新执行入口模块的初始化代码(只热替换组件)。所以即使 dev server 重启了,浏览器如果还用着 HMR 连接、没硬刷新,加载的 bundle 里可能不包含新代码,或这段初始化逻辑没被重跑。

解决:把 SW 注册从入口模块移到 index.html 的内联脚本。内联脚本不经过构建工具的模块系统,浏览器加载 HTML 就一定执行,不受 HMR 影响:

<!-- index.html -->
<script>
  if ("serviceWorker" in navigator) {
    window.addEventListener("load", function () {
      navigator.serviceWorker
        .register("/sw.js")
        .then(() => console.info("[sw] 注册成功"))
        .catch((err) => console.error("[sw] 注册失败", err));
    });
  }
</script>

移过去之后,硬刷新页面,Console 终于打印 [sw] 注册成功,缩略图的请求头里也有了 authorization: Bearer ...

还有一个时序坑:首次注册不拦截当前页请求

SW 有个特性:首次注册后,当前页面已经发出的请求不会被拦截——SW 只在 clients.claim() 接管后的「新请求」才生效。

所以正确验证步骤是:

  1. 重启 dev server + 硬刷新页面(注册 SW)。
  2. 再刷新一次(SW 已接管,拦截新请求)。
  3. 在 DevTools → Network 看请求的 Request Headers。
// sw.js —— 这两行让 SW 尽快接管
self.addEventListener("install", () => self.skipWaiting());
self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim()));

验证方式的一个大坑:别用 curl!

排查过程中,我从浏览器复制了请求的 curl,发现 curl 里没有 authorization,一度以为 SW 没生效。

后来才意识到:curl 是命令行工具,直接发请求,完全不经过浏览器、不经过 Service Worker。SW 只拦截浏览器页面里发起的请求(fetch/<img>/<a> 等),对 curl 无效。

所以验证 SW 是否注入头,必须在 DevTools → Network 面板看页面请求的 Request Headers,不能用 curl。这是排查 SW 问题时最容易自我误导的点。

最终方案:三层鉴权

最终落地的是三层组合,覆盖文件接口的所有请求方式:

请求类型 鉴权方式 适用场景
axios 接口请求 请求拦截器全局注入 header 普通接口调用
fetch / <a> 下载 带 Authorization 头拉 blob 预览组件、点击下载
<img>/<iframe>/CSS 背景 Service Worker 网络层注入 header 缩略图、头像、PDF 预览、背景图

总结:这个坑为什么"看起来无解"

<img src> 无法加自定义请求头,是浏览器的底层设计,JS 层面确实没有直接解法。但浏览器的限制只在「页面脚本」这一层——Service Worker 是浏览器给的一个「越权」口子,它工作在网络层,可以拦截和改写任何请求

所以遇到「浏览器原生请求无法加头」这类问题,记住一句话:

页面脚本加不了的头,Service Worker 可以。

落地时注意四个坑:

  1. no-cors 请求:SW 内用 mode: "cors" 重发,否则 header 被剥离。
  2. SW 无 localStorage:用 IndexedDB 做 token 中转。
  3. 构建工具 HMR 不重跑入口模块初始化:SW 注册放 index.html 内联脚本,别放模块入口。
  4. 首次注册不拦截当前页请求:注册后需再刷新一次,且 SW 要 skipWaiting + clients.claim

最后,验证用 DevTools Network,别用 curl——SW 不拦截命令行请求。


附:生产部署注意——SW 要求 HTTPS(localhost 除外);若要覆盖根路径 scope,后端需响应 Service-Worker-Allowed: /;sw.js 更新后浏览器可能用旧缓存,可加版本号或引导用户刷新。
image

posted on 2026-08-21 16:09  中文还在写码  阅读(20)  评论(0)    收藏  举报