关于 dotnet-secrets-management
面向 .NET 应用开发者的云无关密钥管理实践指南,覆盖本地开发、生产环境配置、轮换和托管标识。还说明如何避免将密钥提交到代码库、写入配置文件或记录到日志。
主要功能
- 01本地开发使用 User Secrets
- 02生产环境注入环境变量
- 03将密钥绑定到强类型选项
- 04支持密钥轮换的模式
- 05生产环境托管标识实践
- 06密钥管理反模式说明
适用场景
- 01为本地开发配置敏感值
- 02在生产环境注入应用密钥
- 03配置密钥轮换以减少停机
- 04检查代码和日志中的密钥泄露风险
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ wshaddix/ dotnet-skills --skill dotnet-secrets-management安装后在 Claude Code 中输入/dotnet-secrets-management确认已加载。
详细介绍
dotnet-secrets-management
面向 .NET 应用的云无关密钥管理。涵盖完整生命周期:本地开发使用用户机密、生产环境使用环境变量、IConfiguration 绑定模式、密钥轮换,以及作为生产最佳实践的托管标识。还包括应避免的反模式(将密钥放在源代码、appsettings.json 中,或硬编码连接字符串)。
**不在范围内:**云提供商专属的密钥保管库服务(Azure Key Vault、AWS Secrets Manager、GCP Secret Manager)——这些内容由云服务专属的史诗涵盖。身份验证/授权实现(OAuth、Identity)——参见 [skill:dotnet-api-security] 和 [skill:dotnet-blazor-auth]。加密算法选择——参见 [skill:dotnet-cryptography]。通用 Options 模式和配置源——参见 [skill:dotnet-csharp-configuration]。
交叉引用:[skill:dotnet-security-owasp],了解 OWASP A02(加密失败)和已弃用模式警告;[skill:dotnet-csharp-configuration],了解 Options 模式和配置源优先级。
密钥生命周期
| 环境 | 密钥来源 | 机制 |
|---|---|---|
| 本地开发 | 用户机密 | dotnet user-secrets CLI,存储在仓库外的 secrets.json |
| CI/CD | 流水线变量 | 注入为环境变量,绝不放在 YAML 中 |
| 预发布/生产 | 环境变量或密钥保管库 | OS 级环境变量、托管标识或密钥保管库提供程序 |
**原则:**密钥绝不能存在于源代码仓库中,也不能存在于任何已提交到版本控制的文件中。每个环境层级都应使用适合其信任边界的机制。
用户机密(本地开发)
用户机密将敏感配置存储在用户配置文件中的项目目录之外,以防止意外提交。
设置
# Initialize user secrets for a project (creates UserSecretsId in csproj)
dotnet user-secrets init
# Set individual secrets
dotnet user-secrets set "ConnectionStrings:DefaultDb" "Server=localhost;Database=myapp;User=sa;Password=dev123"
dotnet user-secrets set "Smtp:ApiKey" "SG.dev-key-here"
dotnet user-secrets set "Jwt:SigningKey" "dev-signing-key-min-32-chars-long!!"
# List current secrets
dotnet user-secrets list
# Remove a secret
dotnet user-secrets remove "Smtp:ApiKey"
# Clear all secrets
dotnet user-secrets clear
工作原理
用户机密存储位置:
- Windows:
%APPDATA%\Microsoft\UserSecrets\<UserSecretsId>\secrets.json - macOS/Linux:
~/.microsoft/usersecrets/<UserSecretsId>/secrets.json
secrets.json 文件是普通 JSON,结构与 appsettings.json 相同:
{
"ConnectionStrings": {
"DefaultDb": "Server=localhost;Database=myapp;User=sa;Password=dev123"
},
"Smtp": {
"ApiKey": "SG.dev-key-here"
},
"Jwt": {
"SigningKey": "dev-signing-key-min-32-chars-long!!"
}
}
在代码中加载
当 DOTNET_ENVIRONMENT 或 ASPNETCORE_ENVIRONMENT 为 Development 时,WebApplication.CreateBuilder 和 Host.CreateDefaultBuilder 会自动加载用户机密:
var builder = WebApplication.CreateBuilder(args);
// User secrets are already loaded. Access them via IConfiguration:
var connectionString = builder.Configuration.GetConnectionString("DefaultDb");
对于非 Web 主机(控制台应用、工作服务):
var builder = Host.CreateApplicationBuilder(args);
// User secrets are loaded automatically in Development environment.
// For explicit control:
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets<Program>();
}
**注意:**用户机密未加密——它们只是存储在仓库之外。它们仅适用于开发环境,绝不能用于生产环境。
环境变量(生产环境)
环境变量是在不接触文件系统的情况下向生产应用注入密钥的标准机制。
配置优先级
在默认的 ASP.NET Core 配置堆栈中,环境变量优先于基于文件的配置源(后者优先):
appsettings.jsonappsettings.{Environment}.json- 用户机密(仅限 Development)
- 环境变量(覆盖以上所有项)
- 命令行参数
映射约定
.NET 使用 __(双下划线)作为节分隔符,将环境变量映射到配置键:
# These environment variables map to configuration sections:
export ConnectionStrings__DefaultDb="Server=prod-db;Database=myapp;..."
export Smtp__ApiKey="SG.production-key"
export Jwt__SigningKey="production-signing-key-256-bits"
# With a prefix (recommended to avoid collisions):
export MYAPP_ConnectionStrings__DefaultDb="Server=prod-db;..."
// Load prefixed environment variables
builder.Configuration.AddEnvironmentVariables(prefix: "MYAPP_");
// Access the same way as any configuration source:
var smtpKey = builder.Configuration["Smtp:ApiKey"];
容器环境
# docker-compose.yml -- inject secrets via environment
services:
api:
image: myapp:latest
environment:
- ConnectionStrings__DefaultDb=Server=db;Database=myapp;User=sa;Password=${DB_PASSWORD}
- Smtp__ApiKey=${SMTP_API_KEY}
env_file:
- .env # NOT committed to source control
# Dockerfile -- do NOT bake secrets into images
# Use environment variables at runtime instead
ENV ASPNETCORE_URLS=http://+:8080
# NEVER: ENV ConnectionStrings__DefaultDb="Server=..."
**注意:**同一用户下的所有进程都可以看到环境变量。在多租户容器环境中,应使用容器级隔离(Kubernetes secrets、Docker secrets),而不是主机级环境变量。
IConfiguration 绑定模式
将密钥绑定到强类型选项类,以确保编译时安全并进行验证。
public sealed class JwtOptions
{
public const string SectionName = "Jwt";
[Required, MinLength(32)]
public string SigningKey { get; set; } = "";
/// <summary>
/// Previous signing key retained during rotation window.
/// Set this when rotating keys so tokens signed with the old key
/// remain valid until they expire. Remove after rotation completes.
/// </summary>
public string? PreviousSigningKey { get; set; }
[Required]
public string Issuer { get; set; } = "";
[Required]
public string Audience { get; set; } = "";
[Range(1, 1440)]
public int ExpirationMinutes { get; set; } = 60;
}
// Registration with validation
builder.Services
.AddOptions<JwtOptions>()
.BindConfiguration(JwtOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail fast if secrets are missing
// Inject and use
public sealed class TokenService(IOptions<JwtOptions> jwtOptions)
{
private readonly JwtOptions _jwt = jwtOptions.Value;
public string GenerateToken(string userId)
{
var key = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(_jwt.SigningKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: _jwt.Issuer,
audience: _jwt.Audience,
claims: [new Claim(ClaimTypes.NameIdentifier, userId)],
expires: DateTime.UtcNow.AddMinutes(_jwt.ExpirationMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
选项类必须使用
{ get; set; }(而不是{ get; init; }),因为配置绑定器和PostConfigure需要在构造后修改属性。使用数据注释特性([Required]、[MinLength])进行验证。
注意事项:ValidateOnStart() 会在应用启动时(而不是首次使用时)捕获缺失的密钥。对于包含密钥的选项,始终使用它,以便快速失败并显示清晰的错误消息。
密钥轮换
设计应用,使其能够在不中断服务的情况下处理密钥轮换。
适配密钥轮换的模式
// Use IOptionsMonitor<T> for secrets that may change at runtime
public sealed class EmailService(IOptionsMonitor<SmtpOptions> smtpOptions, ILogger<EmailService> logger)
{
public async Task SendAsync(string to, string subject, string body)
{
// CurrentValue reads the latest configuration on every call
var options = smtpOptions.CurrentValue;
logger.LogDebug("Using SMTP host {Host}", options.Host);
// ... send email using current options ...
}
}
// Audit-log configuration changes via a hosted service.
// IHostedService is always activated by the host, so the subscription is guaranteed.
public sealed class SmtpOptionsChangeLogger(
IOptionsMonitor<SmtpOptions> monitor,
ILogger<SmtpOptionsChangeLogger> logger) : IHostedService, IDisposable
{
private IDisposable? _subscription;
public Task StartAsync(CancellationToken cancellationToken)
{
_subscription = monitor.OnChange(options =>
{
logger.LogInformation("SMTP configuration reloaded at {Time}", DateTime.UtcNow);
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public void Dispose() => _subscription?.Dispose();
}
// Registration:
builder.Services.AddHostedService<SmtpOptionsChangeLogger>();
// Dual-key validation for zero-downtime rotation
// Accept both old and new signing keys during rotation window
public sealed class DualKeyTokenValidator(IOptionsMonitor<JwtOptions> optionsMonitor)
{
public TokenValidationParameters GetParameters()
{
// Read CurrentValue on every call so rotated keys are picked up
// without restarting the application
var options = optionsMonitor.CurrentValue;
var keys = new List<SecurityKey>
{
new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey))
};
if (!string.IsNullOrEmpty(options.PreviousSigningKey))
{
keys.Add(new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(options.PreviousSigningKey)));
}
return new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = options.Issuer,
ValidateAudience = true,
ValidAudience = options.Audience,
ValidateLifetime = true,
IssuerSigningKeys = keys
};
}
}
轮换检查清单
- 在旧密钥仍有效时部署新密钥(双密钥窗口期)
- 更新应用,使其同时接受旧密钥和新密钥
- 滚动部署,使所有实例都使用新密钥进行签名/加密
- 所有客户端完成轮换后,移除旧密钥
- 对每次轮换事件进行审计日志记录
托管标识(生产环境最佳实践)
对于托管在云端的应用,托管标识通过使用平台的身份系统来验证服务身份,从而完全消除密钥。
**概念:**应用不再存储带密码的连接字符串,而是使用平台分配的标识向数据库/服务进行身份验证。无需管理、轮换或担心密钥泄漏。
// Example: passwordless connection to SQL Server using DefaultAzureCredential
// This pattern works across Azure, and similar patterns exist for AWS and GCP
var connectionString = "Server=myserver.database.windows.net;Database=mydb;Authentication=Active Directory Default";
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString));
// No password in the connection string -- identity is resolved from the environment
适用托管标识的场景:
- 托管在云平台上的生产和预发布环境
- 平台支持身份联合的任何服务到服务通信
- 数据库连接、消息队列访问、存储访问
仍需要密钥的场景:
- 仅支持 API 密钥的第三方 API
- 不支持身份联合的旧系统
- 本地开发(使用用户机密作为备用方案)
反模式
将密钥提交到源代码管理
// NEVER: hardcoded secrets in source code
private const string ApiKey = "sk-live-abc123def456"; // WRONG
private const string ConnectionString = "Server=prod;Password=secret"; // WRONG
**解决方法:**开发环境使用用户机密,生产环境使用环境变量。请参阅上文相关章节。
在 appsettings.json 中存放密钥
// NEVER: real credentials in appsettings.json (committed to repo)
{
"ConnectionStrings": {
"DefaultDb": "Server=prod-db;Password=RealPassword123!"
}
}
解决方法:appsettings.json 中只能包含非敏感的默认值。使用能明显暴露问题的占位值:
{
"ConnectionStrings": {
"DefaultDb": "Server=localhost;Database=myapp;Integrated Security=true"
},
"Smtp": {
"ApiKey": "REPLACE_VIA_ENV_OR_USER_SECRETS"
}
}
硬编码连接字符串
// NEVER: connection strings directly in code
var connection = new SqlConnection("Server=prod-db;Database=myapp;User=sa;Password=P@ssw0rd!");
**解决方法:**始终从 IConfiguration 获取连接字符串:
// Correct: resolve from configuration
public sealed class OrderRepository(IConfiguration configuration)
{
private readonly string _connectionString =
configuration.GetConnectionString("DefaultDb")
?? throw new InvalidOperationException("ConnectionStrings:DefaultDb is not configured");
}
// Better: use Options pattern with validation
public sealed class OrderRepository(IOptions<DatabaseOptions> options)
{
private readonly string _connectionString = options.Value.ConnectionString;
}
在日志中记录密钥
// NEVER: log secret values
logger.LogInformation("Using API key: {ApiKey}", apiKey); // WRONG
logger.LogDebug("Connection string: {Conn}", connectionString); // WRONG
**解决方法:**记录密钥已加载,而不是记录其值:
logger.LogInformation("API key configured: {IsConfigured}", !string.IsNullOrEmpty(apiKey));
logger.LogInformation("Database connection configured for {Server}", new SqlConnectionStringBuilder(connectionString).DataSource);
Agent 注意事项
- 不要生成包含硬编码密钥的代码——始终使用
IConfiguration或IOptions<T>获取密钥。即使在示例中,也要使用占位值。 - 不要在
appsettings.json中放入真实密钥——该文件会提交到源代码管理。开发环境使用用户机密,生产环境使用环境变量。 - 不要在选项类中使用
{ get; init; }——配置绑定器需要可变 setter。改用{ get; set; },并通过数据注释进行验证。 - 不要跳过
ValidateOnStart()——否则缺失的密钥会在首次使用时导致运行时故障,而不是在启动时显示清晰的错误。 - 不要记录密钥值——记录密钥是否已配置(
IsConfigured: true/false)或元数据(连接字符串中的服务器名称),绝不能记录密钥值。 - 不要对会轮换的密钥使用
IOptions<T>——对可在运行时重新加载的密钥使用IOptionsMonitor<T>,这样轮换时无需重启。 - 不要将密钥烘焙进 Docker 镜像——在容器运行时使用环境变量或挂载的密钥。
先决条件
- .NET 8.0+(LTS 基线版本)
Microsoft.Extensions.Configuration.UserSecrets(包含在 ASP.NET Core SDK 中;控制台应用需手动添加)- 用于
ValidateDataAnnotations()的Microsoft.Extensions.Options.DataAnnotations