返回探索
指南VibeFix 编辑部更新于 2026年10月12日

别把“万能钥匙”发给用户:客户 API Key 签发与权限实战

SaaS 开放 API 就要给用户发 Key:明文存库、一 Key 走天下、无法吊销、用量算不清,是 solo 团队最常见的四个坑。这篇指南给你一套可直接抄走的体系——Key 格式设计(前缀+熵+展示位)、只存哈希的存储方案、scope 权限模型、轮换/吊销 SOP、按 Key 限流与用量归因,附 TS 代码模板、Prisma 表结构、用量归因 SQL 与一周落地清单。

API Key 创建表单截图:包含 Key 名称、关联用户、权限、限流覆盖与过期时间字段

你的 SaaS 跑起来了,用户开始问:"有 API 吗?我想把你们的数据接到我的系统里。"

这是个好信号——说明产品真有价值。但接下来 90% 的 solo 团队会掉进同一个坑:随手生成一串随机字符串塞进数据库,明文存储,发给用户,然后祈祷永远不出事。

我见过太多这样的 Key:一个 Key 走天下,读数据、写数据、删数据全凭它;Key 泄露了想吊销,发现代码里到处是 where api_key = 'xxx' 的硬编码;月底想按调用量收费,发现根本分不清哪个 Key 打了多少请求。更惨的是,Key 明文存在库里,哪天数据库备份泄露,等于把所有用户的大门钥匙一起打包送人。

这篇文章不讲"怎么防黑客",讲的是运营视角的 Key 生命周期:Key 该长什么样、库里该存什么、Key 能干什么、Key 怎么体面地死掉、以及 Key 花掉的钱怎么算到它头上。读完你能直接抄走一套"可吊销、可限权、可计费"的用户 API Key 体系。

先说清楚边界:这篇讲的是发给用户的 Key(customer-facing API keys)。管你自己的密钥(不让密钥进 prompt、仓库、日志)是另一个话题,做法完全不同,别混为一谈。

一、Key 长什么样:前缀 + 熵 + 展示位

先看 Stripe、OpenAI 这些大厂的 Key 长什么样:sk_live_51H...、sk-proj-...。这不是审美,是工程设计。一个合格的用户 Key 应该包含三部分:

  • 前缀(prefix):标识这是谁的 Key、什么环境。vf_live_、vf_test_。前缀有两个硬作用:日志里一眼识别(脱敏后只留前缀+后 4 位也能定位)、防止用户把测试 Key 贴到生产环境还浑然不觉。
  • 熵(entropy):真正的安全部分。用密码学安全的随机数生成,至少 192 bit(24 字节)。不要用 UUID v4 凑合——不是不能用,是它的格式信息位浪费了熵,且一眼就能看出是"随手生成的"。
  • 展示位(display suffix):Key 的后 4 位,明文存库,专门给用户看的。"你的 Key 以 …8f3a 结尾"——用户有 5 个 Key 的时候全靠这个区分。

有人还会加校验位(checksum),比如 Stripe 风格的 CRC。但我实测下来,对 solo 团队这是过度设计:校验位防的是"用户手抄 Key 抄错一位",而 2026 年的用户都是复制粘贴。校验位增加实现复杂度,收益几乎为零。我的判断:前缀 + 192 bit 熵 + 后 4 位展示,够了。

下面是可直接抄的生成与校验模板(TypeScript,Node 20+):

import { randomBytes, createHash, timingSafeEqual } from "node:crypto";

const BASE62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const PREFIX_LIVE = "vf_live_";
const PREFIX_TEST = "vf_test_";

function randomBase62(bytes: number): string {
  const buf = randomBytes(bytes);
  let out = "";
  for (const b of buf) out += BASE62[b % 62];
  return out;
}

export interface IssuedKey {
  /** 只展示一次的完整 Key,明文 */
  plaintext: string;
  /** 存库的 SHA-256 哈希 */
  keyHash: string;
  /** 存库的前缀,用于快速定位 + 日志脱敏 */
  prefix: string;
  /** 存库的后 4 位,给用户看 */
  last4: string;
}

export function issueApiKey(env: "live" | "test" = "live"): IssuedKey {
  const prefix = env === "live" ? PREFIX_LIVE : PREFIX_TEST;
  // 32 字节 = 256 bit 熵,base62 编码后约 43 字符
  const secret = randomBase62(32);
  const plaintext = prefix + secret;
  return {
    plaintext,
    keyHash: createHash("sha256").update(plaintext).digest("hex"),
    prefix,
    last4: secret.slice(-4),
  };
}

/** 校验请求带来的 Key:先哈希再比对,用常量时间比较防时序攻击 */
export function verifyApiKey(presented: string, storedHashHex: string): boolean {
  const presentedHash = createHash("sha256").update(presented).digest();
  const storedHash = Buffer.from(storedHashHex, "hex");
  if (presentedHash.length !== storedHash.length) return false;
  return timingSafeEqual(presentedHash, storedHash);
}

