API 认证与授权深度解析:机制、权衡与故障模式
每个 API 都需要某种形式的身份认证,但"有认证"和"做对了认证"完全是两回事。
我审查过多个生产级系统,有些 JWT 根本没有设置过期时间;有些 API 密钥被硬编码在源码里,还提交到了公共仓库;有些 OAuth 重定向 URI 使用了通配符;还有些金融类 API 在号称使用 HTTPS 的通道上进行 Basic 认证,结果发现根本没人去核验。
这些系统的每一个漏洞,都是在等着被利用。
这些问题大多不是由粗心的工程师造成的,而是由那些懂机制但不懂故障模式的工程师造成的。没有人告诉他们当系统出问题时该怎么做,也没有人定义组织层面的标准。他们就用自己熟悉的那套方案,实现到刚好能通过代码审查的程度,然后就继续推进了。
这篇文章旨在改变这种状况——不仅讲解每种机制的原理,更说清楚什么时候该用、什么时候不该用,以及它们在生产环境中到底会怎么失败。
在开始之前,先澄清一个会造成真实漏洞的概念混淆:身份认证(Authentication)回答的是"你是谁",而授权(Authorization)回答的是"你被允许做什么"。
一个身份认证完美但授权混乱的系统,依然会泄露未授权数据;一个授权严格但身份认证薄弱的系统,则极易被绕过。两者必须各自独立正确。
目录
前置知识
阅读本文前,你需要了解:
什么是 API,HTTP 请求和响应的基本工作原理
对 token 和 session 的基本认识
基本的软件架构概念:什么是网关(gateway)、什么是服务层(service layer)
熟悉 Dart 或 C# 语法
不需要安全领域的背景知识,文中的每个概念都会从工程视角来讲解。
基础前提:选机制之前必须做对的事
在考虑选用哪种认证机制之前,有三件事必须先落实。缺了这些,任何机制都救不了你。
1. TLS 不是可选项
所有 API 都必须通过 HTTPS 通信。所有端点、所有环境都要如此,不只是生产环境,也不只是处理银行卡号的端点,一个都不能例外。
没有 TLS,本文提到的任何机制都可能被拦截。Basic Auth token、API key、bearer token、JWT 都是通过 HTTP 头传输的,而离开 TLS 的 HTTP 头就是明文。
基础设施必须至少强制 TLS 1.2。TLS 1.0 和 1.1 存在已知漏洞,SSLv3 更是被彻底攻破。如果客户端尝试协商更旧的协议版本,必须在基础设施层直接拒绝连接。这是一个配置层面的问题,不是靠代码能解决的。
2. 生产 API 不得在缺乏管控的情况下通过公开工具访问
如果生产 API 可以通过 Postman 或 Swagger 从公网直接访问,却没有任何访问控制,那就是一颗定时炸弹。
开发和测试必须使用专门的环境,配备独立的凭证,且这些凭证对生产数据零权限。
3. 生产数据不得复制到开发或测试环境
这不只是最佳实践的问题。根据尼日利亚 NDPA 2023,个人数据只能为特定、明确且合法的目的进行处理,把生产环境的个人数据复制到开发环境会带来直接的法律风险。而根据 PCI-DSS,任何存储、处理或传输持卡人数据的环境都在合规范围之内。
工程上的正确做法是:在所有非生产环境中,始终使用合成的测试数据和脱敏数据集。
做好这一点之后,下面介绍七种认证机制。
1. Basic Authentication(基本认证)
工作原理
客户端在每次请求时都发送用户名和密码。凭据按 username:password 格式拼接,经过 Base64 编码后放入 Authorization 请求头。
Authorization: Basic am9objpzZWNyZXQxMjM=
这串编码内容就是 john:secret123 的 Base64 形式。Base64 不是加密,只是编码。任何截获该请求头的人,用网上的 Base64 解码工具几秒钟就能还原出来。
Dart:
// server-side basic auth validation
String? extractBasicAuthCredentials(Request request) {
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Basic ')) return null;
final encoded = authHeader.substring(6);
final decoded = utf8.decode(base64.decode(encoded));
return decoded;
}
Handler basicAuthMiddleware(Handler handler, UserService userService) {
return (Request request) async {
final credentials = extractBasicAuthCredentials(request);
if (credentials == null) {
return Response.unauthorized(
'Missing credentials',
headers: {'WWW-Authenticate': 'Basic realm="API"'},
);
}
final parts = credentials.split(':');
if (parts.length != 2) return Response.unauthorized('Invalid credentials');
final isValid = await userService.validateCredentials(parts[0], parts[1]);
if (!isValid) return Response.unauthorized('Invalid credentials');
return handler(request);
};
}
C#:
public class BasicAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
private readonly IUserService _userService;
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.ContainsKey("Authorization"))
return AuthenticateResult.Fail("Missing Authorization header");
var authHeader = Request.Headers["Authorization"].ToString();
if (!authHeader.StartsWith("Basic "))
return AuthenticateResult.Fail("Invalid Authorization scheme");
var encoded = authHeader.Substring(6);
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
var parts = decoded.Split(':');
if (parts.Length != 2)
return AuthenticateResult.Fail("Invalid credentials format");
var isValid = await _userService.ValidateCredentials(parts[0], parts[1]);
if (!isValid)
return AuthenticateResult.Fail("Invalid credentials");
var claims = new[] { new Claim(ClaimTypes.Name, parts[0]) };
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
Basic Auth 的真正问题
凭证随每个请求传输。一旦某个请求被拦截,攻击者将永久获取用户名和密码。它没有过期时间,除非修改密码,否则无法吊销。Base64 编码本身不提供任何安全保障。
如果 Basic Auth 端点没有限流,就为暴力破解敞开了大门。持有常见密码列表的攻击者会系统性地逐一尝试。如果没有拦截机制,他们迟早会成功登录。
适用场景
在封闭可控的内部环境中,若连接始终加密且 API 不对外暴露,Basic Auth 可用于服务器间的内部通信。切勿将其用于面向用户的 API、处理敏感数据的场景,或在未使用 TLS 的情况下使用。
2. API 密钥
API key 本质上就是服务端发给客户端的唯一密钥字符串,用来标识调用方。当你注册使用第三方服务(比如支付网关或短信平台)时,会拿到一个 key。之后你的应用每次调用该服务都会带上这个 key,服务端由此知道是谁在调用,可以跟踪你的使用量,并对请求应用相应的权限和限流策略。
它不绑定某个用户,而是绑定你的应用。这也是 API key 与用户认证令牌的根本区别。
工作原理
服务端给客户端发放一个静态密钥字符串,客户端在每次请求的 header 中携带它。
X-API-Key: sk_live_abc123xyz
Dart:
class ApiKeyService {
final ApiKeyRepository _repository;
ApiKeyService(this._repository);
Future<Result<ApiKeyContext, AppException>> validateApiKey(
String apiKey,
String callerDomain,
) async {
final keyRecord = await _repository.findByKey(apiKey);
if (keyRecord == null) {
return Result.failure(AppException.unauthorized('Invalid API key'));
}
if (keyRecord.isExpired) {
return Result.failure(AppException.unauthorized('API key has expired'));
}
if (keyRecord.isRevoked) {
return Result.failure(AppException.unauthorized('API key has been revoked'));
}
// validate that the calling domain is allowed for this key
if (!keyRecord.allowedDomains.contains(callerDomain)) {
return Result.failure(
AppException.forbidden('Calling domain not authorized for this API key'),
);
}
return Result.success(ApiKeyContext(
clientId: keyRecord.clientId,
environment: keyRecord.environment,
allowedScopes: keyRecord.allowedScopes,
));
}
}
Handler apiKeyMiddleware(Handler handler, ApiKeyService apiKeyService) {
return (Request request) async {
final apiKey = request.headers['x-api-key'];
if (apiKey == null || apiKey.isEmpty) {
return Response(401, body: jsonEncode({'error': 'API key required'}));
}
final origin = request.headers['origin'] ?? request.headers['host'] ?? '';
final result = await apiKeyService.validateApiKey(apiKey, origin);
if (result.isFailure) {
return Response(403, body: jsonEncode({'error': result.error?.message}));
}
return handler(request);
};
}
C#:
public class ApiKeyMiddleware
{
private readonly RequestDelegate _next;
private readonly IApiKeyService _apiKeyService;
public async Task InvokeAsync(HttpContext context)
{
if (!context.Request.Headers.TryGetValue("X-API-Key", out var apiKey))
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("API key required");
return;
}
var callerDomain = context.Request.Headers["Origin"].ToString()
?? context.Request.Host.Value;
var result = await _apiKeyService.ValidateApiKey(apiKey, callerDomain);
if (!result.IsSuccess)
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync(result.Error);
return;
}
context.Items["ApiKeyContext"] = result.Value;
await _next(context);
}
}
API Key 的真正问题
API key 是静态的,不会自动过期。一旦出现在公开的 GitHub 仓库、Slack 消息或日志文件里,它就是一份有效的凭证,直到有人发现并轮换它为止。
我在大型组织里见过的最常见漏洞之一:被多个客户端调用的 API 没有做域名白名单校验,同一把 key 在任何域名下都能用。这样一来,key 就可以在客户端之间、环境之间随意流转——staging 的 key 能调生产环境,移动端的 key 能在 Web 客户端使用,各端之间根本没有边界。
任何 API key 都不应该在多个客户端或多个环境通用。key 必须绑定到特定的允许域名和特定环境,这没有商量的余地。
绝不要把 API key 硬编码在源码里,绝不要提交到 Git。.gitignore 是不够的。key 必须在运行时从可信的密钥管理服务获取:Vault、Azure App Configuration、AWS Secrets Manager 等。同时,key 轮换必须是一项定期执行的组织规范,而不是怀疑泄露后的应急操作。
适用场景
API key 适合用在服务端之间的通信、需要识别和限流调用方的公开 API,以及开发者工具与集成等场景——一句话,凡是非人类用户直接调用的场景。
3. Bearer Token 认证
工作原理
客户端通过登录流程认证一次,拿到一个 token,之后的所有请求都在 Authorization 请求头里携带这个 token。
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
初始登录后不再需要凭证。传输的是令牌,服务器会在每次请求中验证该令牌。
Dart:
class BearerTokenMiddleware {
final TokenValidator _validator;
BearerTokenMiddleware(this._validator);
Handler call(Handler handler) {
return (Request request) async {
final authHeader = request.headers['authorization'];
if (authHeader == null || !authHeader.startsWith('Bearer ')) {
return Response(401, body: jsonEncode({'error': 'Bearer token required'}));
}
final token = authHeader.substring(7);
final validationResult = await _validator.validate(token);
if (validationResult.isFailure) {
return Response(401, body: jsonEncode({'error': validationResult.error?.message}));
}
final updatedRequest = request.change(
context: {'auth_claims': validationResult.value},
);
return handler(updatedRequest);
};
}
}
C#:
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)
),
ClockSkew = TimeSpan.Zero
};
});
Bearer 令牌的真实问题
令牌窃取是最大隐患。一旦攻击者获取有效的 Bearer 令牌,就能在令牌过期或被手动吊销前持续使用。因此令牌过期不是可选项,而是限制令牌泄露后损害范围的关键。短寿命访问令牌配合刷新令牌轮换才是正确模式。
适用场景
适用于大多数现代 Web 和移动 API、用户认证流程,以及任何需要无状态、可扩展认证的场景。
4. JWT – JSON Web Token
工作原理
JWT 是一种特定的 Bearer Token 格式,并非独立的认证机制。它是自包含的令牌,用户相关的声明(claims)直接携带在令牌内部。 JWT 由三部分组成,用点号分隔:Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ.SIGNATURE
Header 指定签名算法;Payload 携带各项声明:用户 ID、角色、过期时间、签发时间;Signature 则是一段加密哈希,用于证明令牌确实来自你的服务器,且未被篡改。
服务端通过密码学方式验证签名后,即可信任令牌内的声明,无需查询数据库。这正是 JWT 无状态、易扩展的原因。
Dart:
class JwtService {
final String _secret;
final String _issuer;
final Duration _accessTokenExpiry;
JwtService({
required String secret,
required String issuer,
Duration accessTokenExpiry = const Duration(minutes: 15),
}) : _secret = secret,
_issuer = issuer,
_accessTokenExpiry = accessTokenExpiry;
String generateAccessToken(User user) {
final now = DateTime.now();
final payload = {
'sub': user.id,
'role': user.role.name,
'iat': now.millisecondsSinceEpoch ~/ 1000,
'exp': now.add(_accessTokenExpiry).millisecondsSinceEpoch ~/ 1000,
'iss': _issuer,
};
return _sign(payload);
}
Result<JwtClaims, AppException> validateToken(String token) {
try {
final claims = _verifyAndDecode(token);
final exp = claims['exp'] as int;
if (DateTime.fromMillisecondsSinceEpoch(exp * 1000).isBefore(DateTime.now())) {
return Result.failure(AppException.unauthorized('令牌已过期'));
}
if (claims['iss'] != _issuer) {
return Result.failure(AppException.unauthorized('无效的令牌签发者'));
}
return Result.success(JwtClaims.fromMap(claims));
} on SignatureVerificationException {
return Result.failure(AppException.unauthorized('令牌签名无效'));
} catch (e) {
return Result.failure(AppException.unauthorized('令牌验证失败'));
}
}
}
C#:
public class JwtService
{
private readonly JwtSettings _settings;
public string GenerateAccessToken(User user)
{
var securityKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_settings.Secret)
);
var credentials = new SigningCredentials(
securityKey,
SecurityAlgorithms.HmacSha256
);
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, user.Id),
new Claim(ClaimTypes.Role, user.Role.ToString()),
new Claim(JwtRegisteredClaimNames.Iat,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
};
var token = new JwtSecurityToken(
issuer: _settings.Issuer,
audience: _settings.Audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(15),
signingCredentials: credentials
);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
JWT 的失效模式
JWT 用对了非常好,用错了后果可能很严重。所有问题都出在实现上,而不是标准本身。以下是我见过的几种会引发真实故障的场景。
1. 算法混淆攻击
JWT 的 header 声明了签名所用的算法。如果你的服务端照单全收,攻击者就可以把算法改成 none,直接去掉签名——服务端就会把任何 token 都当作有效请求。
2. 签名密钥太弱
弱密钥可以被离线暴力破解。攻击者根本不需要访问你的服务器:拿到 token、跑一遍破解工具、找出密钥,之后就能以任意用户身份签发任意 token。
请使用至少 256 位的强加密随机密钥。在高安全场景下,改用非对称密钥的 RS256 或 ES256。
3. 没有过期时间
缺少 exp 声明的 JWT 永远不会失效。务必设置过期时间:短期 access token 建议 15 分钟到 1 小时,会话的延续交给 refresh token 来处理。
4. payload 里放了敏感数据
JWT 的载荷是 Base64 编码而非加密,任何人拿到令牌都能解码查看全部内容。切勿在 JWT 载荷中放入密码、完整账号、BVN 或敏感 PII,应只放标识符,由服务端负责获取敏感数据。
5. 缺乏吊销机制
由于 JWT 是无状态的,服务端不会跟踪已签发的令牌。被盗用的令牌在过期前始终有效。
解决方案:维护一个令牌黑名单用于标记被显式吊销的令牌,或者采用短有效期配合刷新令牌轮转,以缩短风险窗口。
何时使用 JWT
适用于需要规模化无状态 API 的场景,适用于微服务架构(服务方无需在每次请求时都调用中央认证服务器来验证身份),以及移动应用。在那些无状态认证的可扩展性比管理令牌吊销的复杂度更重要的系统中,JWT 是一个好选择。
5. OAuth 2.0
工作原理
OAuth 2.0 是授权框架,不是认证协议。它允许用户授予第三方应用访问其资源的权限,而无需将密码共享给该第三方。
每次你用 Google 账号登录某个应用,或者看到“允许此应用访问你的账户”时,就已经在使用它了。
涉及四个角色:颁发令牌的授权服务器、托管受保护 API 的资源服务器、发起访问请求的客户端,以及即用户的资源所有者。
授权码流程如下:
客户端将用户重定向到授权服务器,并请求特定的作用域(scopes)。
用户完成认证并批准所请求的作用域。
授权服务器将用户重定向回客户端,同时返回一个授权码。
客户端在服务端用该授权码换取访问令牌。
客户端使用访问令牌调用资源服务器。
Dart 示例:
class OAuthClient {
final String _clientId;
final String _clientSecret;
final String _redirectUri;
final String _authorizationEndpoint;
final String _tokenEndpoint;
OAuthClient({
required String clientId,
required String clientSecret,
required String redirectUri,
required String authorizationEndpoint,
required String tokenEndpoint,
}) : _clientId = clientId,
_clientSecret = clientSecret,
_redirectUri = redirectUri,
_authorizationEndpoint = authorizationEndpoint,
_tokenEndpoint = tokenEndpoint;
String buildAuthorizationUrl(List<String> scopes) {
final state = _generateSecureState();
final params = {
'response_type': 'code',
'client_id': _clientId,
'redirect_uri': _redirectUri,
'scope': scopes.join(' '),
'state': state,
};
final uri = Uri.parse(_authorizationEndpoint)
.replace(queryParameters: params);
return uri.toString();
}
Future<Result<OAuthTokens, AppException>> exchangeCodeForTokens(
String code,
String state,
String expectedState,
) async {
if (state != expectedState) {
return Result.failure(
AppException.unauthorized('Invalid state parameter'),
);
}
final response = await http.post(
Uri.parse(_tokenEndpoint),
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: {
'grant_type': 'authorization_code',
'code': code,
'redirect_uri': _redirectUri,
'client_id': _clientId,
'client_secret': _clientSecret,
},
);
if (response.statusCode != 200) {
return Result.failure(AppException.unauthorized('Token exchange failed'));
}
final tokens = OAuthTokens.fromJson(jsonDecode(response.body));
return Result.success(tokens);
}
String _generateSecureState() {
final bytes = List<int>.generate(32, (_) => Random.secure().nextInt(256));
return base64Url.encode(bytes);
}
}
C#:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "OAuth2";
})
.AddCookie()
.AddOAuth("OAuth2", options =>
{
options.ClientId = builder.Configuration["OAuth:ClientId"]!;
options.ClientSecret = builder.Configuration["OAuth:ClientSecret"]!;
options.CallbackPath = "/auth/callback";
options.AuthorizationEndpoint = "https://auth.provider.com/authorize";
options.TokenEndpoint = "https://auth.provider.com/token";
options.SaveTokens = true;
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Events = new OAuthEvents
{
OnCreatingTicket = async context =>
{
var userInfoRequest = new HttpRequestMessage(
HttpMethod.Get,
"https://auth.provider.com/userinfo"
);
userInfoRequest.Headers.Authorization =
new AuthenticationHeaderValue("Bearer", context.AccessToken);
var response = await context.Backchannel.SendAsync(userInfoRequest);
var userInfo = await response.Content.ReadFromJsonAsync<JsonDocument>();
context.Identity!.AddClaim(new Claim(
ClaimTypes.NameIdentifier,
userInfo!.RootElement.GetString("sub")!
));
}
};
});
OAuth 2.0 的常见故障模式
1. 重定向 URI 配置不当
如果授权服务器没有严格校验重定向 URI,攻击者就可以替换成自己的 URI,截获授权码。防范方法是采用精确匹配校验。
2. Token 出现在 URL 中
有人把 access token 放在查询参数而不是 Authorization 头里,结果它就会出现在服务器日志、浏览器历史或 referrer 头中。Token 应该放在 Authorization 头里——URL 会进日志,而 Authorization 头不会。
何时使用 OAuth 2.0
凡是第三方需要代理访问用户资源的场景,它都很适用,比如社交登录、跨组织的 API 集成,或合作伙伴系统对接。
6. OpenID Connect(OIDC)
工作原理
OAuth 2.0 只负责授权,而 OpenID Connect 在它之上增加了认证能力。它不仅能告诉你用户批准了什么,还能告诉你这个用户到底是谁。
OIDC 在签发访问令牌(access token)的同时会颁发一个 ID token。该 ID token 是一个 JWT,其中包含经过验证的身份声明,包括主题标识符、邮箱、姓名、头像以及认证发生的时间。
Dart:
class OidcService {
final String _issuer;
final String _clientId;
final JwtValidator _jwtValidator;
OidcService({
required String issuer,
required String clientId,
required JwtValidator jwtValidator,
}) : _issuer = issuer,
_clientId = clientId,
_jwtValidator = jwtValidator;
Future<Result<UserIdentity, AppException>> validateIdToken(
String idToken,
) async {
final claimsResult = await _jwtValidator.validate(idToken);
if (claimsResult.isFailure) {
return Result.failure(claimsResult.error!);
}
final claims = claimsResult.value!;
if (claims['iss'] != _issuer) {
return Result.failure(AppException.unauthorized('Invalid token issuer'));
}
final aud = claims['aud'];
final audiences = aud is List ? aud : [aud];
if (!audiences.contains(_clientId)) {
return Result.failure(
AppException.unauthorized('Token not intended for this client'),
);
}
return Result.success(UserIdentity(
subject: claims['sub'] as String,
email: claims['email'] as String?,
name: claims['name'] as String?,
emailVerified: claims['email_verified'] as bool? ?? false,
));
}
}
C#:
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
options.Authority = "https://accounts.google.com";
options.ClientId = builder.Configuration["OIDC:ClientId"]!;
options.ClientSecret = builder.Configuration["OIDC:ClientSecret"]!;
options.ResponseType = "code";
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
NameClaimType = "name",
RoleClaimType = "role"
};
});
OIDC 的适用场景
凡是需要通过可信身份提供方验证用户身份的应用都适合使用它,比如企业 SSO 或社交登录。当你想把身份验证委托给可信的第三方,而不是自己管理用户凭证时,它都是不错的选择。
Google Sign-In、Microsoft Azure AD、Okta、Auth0 都实现了 OIDC。如果你关心的是"这个人是谁",而不仅仅是"这个请求是否被授权",那么 OIDC 就是合适的框架。
7. 双向 TLS(mTLS)
工作原理
在普通 TLS 中,只有客户端验证服务器的证书。服务器信任任何能建立连接的客户端。
而在双向 TLS 中,双方会互相验证证书。服务器只接受持有可信证书颁发机构(CA)所签发证书的客户端连接。客户端无法伪造身份,因为它必须持有真实证书。
这里没有 bearer token 或 API key,证书本身就是身份验证。
Dart(客户端 mTLS):
class MtlsHttpClient {
final http.Client _client;
MtlsHttpClient._internal(this._client);
static Future<MtlsHttpClient> create({
required String certificatePath,
required String privateKeyPath,
required String trustedCaPath,
}) async {
final context = SecurityContext(withTrustedRoots: false);
// load the client certificate
context.useCertificateChainBytes(
await File(certificatePath).readAsBytes(),
);
// load the client private key
context.usePrivateKeyBytes(
await File(privateKeyPath).readAsBytes(),
);
// only trust this specific CA
context.setTrustedCertificatesBytes(
await File(trustedCaPath).readAsBytes(),
);
final httpClient = HttpClient(context: context);
final client = IOClient(httpClient);
return MtlsHttpClient._internal(client);
}
Future<http.Response> get(String url, {Map<String, String>? headers}) {
return _client.get(Uri.parse(url), headers: headers);
}
Future<http.Response> post(
String url, {
Map<String, String>? headers,
Object? body,
}) {
return _client.post(Uri.parse(url), headers: headers, body: body);
}
}
C#:
builder.WebHost.ConfigureKestrel(options =>
{
options.ConfigureHttpsDefaults(httpsOptions =>
{
httpsOptions.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
httpsOptions.ClientCertificateValidation = (certificate, chain, errors) =>
{
if (errors != SslPolicyErrors.None)
return false;
var expectedThumbprint = "your-trusted-cert-thumbprint";
return certificate.Thumbprint == expectedThumbprint;
};
});
});
public class MtlsMiddleware
{
private readonly RequestDelegate _next;
public async Task InvokeAsync(HttpContext context)
{
var clientCert = context.Connection.ClientCertificate;
if (clientCert == null)
{
context.Response.StatusCode = 401;
await context.Response.WriteAsync("Client certificate required");
return;
}
if (!IsValidClientCertificate(clientCert))
{
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Invalid client certificate");
return;
}
await _next(context);
}
private bool IsValidClientCertificate(X509Certificate2 cert)
{
if (cert.NotAfter < DateTime.UtcNow) return false;
var trustedThumbprints = new HashSet<string>
{
"THUMBPRINT_SERVICE_A",
"THUMBPRINT_SERVICE_B",
};
return trustedThumbprints.Contains(cert.Thumbprint);
}
}
mTLS 真正的坑在哪里
问题出在证书管理上。证书会过期,如果轮换没有自动化,一个过期证书就会在最不该出事的时刻把服务间通信搞挂。此外,CA 基础设施本身也必须严格保护——一旦 CA 被攻破,它签发的所有证书就全部沦陷了。
对于缺乏运维能力去维护一整套 PKI 的团队来说,配合严格的域名白名单和定期轮换的 API key 可能是更务实的选择。mTLS 是正确的架构,但也是运维成本最高、最容易出错的方案。
什么时候该用 mTLS
在高安全性的服务间通信场景中(例如支付网关或银行 API),务必使用 mTLS。在受监管环境中,合规要求通常在传输层就需要密码学层面的身份验证,mTLS 同样是理想选择。在高安全系统中,内部服务之间的所有通信都应视为敏感数据。mTLS 为此类系统奠定了安全基础。
选择合适的机制
选择身份认证机制属于架构层面的决策。以下是选型框架:
面向用户的 Web 和移动应用:选用基于 JWT 的 Bearer Token。采用短时效访问令牌配合刷新令牌轮换机制,并对所有认证端点实施速率限制。
第三方委托访问:使用 OAuth 2.0。如果还需要验证用户身份,在此基础上叠加 OIDC。
企业 SSO:使用 OpenID Connect 搭配企业级身份提供商。
低安全要求的服务间通信:使用 API Key,配合域名白名单、环境专属密钥以及定期轮换策略。
受监管高安全环境中的服务间通信:使用 mTLS。
严格受控环境下的内部工具:Basic Auth 是底线,且前提是必须保证 TLS 传输安全。除上述场景外,其他情况请务必使用更强的认证方式。
选择认证机制时,应依据调用方身份、数据敏感度以及合规要求来决定,而非盲目跟随惯例或沿用上一项目的做法,更不能为了省事而降低标准。
贯穿始终的组织纪律
搞定技术实现只算完成了一半,另一半在于组织管理。
API Key 的轮换必须是计划内、主动进行的,绝不能等到怀疑泄露后才被动响应。每个密钥都应有明确的最大生命周期,并由工程团队负责执行轮换计划。
严禁一个 API Key 供多个客户端或多个环境使用。Staging 环境的密钥就只属于 Staging 环境,移动端客户端密钥就只属于移动端。跨环境复用密钥意味着安全边界的彻底崩塌。
密钥严禁硬编码在源代码、应用代码或提交至代码仓库的配置文件中。密钥必须在运行时从可信的密钥管理系统中动态获取。这是一项工程标准,而非个人偏好。
加密算法必须是组织层面的标准,而不是开发者逐项目自行决定的事。由 staff engineer 或架构师级别的人来规定组织统一使用哪种算法、哪种模式、哪种密钥长度。所有项目都遵循这套标准,任何开发者都不允许在每个项目里从零实现加密。
组织应当构建并维护内部共享的加密操作包。开发者直接引入这个包即可,永远不要自己手写加密逻辑。这样就消灭了一整类因随意选型导致的加密实现缺陷。
带字段级脱敏的结构化日志必须在框架层面强制执行。Authorization 头、token、密码、账号以及任何敏感字段,在任何环境(生产、预发或开发)的日志中都绝不能出现。脱敏逻辑做在日志框架层面,即使开发者想打敏感数据也打不进去。
结语
本文介绍的每种机制,实现正确时都能正常工作;但实现不当时,也会以可预见、有据可查的方式出问题。
JWT 算法混淆攻击曾造成过真实的身份认证绕过,OAuth redirect URI 配置错误曾导致过真实的账号接管,缺失 state 参数曾让真实的 CSRF 攻击得逞,弱签名密钥曾引发过大规模的身份伪造,代码仓库里硬编码的 API key 曾让生产系统暴露在未授权访问之下。
这些都不是理论上的失败场景,而是真实出现在各种规模组织的生产系统中。构建这些系统的工程师并非能力不足,只是没人告诉他们这些失败模式——没有人制定组织标准,也没有人在流水线里强制执行。
所谓工程纪律,就是在写下第一行代码之前,先彻底搞清楚每种机制会怎么出问题,并从设计之初就规避这些失败模式。这正是“能用”的认证和“能在对抗环境下扛得住”的认证之间的区别。
祝编码愉快!