昨天群里又有人甩了一张报错截图过来Apps Script 里调 Gemini API返回InvalidSignature。我第一反应就是问一句你的 API Key 是不是塞在Authorization头里当 Bearer Token 用了果不其然全中。这个问题几乎每个月都能遇到报错信息又特别有迷惑性光看字段名会让人以为是签名算法、密钥过期这类复杂问题实际上九成是请求的组装姿势不对。这篇文章就把这个坑彻底讲透先从 Gemini API 的鉴权原理说起再给一套可以直接抄的 Apps Script 标准调用代码最后按我的排障习惯把高频诱因逐个过一遍。不管你是在搞 AI 聊天机器人、自动化内容生成还是想把 Gemini 接进 Sheets 里做批处理这篇都能帮你省下半天查文档的时间。1. 先搞清楚 InvalidSignature 到底是谁在报错1.1 这个报错最容易出现在两类场景里我在项目里见到的 InvalidSignature绝大多数逃不出下面两类情况。第一类是用 API Key 调 Gemini API。API Key 本质是一串 Google 签发时做了内部签名的凭证字符串。当这个字符串到达服务端网关时网关要拆开它的内部结构、校验其签名合法性。如果 key 在传输过程中被截断、混入了隐藏字符、或者被 URL 编码破坏了原始字节网关就无法通过内部签名校验于是抛出一个和签名相关的错误。注意这里的签名不是说你每次请求都要签名而是 Google 在签发 key 的时候就已经签好名了你只是负责原样把它递过去。第二类是用 OAuth2 / 服务账号。这时候签名就是真正意义上的 JWT 签名。你拿私钥去签一个 JSON Web Token网关拿公钥验签。如果私钥不对、密钥格式解析错了、scope 配错了、token 过期时间算错了网关验签失败也会报 InvalidSignature。在 Apps Script 这个特定环境里我实际遇到最多的是第一类中的变种——不是 key 本身坏了而是你把 key 放错了位置导致网关用错误的验签流程去处理它。1.2 Gemini API 的鉴权方式其实只有两条路Gemini API对应的云服务叫 Generative Language API支持的鉴权方式官方文档里写得很清楚总结起来就两条路API Key最简单。把 key 放在请求 URL 的key查询参数里或者部分客户端可以放在X-Goog-Api-Key头里。官方 curl 示例用的是?key形式。OAuth2走标准授权流程需要拿 Client ID / Client Secret 换 access token服务账号还要额外生成 JWT。Apps Script 里最合理的默认选择是 API Key。原因很简单脚本运行在 Google 服务器上你不需要给最终用户做交互式授权只要把 key 存在脚本属性里请求时取出来拼到 URL 上就行了。很多人的认知误区来自于 OpenAI 的调用习惯API Key 嘛不就是Authorization: Bearer sk-xxx嘛。 在 Gemini 这儿如果你真的这么干网关会把你那串AIza...当成 OAuth token 去验签出来的错误往往就是这个 InvalidSignature 或者类似的鉴权失败文案。这就好比你有小区门禁卡却拿它去刷公司打卡机机器读不懂卡的类型只能提示卡无效。1.3 签名到底签的是什么继续说这个签名问题。Google 创建 API Key 时key 不是简单的一串随机字符而是包含元信息项目 ID、key 版本、创建时间等再用服务端私钥做签名的结构化令牌整体经过了 base64 之类的编码和混淆处理最终以AIzaSy...开头呈现给你。服务端收到请求后网关要做的事情是把字符串解析回结构化数据再用内置公钥校验签名。只要这个字符串在任何一个环节被破坏——复制时带了不可见换行符、拼 URL 时特殊字符没编码、被日志系统截断、或者被某个库做了大小写转换——校验就会失败报回来的就是签名类错误。用生活化的比喻API Key 就像一张银行发的防伪卡卡里芯片存了签名数据。你把卡放进读卡器?key 参数正常刷没问题但如果你把卡剪了一角key 被截断、贴了层膜URL 编码后多出%0A之类字符读卡器读不出原始签名自然放行不了。2. 正确姿势Apps Script 调用 Gemini API 的标准流程2.1 前置准备三件事在写代码之前先把这三件准备工作做扎实能避免后面一半的报错。首先确保你的 Google Cloud 项目里已经启用了 Generative Language API。如果你是在 AI Studioaistudio.google.com里创建的 API Key通常项目已经默认开好了服务但如果你用的是自己的 Cloud 项目需要去 API 库搜 Generative Language API 并手动启用。很多人栽在 403 上就是这一层没做。其次去 AI Studio 的 API Key 页面申请一个 key或者去 Cloud Console 的凭据页面创建。创建之后复制下来注意它很长建议复制到临时文本里再检查一遍有没有多余空格。最后把 key 存进 Apps Script 的脚本属性里而不是硬编码在代码中。这样做的好处是脚本属性只能通过 Apps Script 代码访问比明文写在脚本文件里安全换 key 的时候不用改代码直接在属性面板里更新就行。操作路径是脚本编辑器 项目设置 脚本属性 添加属性属性名建议用GEMINI_API_KEY。2.2 最小可运行代码下面这段是最小可运行版本能通就能证明你的环境链路完全 OKfunction testGemini() { const apiKey PropertiesService.getScriptProperties().getProperty(GEMINI_API_KEY); if (!apiKey) throw new Error(请先在脚本属性里配置 GEMINI_API_KEY); const model gemini-2.0-flash; const url https://generativelanguage.googleapis.com/v1beta/models/ model :generateContent ?key encodeURIComponent(apiKey); const payload { contents: [ { parts: [{ text: 用一句话介绍你自己 }] } ] }; const options { method: post, contentType: application/json, payload: JSON.stringify(payload), muteHttpExceptions: true }; const response UrlFetchApp.fetch(url, options); console.log(HTTP, response.getResponseCode()); console.log(response.getContentText()); }这段代码有几个关键点我逐个说。encodeURIComponent(apiKey)是很多人会漏掉的一步。API Key 里可能包含 URL 特殊字符虽然大部分情况下AIza...前缀的 key 以字母数字为主但保险起见任何把 key 拼进 URL 的代码都应该做一次编码。这一步能直接消灭一类 InvalidSignature。muteHttpExceptions: true也很重要。默认情况下UrlFetchApp 只要收到 4xx/5xx 状态码就直接抛异常异常信息是通用的 Request failed你看不到服务端返回的具体 body排障全靠猜。打开这个开关后即使报错也会正常返回响应对象你可以读取状态码和响应正文看到真实错误内容。模型名gemini-2.0-flash是当前可用的轻量模型速度快、免费额度够测试。如果后续模型列表有变化可以用提到的 listModels 接口查询最新可用的模型名。2.3 返回结构长什么样调用成功时响应体长这样{ candidates: [ { content: { parts: [ { text: 我是 Gemini一个多模态 AI 模型…… } ], role: model }, finishReason: STOP } ], usageMetadata: { promptTokenCount: 8, candidatesTokenCount: 30, totalTokenCount: 38 } }你要取的结果在candidates[0].content.parts[0].text。注意不要直接读candidates[0].content.parts的 JSON 字符串要用.text字段。调用失败时响应体是标准的 Google API 错误结构{ error: { code: 400, message: InvalidSignature, status: INVALID_ARGUMENT } }排障时一定要把code、message、status三个字段都看全。message是给人看的描述status是机器可读的标准状态两者结合才能精确定位问题。很多情况下message里写的是 InvalidSignature但status可能是INVALID_ARGUMENT或者PERMISSION_DENIED处理方式完全不同。3. InvalidSignature 高频诱因逐一排查3.1 诱因一把 API Key 塞进了 Authorization 头这是我在 Apps Script 场景里遇到最多的原因没有之一。典型代码如下// 错误示范 const options { method: post, headers: { Authorization: Bearer apiKey, Content-Type: application/json }, payload: JSON.stringify(payload), muteHttpExceptions: true };看起来人畜无害但 Gemini API 的网关不会把你当 API Key 处理。它会尝试把这串Bearer AIza...当成 OAuth 2.0 access token 去验签。AIza...不是合法的 OAuth token验签自然失败于是返回 InvalidSignature 或者 Request had invalid authentication credentials 之类的错误。为什么会有人这么写因为 OpenAI、Firebase、很多第三方服务的 SDK 都是这么用的形成惯性了。而且 Gemini API 官方文档的 curl 示例明确用了?key参数App Scripts 的 UrlFetchApp 需要在 URL 字符串里拼参数两者写法不一致习惯就成了坑。正确的做法是去掉 Authorization 头把 key 放进 URL// 正确API Key 放 URL 查询参数 const url https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key encodeURIComponent(apiKey);3.2 诱因二API Key 拼 URL 时没有编码第二种情况是 key 本身没问题但拼 URL 时没做编码处理导致 key 在传输过程中被破坏。API Key 的字符集大多数情况下是字母、数字、下划线、连字符看起来人畜无害。但如果你在云控制台里创建 key 时选择了自定义格式或者 key 恰好包含、/、base64 常见的填充字符这些字符在 URL 里有特殊含义。会被解析成空格/会被当成路径分隔符会影响参数解析。服务端拿到的 key 已经不是原始字符串了签名校验不通过报 InvalidSignature。另外从 AI Studio 页面复制 key 时肉眼看不见的换行符或空格可能被一起复制进来。放在PropertiesService里时同样可能带着隐藏字符。这类问题最隐蔽因为你单独看复制出来的 key 怎么都对。解决方案就两步一是拼 URL 时必须用encodeURIComponent(apiKey)二是存入脚本属性前可以做一次清理const cleanedKey keyFromClipboard.trim().replace(/\s/g, );3.3 诱因三HTTP 方法用错Gemini API 的generateContent接口只接受 POST 请求请求体里必须传contents。如果你习惯性地用 GET 去调或者用method: GET去拉数据服务端会直接拒绝。这个错误有时候返回 405 Method Not Allowed有时候则会被包装成 400 加一段令人迷惑的 message。它虽然不是严格意义上的签名错误但出现在日志里时同样是请求参数有问题这一类。我在给客户做代码评审时不止一次看到他们把 generateContent 写成了 GET把 prompt 放在 query string 里结果返回的报错信息指向鉴权而非方法。所以排查 InvalidSignature 时第一步就该确认两件事方法是不是post请求体是不是 JSON 字符串。这两点没问题再往下看 key 的传递。3.4 诱因四OAuth 场景下的 JWT 签名问题如果你的项目用的是服务账号 OAuth2而不是简单 API KeyInvalidSignature 就真的是 JWT 验签失败了。常见原因包括从 JSON 私钥文件里提取private_key时格式被破坏比如换行符变成了\n字面量而不是真实换行、JWT 的scope填写错误、exp过期时间计算偏差导致网关认为 token 已失效、或者你误把 P12 证书里的私钥当成了 PEM 私钥直接用。这种场景下我建议直接用 Apps Script 社区里成熟的 OAuth2 库库 ID1B7FSrk5Zi6L1rSxxTDgDEUs2zlWpKjG4t2l7iU0s9p1cZ0v9a0kA等避免手写 JWT 生成逻辑。手写 JWT 的坑实在太多一个 base64 编码处理不当就够你折腾半天。如果你确实是走 API Key 简单路线那 OAuth 部分可以直接跳过不需要管 JWT 的事。3.5 诱因五报错文案会骗人根因可能在别处最后要提醒一个排查心态问题不要完全相信错误信息里的message字段。API 网关在转发请求时某些底层错误会被统一包装InvalidSignature这个文案不一定精确对应签名问题。我遇到过的情况有API 未启用实际返回 403 PERMISSION_DENIED、模型名拼错实际返回 404 NOT_FOUND、免费额度用完实际返回 429 RESOURCE_EXHAUSTED。这些错误在特定条件下都会被网关包装成让人误判的文本。所以拿到报错后先看状态码和status字段再对照message做判断不要看到 InvalidSignature 就一头扎进签名相关的排查。4. 分步排障从报错到定位只要十分钟4.1 第一步先验证 Key 本身是否有效发生报错后第一步永远是脱离 Apps Script 验证 key。不要急着改代码先用命令行或任意 API 调试工具Postman、Apifox 都行测一次curl https://generativelanguage.googleapis.com/v1beta/models?keyYOUR_API_KEY这个请求不带任何 payload只是列出模型列表。如果返回 200说明 key 有效、API 已启用、网络链路没问题问题大概率出在 Apps Script 侧的请求组装上。如果返回 4xx说明 key 本身或项目配置有问题这时候再去检查 API 启用状态和 key 的受限设置比如是否限制了 IP、Android 应用包名等。这一步能把排查范围直接砍半。我自己的习惯是每次新项目接入 Gemini先跑这条 curl把这个基本盘稳住再写业务代码。4.2 第二步让 Apps Script 把请求细节吐出来如果 curl 通过了说明问题在脚本内部。这时打开 Apps Script 的执行日志用一段专门的调试代码把请求和响应完整打出来function debugGeminiCall() { const apiKey PropertiesService.getScriptProperties().getProperty(GEMINI_API_KEY); if (!apiKey) { console.log(未读取到 API Key请检查脚本属性); return; } // 记录 key 的基本信息避免日志泄露完整 key console.log(Key 长度:, apiKey.length); console.log(Key 前 10 位:, apiKey.slice(0, 10) ...); const model gemini-2.0-flash; const base https://generativelanguage.googleapis.com/v1beta/models/ model :generateContent; const url base ?key encodeURIComponent(apiKey); console.log(请求 URL不含完整 key:, base ?key apiKey.slice(0, 4) ...); const payload { contents: [{ parts: [{ text: hi }] }] }; const options { method: post, contentType: application/json, payload: JSON.stringify(payload), muteHttpExceptions: true }; try { const response UrlFetchApp.fetch(url, options); const code response.getResponseCode(); const body response.getContentText(); console.log(HTTP 状态码:, code); console.log(响应体:, body); if (code 200) { const data JSON.parse(body); console.log(模型回复:, data.candidates[0].content.parts[0].text); } } catch (e) { console.log(请求抛出异常:, e.message); } }这段代码里有几个细节值得注意。首先Key 的长度和前缀打印出来能快速判断 key 是否完整读取。其次日志里不要打印完整 key避免脚本日志泄露敏感信息。再次muteHttpExceptions: true确保 4xx/5xx 也能拿到响应体。4.3 第三步对照排查表定位拿到日志后按下面的表逐项比对日志特征最可能原因处理方式401 message 含 InvalidSignature 请求里带了 Authorization 头Key 被当成 Bearer Token删除 Authorization 头改用?key400 key 长度异常或 URL 里有%转义异常URL 编码问题统一用encodeURIComponent(apiKey)403 PERMISSION_DENIEDAPI 未启用或 Key 受限检查项目 API 状态、Key 的 IP/服务限制404 NOT_FOUND模型名拼错或该区域不可用换成有效模型名先用 listModels 接口确认429 RESOURCE_EXHAUSTED配额耗尽等配额恢复或升级套餐代码里加重试403 服务账号场景JWT 签名或 scope 错误检查私钥格式、scope、exp 计算4.4 排障期间的注意事项排障过程中有几点我踩过坑专门提醒一下。第一绝不要把完整 API Key 粘贴到日志、Git、或者发给别人的代码片段里。Key 就是密码泄露一次等于作废一次。使用key.slice(0, 8) ...这种方式打日志就够了。第二修改代码后记得重新部署。Apps Script 里有开发部署和正式部署两套运行环境如果你改了代码但跑的是旧部署调试半天改了个寂寞。我习惯在编辑器顶部的部署下拉菜单里确认当前执行的版本。第三先测最小请求再上复杂功能。很多人一上来就传几十条历史对话、配上各种generationConfig参数一旦报错根本分不清是鉴权问题还是参数问题。先用{ contents: [{ parts: [{ text: hi }] }] }这种最小 payload 打通链路再逐步加需求。5. 可以直接抄作业的完整封装5.1 带错误处理和生产级兜底的调用函数排障通过之后可以把调试代码升级成一个适合实际使用的封装。下面这个函数处理了 key 读取、URL 编码、错误解析、基础重试几个关键点function callGemini(prompt, options {}) { const apiKey PropertiesService.getScriptProperties().getProperty(GEMINI_API_KEY); if (!apiKey) { throw new Error(未配置 GEMINI_API_KEY 脚本属性); } const model options.model || gemini-2.0-flash; const temperature options.temperature ?? 0.7; const maxOutputTokens options.maxOutputTokens || 1024; const maxRetries options.maxRetries || 2; const url https://generativelanguage.googleapis.com/v1beta/models/ model :generateContent ?key encodeURIComponent(apiKey); const payload { contents: [{ parts: [{ text: prompt }] }] }; if (options.systemInstruction) { payload.systemInstruction { parts: [{ text: options.systemInstruction }] }; } payload.generationConfig { temperature: temperature, maxOutputTokens: maxOutputTokens }; const httpOptions { method: post, contentType: application/json, payload: JSON.stringify(payload), muteHttpExceptions: true }; let lastCode 0; let lastBody ; for (let attempt 0; attempt maxRetries; attempt) { const response UrlFetchApp.fetch(url, httpOptions); lastCode response.getResponseCode(); lastBody response.getContentText(); if (lastCode 200) { const data JSON.parse(lastBody); const text data.candidates?.[0]?.content?.parts?.[0]?.text; if (text) { return { ok: true, text: text, usage: data.usageMetadata }; } throw new Error(响应中没有有效文本内容: lastBody); } // 遇到可重试的临时错误等待后重试 if (lastCode 429 || lastCode 500 || lastCode 503) { if (attempt maxRetries) { const waitMs 1000 * Math.pow(2, attempt); Utilities.sleep(waitMs); continue; } } break; } let reason 未知错误; try { const errObj JSON.parse(lastBody); reason errObj.error?.status : errObj.error?.message; } catch (e) { reason lastBody; } return { ok: false, code: lastCode, error: reason, raw: lastBody }; }这个封装里有两个设计值得说明。第一muteHttpExceptions配合手动解析错误让函数返回结构化结果而不是直接抛异常业务代码可以根据ok字段决定后续流程。这在批量处理场景比如遍历文档生成摘要里尤其有用不会因为一条失败就中断整个批次。第二对 429/500/503 做指数退避重试。429 是配额或限流500/503 是服务端临时故障这三种情况稍等片刻再试往往就能成功。Utilities.sleep()是 Apps Script 内置函数单位是毫秒。重试次数默认 2 次最多等 1 秒 2 秒不会拖垮整体执行时间。5.2 一个简单的文本生成入口上面的函数是核心下面再给一个给业务代码用的薄封装function generateText(prompt) { const result callGemini(prompt, { model: gemini-2.0-flash, temperature: 0.3, maxOutputTokens: 2048 }); if (result.ok) { console.log(生成完成tokens:, result.usage.totalTokenCount); return result.text; } console.error(生成失败:, result.code, result.error); return null; }使用方式很简单function example() { const summary generateText(请用三句话总结下面这段话……); Logger.log(summary); }温度设 0.3 是为了让摘要类任务输出更稳定。如果是创意写作类需求可以放宽到 0.7 或更高。maxOutputTokens2048对大多数文本生成够用但如果要生成长文记得调大否则输出会被截断finishReason 会显示MAX_TOKENS。5.3 顺手做一个模型列表查询工具最后一个小工具用来确认当前项目可用哪些模型、模型名有没有写错function listAvailableModels() { const apiKey PropertiesService.getScriptProperties().getProperty(GEMINI_API_KEY); const url https://generativelanguage.googleapis.com/v1beta/models?key encodeURIComponent(apiKey); const options { method: get, muteHttpExceptions: true }; const response UrlFetchApp.fetch(url, options); if (response.getResponseCode() ! 200) { console.log(获取模型列表失败:, response.getContentText()); return; } const data JSON.parse(response.getContentText()); const models data.models .filter(m m.supportedGenerationMethods m.supportedGenerationMethods.includes(generateContent)) .map(m m.name); console.log(可用模型:, models.join(\n)); }这个工具不仅能确认模型名还能顺便验证 key 在整个项目链路上的可用性算是排障代码里的一器多用。6. 常见问题速查与经验小结6.1 常见问题速查表把平时被问得最多的问题整理成一张速查表供大家直接查阅问题现象解决方案400 InvalidSignature请求被网关拒绝检查 key 是否放在?key参数中是否用encodeURIComponent编码401 InvalidSignature鉴权失败确认没把 key 放 Authorization 头确认 key 没被脚本属性意外改动403 PERMISSION_DENIED无权限确认 Generative Language API 已启用检查 Cloud 项目的配额404 NOT_FOUND模型名不存在用 listModels 确认可用模型名429 RESOURCE_EXHAUSTED配额超限等待或降频代码里加 429 重试逻辑500/503服务端临时故障指数退避重试6.2 我的几点实操心得踩过这么多次坑之后几个经验成了我接任何 Google API 的固定动作。第一任何报错先 curl 一把。不管日志里说什么先在命令行用最小的 curl 请求验证 key 和 API 本身这一步花不到一分钟但能把问题的归属范围立刻分成两半。第二Apps Script 里拼 URL 永远带上encodeURIComponent。不要因为自己的 key 里没有特殊字符就省略一旦 key 轮换或者项目迁移新 key 什么字符都可能出现防御性编码成本几乎为零。第三muteHttpExceptions是排障标配。不开这个开关你只能看到 Request failed 三个词开了之后才能看到服务端真正想告诉你的信息。这条对所有UrlFetchApp调用都适用不止 Gemini API。第四API Key 存脚本属性不存代码。代码会在版本历史、部署记录、分享协作者之间流转而脚本属性只有你自己和你授权的脚本能访问。key 泄露后只能作废重建这个代价比多写一行PropertiesService大得多。这次遇到的 InvalidSignature最后定位到的原因就是 Authorization 头误用。如果你也卡在这个报错上先按第 4 节的调试代码跑一遍把日志贴出来对照速查表大概率十分钟内就能找到根因。实际接入 Gemini API 之后把它接到 Sheets 里做批处理、接到 Gmail 里自动回信、接到 Docs 里做内容生成都是顺手的事。链路通了后面扩展就快了。