
做过 AI Agent Skill 开发的朋友应该都有过这样的瞬间一段 Python 逻辑在本地跑通了往 Skill 包里一塞感觉任务就结束了。但 Skill 真的不是普通脚本。我最早做的一个会议室预定 Skill 就是这种心态——Python 代码里直接调公司内部 API能用是能用直到有同事在对话里问了句“帮我把这周所有人的会议室使用统计拉出来”数据就被 AI “好心”地取了出来。那一刻我才意识到Skill 里的 Python 代码不是给自己跑的是给 LLM 当手用的天然暴露在不可信输入面前。Skill 中 Python 代码的鉴权处理从第一行代码就该开始。这篇我会结合自己的踩坑经历把 Skill 鉴权拆开讲透覆盖三层鉴权模型、三套能落地的 Python 实现、密钥管理的五种做法以及一场真实的内网部署事故复盘。正在做 Agent Skill / 插件、需要把内部能力开放给 AI 的开发者应该都能从中找到直接能抄的东西。1. 为什么一个Skill的Python代码要专门做鉴权三个真实翻车现场先说一个容易被忽略的前提Skill 不是传统意义上的接口。传统 API 的调用方是另一个程序输入输出都可以通过契约约束Skill 的调用方是 LLM而 LLM 拿到的指令来自自然语言可以被诱导、被注入。你写一个 Python 函数本意是让 AI 帮你查天气但别人可以通过精心构造的对话让 AI 去执行你根本没预想到的操作。这不是危言耸听下列三个场景我都真实遇到过。1.1 场景一公网Skill被当成免费代理把 Skill 封装成 HTTP 服务是很常见的做法尤其是团队里多个 Agent 要共用同一个能力时。问题在于很多人封装完 URL 就直接丢给 Agent 用没有在入口加任何身份校验。我见过一个项目团队做了个天气查询 Skill接的是第三方气象数据 API。AI 对话里调用一切正常但上线两天后第三方平台发来告警免费额度被耗尽正式用户请求全部返回 403。查了日志才发现有人绕过了 Agent 网关直接拿着 Skill 服务的地址用脚本高频调用这个 HTTP 端点。本质上Skill 的入口对 LLM 开放但不代表它应该对全世界开放。一个只打算给内部 Agent 用的能力如果没有鉴权就等于在公网上摆了一台任何人都能用的免费代理而且这台代理背后还挂着你的第三方 API 额度。1.2 场景二AI“借刀杀人”式的越权操作LLM 本身没有安全概念它只会尽力执行用户的指令。如果你的 Skill 内部包含了 update、delete、send 这类有副作用的操作并且入口只校验“请求是不是来自合法客户端”不校验“当前用户有没有权限做这个操作”就会非常危险。举一个我踩过的例子文件处理 Skill 里有一个delete_file函数本地测试时我用管理员账号验证通过就以为万事大吉。结果某次同事在对话里说“帮我把项目目录下的旧备份都删掉反正没人用了”AI 检索到这个 Skill 后真的执行了删除。问题出在哪Skill 只验证了“调用者持有合法 API Key”却完全没有区分这个调用来自谁、要执行的动作属于什么权限等级。更麻烦的是Prompt 注入会让 LLM 在不知不觉中被恶意指令控制只要 Skill 暴露了足够危险的动作它就会变成攻击者的“手”。所以在 Skill 层鉴权不只要验证“调用者是谁”还要验证“这次调用允许干什么”。1.3 场景三内网部署后Skill失去了网络边界保护很多团队做内部 Skill 时的第一反应是反正部署在公司内网不暴露公网应该没问题吧。我过去也这么想直到被现实教育了一次。内网有一个特点服务之间默认互不设防。你打包部署一个 Skill它要访问数据库、要调内部 API这些都是明文内网链路。问题在于内网里不是只有你一个服务还有各种历史遗留系统、测试环境、非安全配置的 Web 服务。一旦某个老旧后台被钓鱼或者被 SSRF 利用攻击者就拿到了内网的“入场券”这时候你的 Skill 如果没有自己的鉴权就变成了横向移动的跳板。我见过一个部署在 K8s 集群里的 Skill连服务间调用都是明文 HTTP其他 Pod 只要知道 Service 名就能直接请求。把鉴权寄托在“内网 IP 可信”上是最不靠谱的安全假设。这三个场景分别指向三类风险入口暴露、身份冒用、越权执行。后面的所有方案本质上都是围绕这三件事展开的。2. 先搞清楚谁在鉴谁Skill鉴权的三层边界Skill 鉴权最容易搞混的一点是把“用户登录”当成“服务鉴权”。在 Skill 的场景里至少存在三个独立的信任关系外部调用方到 Skill、Skill 到后端服务、LLM 意图到具体功能。三个关系是三种不同的鉴权需求不能混为一谈。2.1 第一层外部调用方对Skill的鉴权这一层解决的是“这个请求确实是授权方发出的”。Skill 的调用方可能是 Agent 框架、另一个 Skill、也可能是直接拿着 URL 的普通 HTTP 客户端。常见的做法是 API Key、Bearer Token 或者客户端证书。如果你把 Skill 封装成一个 FastAPI 服务最简单的做法是在依赖注入层统一校验 Header 里的凭据而不是在每个路由里各写一遍。这里有个实际经验不要把校验逻辑写在“中间件”里就算完最好通过 FastAPI 的依赖注入Depends绑定到具体路由因为中间件很难做细粒度的 scope 控制而依赖注入可以在每个路由上按需选择校验强度。这一层最容易犯的错误是校验了“有没有带 Key”却没校验“Key 有没有效”校验了“Key 有没有效”却把 Key 直接写在客户端代码里。两者都是白做。2.2 第二层Skill对后端服务的鉴权Skill 是中间人它一边接收用户指令一边要替用户去访问数据库、调第三方 API。这层鉴权的典型做法是服务账号service account、OAuth 2.0 client credentials 流程、或者短时 token。核心原则是Skill 拿到的凭据应该具有最小权限。举个例子如果你的 AI 功能只是“查询会议室列表”那给 Skill 配的数据库账号就应该只读如果之后要加“创建会议”的功能再单独提升权限而不是一上来就给一个 DBA 账号。还有一个很容易被忽略的点Skill 的凭据要和人类用户的凭据分开。我见过有人图省事直接用某个员工的个人 token 作为服务调用凭证。这样做的问题是后端系统会把这个 Skill 当成那个员工权限边界完全错乱出了安全问题也无法追溯到底是谁的操作。Skill 应该有自己独立的服务身份哪怕它服务的是多个真实用户。2.3 第三层LLM意图与功能Scope的授权这一层是 Skill 特有的也是最少人考虑的。前两层确认了“请求方是谁”“Skill 可以访问哪些后端资源”但还没有回答一个关键问题这次自然语言指令到底被 LLM 映射成了哪个动作这个动作允许当前调用者执行吗我说一个具体例子。假设你有一个文件处理 Skill内部暴露了 read、write、delete 三个函数。正常用户问“可以帮我看看这个文件吗”LLM 会调用 read但如果有人通过 Prompt 注入诱导 LLM 去调用 delete而 Skill 的 delete 函数只校验了“调用者是否持有合法 Key”没有校验“这个调用者是否有删除权限”那就会出大事。解决思路是每个 Skill 函数声明自己的 scope比如file:read、file:write、file:delete在鉴权装饰器里接收这个 scope 参数统一校验当前调用者的 token 里是否包含对应权限。这样一来即使 LLM 被诱导去调用 delete也会因为 token 里没有 file:delete 的作用域而被拒。我个人在做 Skill 时会把第三层当成默认要求凡是带副作用的操作一律检查 scope宁可多写一个装饰器也不要省这一行判断。把三层关系串起来就是用户通过 Agent 发起请求Agent 替用户拿到一个 JWTSkill 先验 JWT 确认用户身份第一层再用自己的服务账号去后端取数第二层最后检查用户角色和当前动作的 scope 是否匹配第三层。三层都过了这个请求才真正可以执行。3. 三套能直接抄的Python鉴权实现API Key、JWT、HMAC签名说完了理念下面是动手环节。我常用的三套方案按适用场景区分方案适用场景优点缺点API Key机器间固定身份、保护 Skill 自身的 HTTP 入口实现简单开销低Key 泄露后难以单独吊销无内置过期JWT Bearer用户级临时授权、需要记录操作者身份自带有效期可携带 scope 等声明需要统一签发与验签体系密钥管理要求高HMAC 签名Webhook 回调、内网服务间调用防篡改可防重放不依赖用户系统需要双方共享 secret密钥分发有成本下面分别给出可直接抄的 Python 实现。示例统一基于 FastAPI因为它做依赖注入最顺手。3.1 方案AAPI Key校验最轻量也最容易被忽略当 Skill 自身作为一个 HTTP 服务暴露调用方是内部固定的 Agent 网关时API Key 就够了。实现上要注意三点从环境变量读 Key、用恒定时间比较函数、空 Key 直接拒绝启动校验。import os import secrets from fastapi import FastAPI, HTTPException, Security from fastapi.security import APIKeyHeader app FastAPI() api_key_header APIKeyHeader(nameX-API-Key, auto_errorFalse) EXPECTED_API_KEY os.getenv(SKILL_API_KEY, ) def require_api_key(api_key: str Security(api_key_header)) - str: if not EXPECTED_API_KEY: raise HTTPException(status_code503, detail服务端未配置SKILL_API_KEY) if not api_key: raise HTTPException(status_code401, detail缺少API Key) if not secrets.compare_digest(api_key, EXPECTED_API_KEY): raise HTTPException(status_code401, detailAPI Key无效) return api_key app.get(/health) def health(api_key: str Security(require_api_key)): return {status: ok}这里最关键的一行是secrets.compare_digest。普通字符串比较在遇到不匹配时可能会提前返回攻击者可以通过耗时差异一点一点猜出 Keycompare_digest保证无论结果如何耗时都一致直接封掉时序攻击这条路。另一个容易被忽略的点环境变量没配置时服务要直接返回 503而不是“空 Key 放行所有请求”。这个坑真的很蠢但我确实见过线上服务因为环境变量没配好导致if api_key os.getenv(API_KEY)两边都是空字符串而全部放行的案例。还有个使用上的提醒不要把 API Key 写进 Agent 的 system prompt 里让 LLM“记住”请求时要带上它。模型很容易在后续对话中被诱导把这个 Header 值原样输出给用户相当于自己把钥匙交出去了。正确的做法是在 Agent 网关层注入 HeaderSkill 这一侧只负责校验。3.2 方案BJWT Bearer Token适合用户级临时授权当 Skill 需要区分用户身份、需要记录“谁通过哪个 Skill 做了什么”时JWT 是比 API Key 更合适的方案。Skill 不负责签发 token只负责验签。校验时至少要确认四件事签名可信、token 未过期、issuer 正确、audience 正确。import os import jwt from fastapi import FastAPI, HTTPException, Security from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer app FastAPI() bearer_scheme HTTPBearer(auto_errorFalse) JWT_SECRET os.getenv(SKILL_JWT_SECRET, ) EXPECTED_ISSUER skill-auth EXPECTED_AUDIENCE skill-backend def require_jwt( credentials: HTTPAuthorizationCredentials Security(bearer_scheme), ) - dict: if credentials is None: raise HTTPException(status_code401, detail缺少Bearer Token) if not JWT_SECRET: raise HTTPException(status_code503, detail服务端未配置SKILL_JWT_SECRET) try: payload jwt.decode( credentials.credentials, JWT_SECRET, algorithms[HS256], issuerEXPECTED_ISSUER, audienceEXPECTED_AUDIENCE, options{require: [exp, iat, iss, aud]}, ) except jwt.ExpiredSignatureError: raise HTTPException(status_code401, detailToken已过期) except jwt.InvalidTokenError as exc: raise HTTPException(status_code401, detailfToken无效: {exc}) return payload用 PyJWT 校验时options{require: [...]}是必须的。很多人以为 decode 默认就会校验 exp实际并不是——如果不显式要求缺失 exp 的 token 也能通过验签。另外JWT_SECRET 不要用太短的弱密钥HS256 对对称密钥长度有要求实践中至少 32 字节生产环境建议用 RS256Skill 侧只放公钥私钥留在认证中心这样即使 Skill 被攻破也不至于泄露签发密钥。我在实战中还会检查 payload 里的scope字段。比如scope: meeting:read meeting:write然后在需要写操作的函数里多一道判断确保 token 里确实有这个权限而不是只要签名合法就放行。配合前面说的第三层授权模型JWT 的 payload 其实天然就是 scope 的载体比自己在代码里写死角色判断要清晰得多。3.3 方案CHMAC请求签名防篡改与防重放如果 Skill 要接收 Webhook 回调或者要在内网服务之间做接口调用API Key 和 JWT 都不太合适。Webhook 场景里你不知道回调方的用户体系内网服务间调用又往往没有统一认证中心。这时候 HMAC 签名是最实用的双方共享一个 secret发送方用它对 timestamp body 做 HMAC-SHA256 签名接收方重新计算并比对。import hashlib import hmac import time MAX_SKEW_SECONDS 300 def sign(secret: str, timestamp: str, body: bytes) - str: message f{timestamp}..encode() body return hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() def verify_signature(secret: str, timestamp: str, body: bytes, received_sig: str) - bool: try: ts int(timestamp) except ValueError: return False if abs(time.time() - ts) MAX_SKEW_SECONDS: return False expected sign(secret, timestamp, body) return hmac.compare_digest(expected, received_sig)这里有三个实战细节。第一为什么要带 timestamp没有 timestamp签名可以被无限重放攻击者只要录下一次合法请求就能反复提交。带上 timestamp 并限制前后 5 分钟窗口就能挡住大部分重放攻击。第二更严格的做法是再加 nonce每次请求分配一个唯一 ID被使用过的 nonce 记录下来防止在时间窗口内的重放。第三计算签名时一定要用原始请求体字节不要在中间过程把 body 做 JSON 序列化、URL 解码或者其他变换因为发送方和接收方只要有一点点处理差异签名就对不上这种 bug 排查起来非常痛苦。我遇到过因为网关给 body 自动补了空格导致签名全部失败的案例后面在 5.4 小节详细说。4. 密钥管理才是Skill鉴权的命门五种不硬编码的落地方式鉴权算法写得再漂亮密钥一旦泄露一切都等于零。Skill 开发里最常见的翻车点不是写不出鉴权代码而是把 Secret 放在了一个所有人不经意间都能看到的地方。我归纳一下密钥泄露的典型路径有这么几条硬编码在 .py 文件里然后 Skill 打包发布放在 .env 文件里结果 .env 被提交到了 Git 仓库写在 Agent 的 system prompt 里让模型“记住”打印在日志里被异常堆栈带出来。以下五种做法是我现在做 Skill 时的强制要求。4.1 环境变量只是及格线不是终点从环境变量读密钥os.getenv(SKILL_API_KEY)这只是第一步它解决了“代码里没有明文”的问题但没有解决“环境变量本身也可能被看到”的问题。进程的环境变量在 Linux 下可以通过/proc/pid/environ查看容器编排工具的日志里也可能把环境变量打出来K8s 里如果直接在 Deployment 的env字段里写 value它就会明文存在 etcd 里。所以我现在的做法是本地开发时用 .env 文件但 .env 不入库容器部署时优先用 Secret 挂载成文件而不是环境变量。环境变量适合放非敏感配置比如日志级别、开关项真正的密钥走 Secret 机制。4.2 部署到内网服务器时用平台Secret机制具体到 Skill 部署如果你用的是 K8s不要把密钥写进 ConfigMap更不要直接写进 Deployment 的 yaml。正确做法是用 K8s Secret然后以文件形式挂载到 PodapiVersion: v1 kind: Secret metadata: name: skill-secret type: Opaque stringData: SKILL_API_KEY: your-actual-key --- apiVersion: apps/v1 kind: Deployment spec: template: spec: containers: - name: skill image: skill:latest volumeMounts: - name: secret-volume mountPath: /etc/skill-secret readOnly: true volumes: - name: secret-volume secret: secretName: skill-secret代码里读取挂载文件而不是环境变量import os def _read_secret(key: str) - str: try: with open(f/etc/skill-secret/{key}, r, encodingutf-8) as f: return f.read().strip() except FileNotFoundError: return os.getenv(key, )这里用.strip()去掉换行符是有原因的后面第 5 节的事故就是被一个换行符坑惨了。另外K8s Secret 本身只是 base64 编码不是加密真正的安全还要靠 etcd 加密和 RBAC 权限控制但至少比把密钥直接写进编排文件强得多。如果你的部署环境有 Vault、云厂商的 Secret Manager优先用那些能顺便解决轮换问题。4.3 本地开发配置也要管好.env的权限与习惯很多人以为只有生产环境才需要管理密钥本地开发随便。我见过团队成员的 .env 文件直接chmod 644任何登录这台机器的人都能读。本地开发的 .env 也应该设成chmod 600并且明确写进.gitignore。还有一个习惯问题新同事入职后需要密钥不要从聊天记录里复制。聊天记录会长期留存还可能同步到手机、云盘等于你的 Secret 被复制到了 N 个地方。正确做法是让新同事通过公司的密钥管理平台或运维申请流程拿配置虽然麻烦一点但至少密钥的流转路径是可控的。4.4 凭据轮换与过期处理别让Skill半路瘫痪Skill 是常驻进程它不像人一样会在 token 过期时主动去刷新。这个坑特别隐蔽第三方 API 的 token 有效期为 24 小时你的 Skill 部署当天跑得好好的第二天某个功能突然报 401用户还以为是服务挂了。处理方式分两类。如果用的是短期 tokenSkill 内部要有后台刷新逻辑比如用apscheduler定时任务在 token 还剩 5 分钟时主动去认证中心换新 token而不是等请求失败后再处理。如果用的是长期密钥也要设计轮换机制先让新密钥生效再逐步废弃旧密钥避免“一刀切”导致所有正在运行的 Skill 实例同时失去访问权限。此外Skill 在收到第三方 API 的 401 时至少要能区分“当前凭据失效”和“请求本身非法”前者触发自动刷新重试后者直接向用户报错。我在代码里会为这种情况单独定义一个CredentialsExpiredError而不是把裸 401 原样抛给上层。4.5 日志和异常信息里的“隐形泄露”日志泄露是我见过最多、也最容易被忽视的密钥泄露途径。有人习惯性地在请求入口打印 headersAuthorization 字段就这么进了日志系统有人把整个 config 对象打出来里面躺着各种 secret还有人会在异常处理里直接logger.exception(exc)而某些第三方库的异常信息里会包含请求 URL 和参数。我的做法是写一个统一的脱敏工具函数在打日志前过滤敏感字段import re SENSITIVE_KEYS {api_key, authorization, token, secret, password, access_token} def mask_sensitive(text: str) - str: if not text: return text pattern r(|\b)({})(\s*[:]\s*|\s*:\s*)([^]{4,})().format(|.join(SENSITIVE_KEYS)) def replace(m): return f{m.group(1)}{m.group(2)}{m.group(3)}****{m.group(5)} return re.sub(pattern, replace, text) logger.info(mask_sensitive(str(headers)))这只是一个基础版本生产环境还要考虑嵌套 JSON 结构。更重要的是建立一条铁律请求头、配置对象、第三方 API 的原始响应体默认不进日志除非确认里面没有任何敏感字段。另外建议在 CI 里加一道密钥扫描用 gitleaks 这类工具检查代码仓库是否包含疑似密钥作为 pre-commit 或 CI 阶段的一步让问题在进入仓库前就被拦住。5. 一场内网部署事故的完整排查链路从401风暴到根因前面讲的都是方法论最后分享一次真实事故复盘。那次事故让我对 Skill 鉴权的稳定性有了完全不同的认识排查链路本身也很有参考价值因为最后根因和鉴权算法无关全是部署细节。5.1 事故现象与第一判断区分401来源当时的情况是Skill 服务部署在内网 K8s 集群使用 JWT 校验给某个新项目做内部能力开放。第二天早上监控告警提示 Skill 服务 5 分钟内返回了 400 多次 401。第一反应是怀疑有人在恶意扫描。但登录服务日志一看401 的响应体是我们自己代码里写的“Token无效”说明请求确实到达了 Skill而且被 JWT 校验逻辑拦截了。这一步很关键先确认 401 是 Skill 自己返回的还是上游服务返回的。如果 401 来自上游数据库或第三方 API说明问题出在第二层鉴权Skill 到后端如果来自 Skill 自身问题大概率出在第一层调用方到 Skill的校验逻辑。5.2 第二层检查密钥值居然多了一个换行符在本地我用同样的 token 调同样的代码一切正常在容器里却 100% 拒绝。嫌疑立刻集中到环境配置上。检查 Deployment 后发现SKILL_JWT_SECRET 来自一个 ConfigMap而 ConfigMap 里的值看起来和本地一样但用echo $SKILL_JWT_SECRET | xxd一看末尾多了一个0x0a换行符。就是这多出来的一个换行符导致 HS256 的签名密钥完全变了所有 token 验签失败。修复方式很简单统一改用 Secret 挂载文件并在读取时.strip()。但这件事暴露了一个更深的问题ConfigMap 里的 secret 是明文的而且手误很难避免。从那次之后我把“所有密钥必须走 Secret 文件挂载且读取时去除首尾空白字符”写进了团队规范。这里提醒做 Skill 部署的朋友本地.env文件解析通常会自动去掉换行但容器环境变量不会Key 从文件读进来往往会带一个\n这是一个非常隐蔽的坑。5.3 第三层检查节点时钟漂移导致的偶发401换掉 Secret 后大规模 401 消失了但还剩下零星的偶发 401集中在凌晨某个时间段。这更奇怪token 明明没过期为什么时不时就失败继续看日志发现报错集中在 K8s 集群里某个特定节点上的 Pod。再查下去是节点系统时钟漂移了比真实时间快了大约 2 分钟。JWT 校验依赖当前时间来计算 exp 和 iat节点时钟不准token 就会被判定为“尚未生效”或“已经过期”。内网集群如果没有配置 NTP 同步时间漂移是迟早的事。修复手段有两个层面运维层面给所有节点配置 chrony/NTP 时间同步这是根治代码层面给 jwt.decode 加上leeway30允许 30 秒的时钟偏差容差。我个人建议两者都做尤其是部署在非标准环境离线内网、虚拟机模板、被暂停过很长时间的节点时时钟漂移几乎是必然事件。5.4 同类坑清单与部署自查表这次事故之后我复盘了一批同类问题列成了一份清单每次部署 Skill 前都会过一遍检查项具体检查内容对应风险密钥来源是否通过 Secret 文件挂载而非环境变量或 ConfigMap密钥泄露、换行符污染空白字符读取密钥后是否 strip签名不一致、401时钟同步所有节点是否有 NTP代码是否有 leeway偶发 401日志脱敏是否默认屏蔽 Authorization、token 等字段密钥泄露scope 校验带副作用的函数是否强制校验权限越权操作凭据轮换token 是否有自动刷新机制服务中断内网互信是否仍假设“内网 IP 可信”横向移动跳板还有一个容易踩的坑是 URL 编码。HMAC 签名时计算的是原始请求体但有些网关会在转发时对 body 做规范化处理导致接收方拿到的字节和发送方签名的字节不一致签名永远对不上。排查时可以打印收到的原始 body 的 hex 值和发送方对比几分钟就能定位。另一个是 HTTP Header 名称大小写的问题。Header 名本身大小写不敏感但有些自定义网关会擅自改写 Header 名比如把X-Signature改成x-signature如果你的框架配置了大小写敏感映射可能就认不出来了。这类问题通常在跨网关调用时出现测的时候多留个心眼。踩过这次坑之后我把“鉴权”从 Skill 功能开发的最后一项挪到了第一项。Skill 这种形态很特殊它的一头是号称能做任何事的 LLM另一头是你真正的业务系统不做隔离等于把入口门锁全拆了。上面的方案未必花哨但都是我实际跑过、能直接抄的。如果你也在做 Skill至少先做到这三条密钥不进代码、函数按 scope 授权、日志不记录敏感字段。能做到这三条你的 Skill 就已经比市面上大多数 Skill 都安全了。