注意三个细节:

  1. plaintext 只在签发那一刻存在,返回给用户一次,之后永远不再展示完整 Key。用户弄丢了?重新签发,不提供"找回"。
  2. 校验用 timingSafeEqual 而不是 ===,防时序攻击。这是 5 分钟能做到的事,没理由不做。
  3. SHA-256 够不够?Key 本身是 256 bit 随机值,攻击者没有"弱口令可撞库"的前提,不需要 bcrypt 那种慢哈希。慢哈希反而会拖慢你每次 API 请求的鉴权路径。快哈希 + 高熵 = 正确组合。

/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/8d2e8077-27d5-4312-bad9-5f8567237a07.png

上面这张图是典型的 Key 管理界面:签发时填名称、关联用户、选权限、设过期时间。你的后台至少要有这四个字段,否则半年后你自己都分不清哪个 Key 是干嘛的。

二、库里存什么:只存哈希,顺带把审计想好

明文存 Key 是红线,原因不只是"泄露",而是你的运维面天然会接触到它:数据库备份、日志、ORM 的 debug 输出、staging 环境的 dump——任何一个环节都能让明文 Key 流出去。哈希存储把"数据库只读泄露"这个常见场景的伤害降为零。

Prisma 表结构模板(Postgres):

model ApiKey {
  id          String    @id @default(cuid())
  userId      String
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)

  name        String    // "生产环境-ERP 同步",用户自己填
  keyHash     String    @unique // SHA-256 hex,只存这个
  prefix      String    // "vf_live_",用于日志脱敏和快速过滤
  last4       String    // 后 4 位展示

  scopes      String[]  // ["invoices:read", "invoices:write"],见第三部分
  status      ApiKeyStatus @default(ACTIVE)

  rateLimitPerMin Int?  // 覆盖默认限流,null = 走全局默认
  expiresAt   DateTime?
  lastUsedAt  DateTime?
  createdAt   DateTime  @default(now())
  revokedAt   DateTime?

  events      ApiKeyEvent[]
  @@index([userId, status])
}

enum ApiKeyStatus {
  ACTIVE
  ROTATING   // 轮换过渡期:新旧 Key 同时有效
  REVOKED
  EXPIRED
}

/// 不可变审计流水:签发/轮换/吊销/过期全部记在这里
model ApiKeyEvent {
  id        String   @id @default(cuid())
  apiKeyId  String
  apiKey    ApiKey   @relation(fields: [apiKeyId], references: [id], onDelete: Cascade)
  type      String   // ISSUED | ROTATED | REVOKED | EXPIRED | USED_FROM_NEW_IP
  actorId   String?  // 操作人(用户自己或管理员)
  ip        String?
  meta      Json?
  createdAt DateTime @default(now())
  @@index([apiKeyId, createdAt])
}

这张表设计里藏了三个运营决策:

  1. 吊销不靠删行。状态机 ACTIVE → REVOKED,行永远保留。删行会让审计断链——三个月后用户问"为什么我的集成 5 月 3 号断了",你查无此 Key,只能干瞪眼。
  2. ROTATING 状态是轮换 SOP 的核心(见第四部分)。没有这个中间态,轮换就等于"先掐死旧 Key 再发新 Key",用户的生产任务会在半夜断掉,然后来骂你。
  3. lastUsedAt 是僵尸 Key 雷达。90 天没用过的 Key,发邮件提醒用户吊销;180 天没用过的,自动转 EXPIRED。这是大厂都在做的 hygiene,solo 团队用一条 cron 就能实现。

三、Key 能干什么:scope 权限模型

"一个 Key 走天下"是 solo 团队最常见的设计,也是最危险的。正确的做法是 scope(权限范围):Key 创建时声明它能访问哪些资源、做什么操作,用 resource:action 命名。

/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/24c8d4e2-941e-433c-899b-d598f006d210.jpg

上图是"创建受限 Key"的界面——权限越细,用户越敢把 Key 交给第三方。你的 scope 设计表可以照这个思路填:

Scope含义典型持有者
invoices:read读取发票列表与详情数据分析脚本、BI 同步
invoices:write创建/作废发票ERP 集成、财务系统
customers:read读取客户资料CRM 同步
webhooks:manage增删改 webhook 订阅仅自有后端,不建议发给第三方
billing:read查看用量与账单客户自己的 FinOps 脚本
admin:*管理面操作(成员、Key 管理)永不签发给用户,仅内部

