给 `<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() 接管后的「新请求」才生效。
所以正确验证步骤是:
- 重启 dev server + 硬刷新页面(注册 SW)。
- 再刷新一次(SW 已接管,拦截新请求)。
- 在 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 可以。
落地时注意四个坑:
- no-cors 请求:SW 内用
mode: "cors"重发,否则 header 被剥离。 - SW 无 localStorage:用 IndexedDB 做 token 中转。
- 构建工具 HMR 不重跑入口模块初始化:SW 注册放
index.html内联脚本,别放模块入口。 - 首次注册不拦截当前页请求:注册后需再刷新一次,且 SW 要
skipWaiting+clients.claim。
最后,验证用 DevTools Network,别用 curl——SW 不拦截命令行请求。
附:生产部署注意——SW 要求 HTTPS(localhost 除外);若要覆盖根路径 scope,后端需响应 Service-Worker-Allowed: /;sw.js 更新后浏览器可能用旧缓存,可加版本号或引导用户刷新。

浙公网安备 33010602011771号