为何不依赖数据库进行身份验证
摘要
本文通过一个SQL注入场景,解释了将数据库作为API身份验证唯一可信源的危险,并介绍了Sturdy Statistics采用的防御深度方法:使用HMAC-SHA512与加密胡椒。
暂无内容
查看缓存全文
缓存时间: 2026/07/10 21:13
# 为什么我们不信任数据库进行身份验证 – Sturdy Statistics
来源:https://blog.sturdystatistics.com/posts/api_keys/
编写 API 时,很容易不知不觉地引入一个危险的隐含假设:数据库是终极真理来源。如果一条记录在数据库中,应用程序通常将其视为权威信息。本文想解释为什么这可能是一个非常糟糕的主意。
在 Sturdy Statistics,我们的核心工程哲学是**纵深防御**:我们努力在每一层防止故障,但每一层的设计都假设其他层可能失效。在 API 身份验证方面,将数据库作为唯一的身份仲裁者,这是一种伪装成标准实践的关键漏洞。
这就是为什么我们不信任数据库负责身份验证,以及我们如何从结构上防止数据库级别的攻破演变成整个系统的入侵。
## 绕过:SQL 注入如何授予系统级访问权限
考虑一下处理 API 密钥时危险且隐含的默认做法。如果你将机器令牌当作用户密码处理,实现方案似乎很明显:
1. 生成一个随机密钥。
2. 对其进行哈希处理(例如 SHA-256)。
3. 将哈希值存储在 `api_keys` 表中,同时存储一个 `org_id`。
4. 当请求到来时,对提供的密钥进行哈希,并查找匹配的行。
这感觉很安全,因为数据库只存储哈希值,而不是明文密钥。但让我们看看如果攻击者在应用程序的其他地方发现了一个盲 SQL 注入漏洞会发生什么。
攻击者不需要读取数据库或逆向任何哈希。攻击者只需注册一个合法账户,生成自己的有效 API 密钥即可。由于该密钥既合法又确实属于他,他知道明文密钥,并且知道应用程序会成功验证它。到目前为止,这完全是合法的。
现在假设攻击者发现了一个 SQL 注入漏洞。通过执行一个简单的 `UPDATE` 语句,他可以复制*自己*密钥的哈希值,并将其粘贴到*受害者*密钥的哈希值上。当攻击者发送一个试图访问受害者数据的请求时,他使用自己的(有效)密钥。API 中间件对其进行哈希,找到*受害者*的行(现在包含了攻击者粘贴的哈希),并授予访问权限。
砰。现在我们有多少其他安全措施都不重要了;攻击者刚刚绕过了 API 安全边界和整个基于密钥的租户边界层。他的攻击使用了一个*有效*的密钥。因为应用程序盲目信任数据库中记录的哈希值,一个简单的数据库写操作就导致了完全租户接管。
## 修复:加密绑定
保护数据库免受 SQL 注入显然是一个优先事项,但这种攻击的“绕过”性质意味着我们需要如同在外围应用的那样纵深防御。我们必须将身份验证系统的安全性与数据库的安全性分开。
为了实现这一点,Sturdy Statistics 不存储简单的 API 密钥哈希。相反,我们使用服务端加密 Pepper——一个由 TPM 密封到后端的高熵秘密——来计算 HMAC-SHA512 签名。
关键是,我们不只对密钥本身签名。我们对密钥的结构上下文进行签名:
``
存储的哈希 = HMAC-SHA512(Pepper, api-key-id
|| rotation-version
|| org-id
|| secret)
``
这些数据来自哪里?`api-key-id`、`rotation-version` 和 `secret` 直接从传入的客户端令牌中解析。`org-id` 严格从请求的 URL 路径参数中提取。数据库提供存储的哈希以供对照,同时提供其记录中的 `api-key-id`、`rotation-version` 和 `org-id`。因此,密钥和存储的哈希各出现一次。其他标识符各在两个位置存储。这种“双重记账”使我们能够检查一致性。
如果攻击者设法写入数据库,并将组织 A 的哈希换到组织 B 的行中,攻击会立即失败。当我们的身份验证中间件验证传入的令牌时,它会从 URL 路径中提取组织 B 的 `org-id` 并将其注入 HMAC 计算。由于哈希编码了错误的 `org-id`,哈希会变化,签名被拒绝,攻击者被锁定。
关键是,因为这个签名需要 Pepper——它只存在于后端内存中,从不接触数据库——即使攻击者拥有数据库的完全读写权限也无法得逞。没有 Pepper,他不可能创建或修改密钥。数据库不再独自决定身份;它只存储后端能够验证的验证器。因此,后端的防御层保持完整,无法通过数据库绕过。
### 回滚防御,或称僵尸密钥
你可能想知道为什么我们还要在哈希中包含 `rotation-version`。这会将签名绑定到密钥生命周期中的特定阶段,这是防御**回滚攻击**的第一部分。如果一个密钥被泄露并最终轮换,其旧的 V1 哈希就会失效。
但是,如果拥有数据库访问权限的攻击者试图通过覆盖当前行来复活已撤销的密钥:将 `hash` 改回 V1 哈希,将 `rotation-version` 改回 1,会怎样?
因为 HMAC 阻止将 V1 哈希冒充为 V2 密钥,攻击者*必须*回滚版本列才能使哈希计算正确。我们通过将加密签名与数据库 `TRIGGER` 配对来阻止这一点。我们配置数据库引擎在 `rotation_version` 列上强制执行严格单向棘轮机制:它只能递增。当攻击者尝试执行 `UPDATE` 语句以回退版本时,数据库架构本身拒绝该事务。这种组合甚至能挡住那些拥有数据库写入权限以及受害者组织过期密钥的攻击者。
## 偏执的租户隔离:四层验证
将 API 密钥加密绑定到租户是第一步,但纵深防御需要重叠的安全边界。在多租户环境中,跨租户数据泄露是 OWASP 确定的最常见安全故障。
为了确保绝对隔离,我们在每个请求的整个生命周期中反复执行租户检查:
1. **路由层:** 每个请求路径本身(作为路径参数)显式声明了租户,确保在任何应用程序代码执行之前,路由基础设施确切知道正在访问哪个逻辑边界。
2. **身份验证层:** 如上所述,API 密钥验证在密码学上直接与 `org-id` 绑定。
3. **应用程序层:** 应用程序级别的每个读写操作都强制执行租户范围限定。业务逻辑函数不接受裸标识符;它们需要授权的租户上下文才能继续。
4. **数据库模式层:** 作为最后的保险,我们在最低可能的级别(使用数据库 `TRIGGER`)强制执行租户隔离。即使应用程序代码中的逻辑错误试图进行跨租户写入,模式也会拒绝该操作。
## 优雅的安全:零停机轮换
难以使用的安全机制通常最终会被绕过。密钥轮换是出了名的麻烦,通常需要 API 提供者和客户端之间协调停机时间。
因为我们明确控制了哈希和数据库模式的确切机制,所以我们将零停机轮换直接构建到架构中。我们的 `api_keys` 表包含一个 `prev-hash` 列和一个 `grace-period-expires-at` 时间戳。
当用户轮换密钥时,我们生成一个新密钥,递增轮换版本,并计算新的主哈希。旧的哈希降级到 `prev-hash` 列,并被赋予严格的存活时间(例如 24 小时)。在此窗口期间,后端检查将授权*任一*密钥。这允许分布式客户端系统异步更新其环境变量,而不会丢弃任何合法请求。
如果你留意,这会重新引入回滚漏洞:拥有数据库写入权限*并且*能够访问过期密钥的攻击者可以通过覆盖宽限期来复活该密钥。我们通过另一个 `TRIGGER` 来解决:只有当轮换版本也同时递增时,宽限期才能延长。
## 无需容器的隔离
在 Sturdy Statistics,我们不相信增加复杂的分布式基础设施层是实现安全的唯一途径。
真正的安全来自于架构的简单性和数学的严谨性。通过将信任锚点移出数据库,并在应用程序边缘强制执行严格的加密边界,我们确保某一层的故障仍然仅仅是:一个受限制的故障,而不是灾难性的入侵。
**注意:** 在整篇文章中,我使用了 SQL 注入和数据库 `TRIGGER` 的语言,因为这些都是关系型数据库架构中熟悉的术语。在幕后,Sturdy Statistics 实际上使用 Datomic。Datomic 的不可变账本意味着身份验证状态永远不会被简单地在原地覆盖,这增加了对依赖于重写存储数据的攻击的防护。虽然 Datomic 不使用 SQL 触发器,但我们通过 Datomic 事务函数强制执行相同的单向棘轮和边界条件。为了便于理解,我在这里选择了 SQL 术语,但核心教训无论存储引擎如何都一样:单凭数据库状态不应能够授予身份验证。
相似文章
现代应用中进行身份验证的最佳方式是什么
文章讨论了在localStorage与cookies中存储身份验证令牌的安全影响,强调了XSS攻击的风险,以及对于敏感应用使用httpOnly cookies的好处。
防止令牌窃取
本文讨论了信息窃取型恶意软件窃取身份验证令牌的问题,并探讨了Dirk Balfanz在15年前提出的一项提案:使用自签名客户端证书进行TLS双向认证,从而将令牌绑定到特定设备,即使令牌被窃取也无法重用。
如何让AI代理接触生产数据库而不令人胆战心惊?
一位开发者向社区提问,如何安全地让AI代理与生产数据库交互,重点表达了对SQL注入、数据泄露和缺乏审计追踪的担忧。
我们让AI代理访问数据库、邮件系统和支付API。然后我们只是……信任它们。
本文强调了当前对能够访问数据库、邮件系统和支付API的AI代理严重缺乏治理层,指出目前在没有监督的情况下信任LLM的做法危险且不足。
停止使用 JWT
一篇观点文章,反对在身份验证和会话管理中使用 JSON Web Token(JWT),并指出了其安全性和设计上的问题。