三条铁律:

  1. 默认最小权限。签发界面默认只勾 :read 类 scope,写权限要用户手动展开"我知道风险"才能选。多这一步,能拦掉一半的误操作。
  2. scope 是签发时快照,不是实时引用。Key 行上的 scopes 数组就是鉴权依据,不要每次请求去查"用户当前角色"。否则用户降级套餐后,旧 Key 的权限语义会变成一笔糊涂账。
  3. 鉴权中间件只做一件事:解析 Key → 查哈希 → 查状态 → 查 scope 是否覆盖当前路由。不要在里面塞业务逻辑。示例:
// Express / Hono 风格的鉴权中间件骨架
async function requireScope(scope: string) {
  return async (req, res, next) => {
    const presented = req.headers["authorization"]?.replace("Bearer ", "");
    if (!presented) return res.status(401).json({ error: "missing_api_key" });

    const key = await db.apiKey.findUnique({ where: { keyHash: sha256(presented) } });
    if (!key || !verifyApiKey(presented, key.keyHash)) {
      return res.status(401).json({ error: "invalid_api_key" });
    }
    if (key.status !== "ACTIVE" && key.status !== "ROTATING") {
      return res.status(401).json({ error: "api_key_revoked" });
    }
    if (key.expiresAt && key.expiresAt < new Date()) {
      await markExpired(key.id);
      return res.status(401).json({ error: "api_key_expired" });
    }
    if (!key.scopes.includes(scope)) {
      return res.status(403).json({ error: "insufficient_scope", required: scope });
    }
    // 异步更新 lastUsedAt,别阻塞请求
    void db.apiKey.update({ where: { id: key.id }, data: { lastUsedAt: new Date() } });
    req.apiKey = key;
    next();
  };
}

// 路由上直接声明所需 scope,一行搞定
app.get("/v1/invoices", requireScope("invoices:read"), listInvoices);
app.post("/v1/invoices", requireScope("invoices:write"), createInvoice);

四、Key 怎么死:轮换与吊销 SOP

Key 的死亡只有两种体面方式:轮换(用户主动换)和吊销(出事了立刻杀)。不体面的方式是"直接删",前面说过,删行等于销毁证据。

轮换 SOP(推荐 90 天一次,或用户手动触发):

  1. 用户点"轮换" → 生成新 Key(同名、同 scopes),旧 Key 状态置为 ROTATING,新 Key 为 ACTIVE。
  2. 双 Key 并行 7 天:网关同时接受新旧两个 Key 的哈希。这是给用户的迁移窗口,写进文档里。
  3. 7 天后 cron 把 ROTATING 且超期的旧 Key 置为 REVOKED,记一条 ApiKeyEvent。
  4. 如果旧 Key 在 ROTATING 期间仍有流量,发邮件提醒"你的旧 Key 还有 X 次调用,记得切到新 Key"。

吊销 SOP(泄露/离职/可疑流量时):

  1. 一键吊销:状态直接 REVOKED,即时生效(网关查库或查缓存——注意缓存 TTL 别超过 60 秒,否则"吊销了但还能用 5 分钟")。
  2. 自动记 REVOKED 事件,带操作人 IP。
  3. 吊销后 24 小时内,给该 Key 的用户发一封"你的 Key 已吊销,如需恢复请重新签发"的邮件——不要自动发新 Key,让用户确认是本人操作。

/media/content-media/a56f3ccd-38fe-490f-a5d3-6edb6bc228d5/2026/10/fc523065-77ce-4257-ad12-a7e4466e9ac3.png

吊销界面要像上图这样:每个 Key 独立一行,有明确的吊销/删除操作和最后使用时间。用户在半夜发现 Key 泄露时,应该 10 秒内能杀掉它,而不是先去翻你的文档找"请联系 support@…"。

五、Key 花了多少钱:按 Key 限流与用量归因

Key 体系最后的闭环是钱:哪个 Key 打了多少请求、花了多少成本、该收多少钱。没有用量归因,API 计费就是拍脑袋。

限流:按 Key 做 token bucket。全局限流防的是 DDoS,按 Key 限流防的是"一个用户的脚本 bug 把你的账单打爆"(以及把你的上游供应商账单打爆)。实现上 Redis 一行 Lua 脚本的事,key 用 ratelimit:{keyId}:{window},配额从 ApiKey.rateLimitPerMin 读,null 则走套餐默认。

用量归因:请求日志里必须带 keyId。网关鉴权通过后,把 keyId 写进访问日志(或一张 ApiUsageDaily 聚合表)。月底对账的 SQL 长这样:

-- 按 Key 汇总上月调用量与错误率:计费 + 异常检测一句话查出来
SELECT
  k.name,
  k.last4,
  u.user_id,
  SUM(u.request_count)          AS total_requests,
  SUM(u.error_count) * 1.0 / NULLIF(SUM(u.request_count), 0) AS error_rate,
  SUM(u.llm_tokens_in + u.llm_tokens_out) AS total_tokens
FROM api_usage_daily u
JOIN api_keys k ON k.id = u.api_key_id
WHERE u.day >= date_trunc('month', now() - interval '1 month')
  AND u.day <  date_trunc('month', now())
GROUP BY k.name, k.last4, u.user_id
ORDER BY total_requests DESC;

如果你用 Stripe 做 metered billing,对接清单只有三步:创建 meter(按 api_requests 事件)、网关每次鉴权成功后上报一次 stripe.billing.meterEvents.create(带 keyId 进 metadata)、月底 Stripe 自动出账单。关键点:上报是"尽力而为"的异步操作,别让 Stripe 的延迟拖慢你的 API 响应——写进队列,失败重试 3 次后记日志人工补。

六、反模式:这三件事别做

反模式为什么是坑正确做法
用户 Key 和自家 secrets 混在一个表自家 secret 轮换逻辑(CI 注入、K8s secret)和用户 Key 生命周期完全不同,混表等于两套 SOP 打架;且一次越权查询可能把自家生产密钥也捞出来物理隔离:api_keys(用户) vs service_credentials(自家),不同表、不同访问账号
吊销靠"删行"审计断链;用户问"我的集成哪天断的"你答不上来;误删无法恢复状态机 + ApiKeyEvent 不可变流水
无 scope 的"root key"发给所有集成方一个 Key 泄露 = 全部权限沦陷;且无法按 Key 做最小授权和用量归因签发时必须选 scopes;提供"一键生成只读 Key"降低用户操作成本

七、一周落地清单

如果你现在是一个 Key 走天下的状态,按这个顺序改,每一步都是独立可上线的:

  1. Day 1-2:加上 prefix/last4/keyHash 三字段,新签发的 Key 走新格式;老 Key 标记为 legacy,限 30 天内轮换。
  2. Day 3:鉴权中间件加上 scope 检查;路由上声明所需 scope;默认给老 Key 补全量 scopes(兼容),新 Key 必须显式选择。
  3. Day 4:吊销/轮换接口 + ROTATING 双 Key 并行逻辑 + 审计事件表。
  4. Day 5:按 Key 限流(Redis token bucket)+ 网关日志带 keyId。
  5. Day 6-7:用量聚合表 + 对账 SQL 跑通;僵尸 Key 提醒 cron 上线。

做完这套,你的 API 就从"发出去就失控的字符串"变成了一套真正的运营体系。下次用户问"能给我个 Key 吗",你的回答不再是"好的稍等",而是一个链接:自助签发、自己选权限、自己轮换——而你,终于不用在半夜被"Key 泄露了怎么办"的消息吵醒。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

一人团队内容审核三层漏斗示意图:机器预审、用户举报、人工终审的分工与过滤比例
指南
一人团队内容审核指南:三层漏斗,每周只花 30 分钟

只要 vibe 项目有用户能写字的地方,你就得做审核——但审核不是删帖,是分级处置。本指南先画三类风险地图(传统 UGC、用户可发布内容、AI 生成输出),再搭三层漏斗:机器做 90%(关键词+AI 审核 API),用户举报当传感器(30 秒表单+分级 SLA),你每周只花 30 分钟处理 1% 的难案。附一页纸社区准则模板、防 prompt 注入三道防线、灾难熔断三档开关,一人团队直接照抄。

工具技巧项目创作独立开发
vibe 项目凭据管理指南封面概念图:AI 手中的钥匙串与上锁的保险箱,警示不要把密钥交给 AI
指南
别把密钥交到 AI 手上:vibe 项目的凭据管理实战

AI coding 时代,密钥泄露不再靠人手滑——AI 会主动把 key 写进代码、注释、commit 里并 push。本指南给 vibe coder 一套凭据五态管理框架(生成→存储→使用→轮换→泄露应急),全部可直接抄走:.env 分层与预提交钩子、生产密钥托管选型、AGENTS.md 三条红线、第三方 key 最小权限、30 分钟泄露止血 SOP,以及每月 5 分钟巡检清单。

开发工作流GitHubStripe
端侧优先的 AI 架构示意图:推理在用户设备上运行,云端只做兜底
指南
把 AI 算力推到用户端:vibe 项目的本地/边缘推理实战

最便宜的 GPU 是用户的 GPU。这篇指南讲一人团队如何把合适的推理任务推到浏览器与端侧:WebGPU、transformers.js、WebLLM、llama.cpp、Ollama、Apple MLX 五条路线对照;1B–8B 量化模型的任务对照表;端侧先行、云端兜底的降级链与可直接套用的回退代码;把"数据不出设备"包装成营销卖点;首次加载体积、内存、发热的三条体验红线;以及两周跑完一个端侧功能试点的落地 SOP。云端只做兜底。

部署上线工具对比独立开发