CWE-798: Use of Hard-coded Credentials - C
Overview
Hard-coded credentials in C# source code are readable by anyone with repository access, and they survive in version control history and in compiled assemblies. Never embed passwords, API keys, connection strings, or encryption keys in code or configuration files committed to version control. Use environment variables, User Secrets, Azure Key Vault, or configuration providers.
Primary Defence: Use .NET User Secrets for local development only, Azure Key Vault or another managed secret store for production, and IConfiguration with secure providers to retrieve credentials at runtime. Environment variables are useful for deployment-time injection but should not be treated as a complete secret-management system - see CWE-526.
Rotate first, then refactor. Any credential that has been committed is
compromised, whether or not the repository is public: it is in git log, in
every clone, and in every build artefact produced since. Deleting the literal
from HEAD changes none of that. Revoke the value at the system that issued it
before or alongside the code change, and treat the refactor as what stops it
happening again rather than as the fix.
Common Vulnerable Patterns
Hard-coded Database Credentials
// VULNERABLE - Credentials in source code
using System.Data.SqlClient;
public class DatabaseConnection
{
private const string ConnectionString =
"Server=myserver;Database=mydb;User Id=admin;Password=P@ssw0rd123;"; // DANGEROUS!
public SqlConnection GetConnection()
{
return new SqlConnection(ConnectionString);
}
}
Why this is vulnerable: Hard-coded credentials in source code are exposed to anyone with repository access, remain in version control history, and cannot be rotated without code changes, enabling unauthorized database access if the code is leaked.
Hard-coded API Keys
// VULNERABLE - API key in code
public class ApiClient
{
private const string ApiKey = "sk_live_51H7x8y9z10a11b12c"; // DANGEROUS!
private const string ApiSecret = "whsec_abcdef123456"; // DANGEROUS!
public async Task<HttpResponseMessage> MakeRequestAsync()
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {ApiKey}");
return await client.GetAsync("https://api.example.com/data");
}
}
Why this is vulnerable: API keys embedded in code are visible in version control, build artifacts, and decompiled assemblies, allowing anyone with code access to impersonate the application and incur charges or access sensitive data.
Hard-coded Encryption Keys
// VULNERABLE - Encryption key in code
using System.Security.Cryptography;
using System.Text;
public class Encryptor
{
private const string SecretKey = "MySecretKey12345"; // DANGEROUS!
public byte[] Encrypt(string data)
{
using var aes = Aes.Create();
aes.Key = Encoding.UTF8.GetBytes(SecretKey.PadRight(32));
aes.IV = new byte[16];
using var encryptor = aes.CreateEncryptor();
return encryptor.TransformFinalBlock(Encoding.UTF8.GetBytes(data), 0, data.Length);
}
}
Why this is vulnerable: Anyone who can read the source or decompile the assembly recovers the key, and with it can decrypt every value the application has ever encrypted. The key cannot be rotated without redeploying the application.
Credentials in appsettings.json (Committed to Git)
// VULNERABLE - appsettings.json with real credentials committed
{
"ConnectionStrings": {
"DefaultConnection": "Server=myserver;Database=mydb;User Id=admin;Password=P@ssw0rd123;"
},
"ApiSettings": {
"ApiKey": "sk_live_51H7x8y9z10a11b12c",
"ApiSecret": "whsec_abcdef123456"
}
}
Why this is vulnerable: Configuration files committed to version control expose credentials permanently in git history, remain accessible even after deletion, and are often deployed to production as well.
Default Administrator Account
// VULNERABLE - Built-in administrator the product ships with
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class LoginController : ControllerBase
{
private const string AdminUser = "admin";
private const string AdminPassword = "admin123"; // DANGEROUS!
// A licence gate that is really a second hard-coded credential.
private const string LicenceKeyHash =
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; // DANGEROUS!
private readonly IUserService _users;
public LoginController(IUserService users) => _users = users;
[HttpPost]
public IActionResult Login([FromForm] string username, [FromForm] string password)
{
// The built-in account is checked before the real user store
if (username == AdminUser && password == AdminPassword)
{
return Ok(IssueAdminSession());
}
return _users.Validate(username, password) ? Ok() : Unauthorized();
}
[HttpPost("activate")]
public IActionResult Activate([FromForm] string licenceKey)
{
var digest = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(licenceKey))).ToLowerInvariant();
return digest == LicenceKeyHash ? Ok() : Unauthorized(); // DANGEROUS!
}
}
Why this is vulnerable: this is the inbound half of CWE-798 - a credential
the product accepts rather than one it sends - and a secret store does not fix
it. admin123 is the same on every installation, so one copy of the product
yields the administrator password for every customer running it; attackers try
admin/admin, admin/admin123 and root/toor in the first minutes of a scan,
and an account intended only for initial setup is routinely still live in
production. The licence check is the same weakness wearing a hash: a fixed
digest is a fixed credential, published in every binary, and == on it also
compares byte by byte with an early exit, so the comparison leaks how much of
the value was right (see CWE-208). Moving either
literal into Azure Key Vault changes nothing - the credential still
authenticates on every deployment.
Secure Patterns
Credentials the Product Accepts: Authenticate, Do Not Compare
Take this one first. It is the fix for Default Administrator Account above,
and none of the secret-management patterns below address it: the built-in
administrator is not a secret the application needs to hold, it is an
authenticator the application should never have accepted. Moving admin123 into
Key Vault leaves the same credential working on every installation.
// SECURE - no credential is identical across installations
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
public sealed class AdminUser
{
public string Username { get; init; } = string.Empty;
public string PasswordHash { get; init; } = string.Empty;
}
// Per-installation store. Nothing in here ships with the product.
public interface IAdminStore
{
Task<int> CountAsync();
Task<AdminUser?> FindAsync(string username);
Task UpdateHashAsync(string username, string passwordHash);
}
[ApiController]
[Route("api/[controller]")]
public class LoginController : ControllerBase
{
// A hash of a value nobody knows, verified against when the account does
// not exist, so the unknown-user path costs about what a real check costs.
private static readonly string DummyHash =
new PasswordHasher<AdminUser>().HashPassword(new AdminUser(), Guid.NewGuid().ToString());
private readonly IAdminStore _store;
private readonly IPasswordHasher<AdminUser> _hasher;
public LoginController(IAdminStore store, IPasswordHasher<AdminUser> hasher)
{
_store = store;
_hasher = hasher;
}
[HttpPost]
public async Task<IActionResult> LoginAsync([FromForm] string username, [FromForm] string password)
{
// First-run enrolment gate: refuse to serve until an administrator has
// been enrolled. A fresh install has no working credential at all,
// rather than a well-known one.
if (await _store.CountAsync() == 0)
{
return StatusCode(StatusCodes.Status503ServiceUnavailable, "Setup incomplete");
}
var user = await _store.FindAsync(username);
if (user is null)
{
_hasher.VerifyHashedPassword(new AdminUser(), DummyHash, password);
return Unauthorized();
}
var result = _hasher.VerifyHashedPassword(user, user.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
{
return Unauthorized();
}
if (result == PasswordVerificationResult.SuccessRehashNeeded)
{
// Stored hash used weaker parameters than the current default.
await _store.UpdateHashAsync(user.Username, _hasher.HashPassword(user, password));
}
// Issue the session here.
return Ok();
}
}
public static class DeploymentToken
{
// For a fixed token the product genuinely must accept - one generated at
// install time and stored per deployment, never compiled in.
public static bool Matches(string presented, string expected)
{
Span<byte> a = stackalloc byte[32];
Span<byte> b = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(presented), a);
SHA256.HashData(Encoding.UTF8.GetBytes(expected), b);
return CryptographicOperations.FixedTimeEquals(a, b);
}
}
Why this works: there is no credential literal left for the code to compare
against, so there is nothing an attacker can read out of the assembly and use
against every other deployment. PasswordHasher<TUser> - the hasher ASP.NET
Core Identity registers by default - verifies a per-user hash from the
installation's own store instead of testing equality against a constant, and it
salts: hashing the same password twice returns two different strings, so one
installation's stolen database does not yield credentials for another. The
encoded hash carries its own format version, which is why
VerifyHashedPassword has three outcomes rather than two;
SuccessRehashNeeded is what it returns for a password that is correct but
stored under older parameters, and re-hashing on that branch upgrades accounts
as people sign in. Verifying the unknown user against DummyHash keeps the two
failure paths similar in cost, which is CWE-208
territory.
CountAsync is what stops the default coming back. A build that refuses to
serve until enrolment finishes cannot ship with a working admin/admin123,
whereas a default that only logs a warning stays in place - put the same check
in middleware so every route is gated, not just login. For a token the product
really must accept, CryptographicOperations.FixedTimeEquals is the comparison
that does not exit early on the first differing byte. It returns false
immediately when the two spans differ in length, so hashing both sides to 32
bytes first keeps the length of the presented token out of the result as well.
Store passwords this way in general - see CWE-916.
Environment Variables
// SECURE - Read from environment variables
using System;
using Microsoft.Data.SqlClient;
public class DatabaseConnection
{
private readonly string _connectionString;
public DatabaseConnection()
{
var server = Environment.GetEnvironmentVariable("DB_SERVER");
var database = Environment.GetEnvironmentVariable("DB_NAME");
var userId = Environment.GetEnvironmentVariable("DB_USER");
var password = Environment.GetEnvironmentVariable("DB_PASSWORD");
if (string.IsNullOrEmpty(password))
{
throw new InvalidOperationException("Database credentials not configured");
}
_connectionString = $"Server={server};Database={database};User Id={userId};Password={password};";
}
public SqlConnection GetConnection()
{
return new SqlConnection(_connectionString);
}
}
// PowerShell - Set environment variables:
// $env:DB_SERVER="myserver"
// $env:DB_NAME="mydb"
// $env:DB_USER="admin"
// $env:DB_PASSWORD="SecurePassword123"
Why this works: Environment variables keep credentials separate from source code and can be supplied by the OS or deployment platform. The validation ensures the application fails fast if credentials are missing. For production, inject these values from a secret store or platform secret mechanism and protect them from process dumps, debug endpoints, platform metadata, and logging. Rotation usually requires updating the provider and refreshing configuration or restarting the application.
IConfiguration with External Config (.NET Core/5+)
// SECURE - ASP.NET Core configuration
using Microsoft.Extensions.Configuration;
public class ApiClient
{
private readonly string _apiKey;
private readonly string _apiSecret;
public ApiClient(IConfiguration configuration)
{
_apiKey = configuration["ApiSettings:ApiKey"]
?? throw new InvalidOperationException("API key not configured");
_apiSecret = configuration["ApiSettings:ApiSecret"]
?? throw new InvalidOperationException("API secret not configured");
}
public async Task<HttpResponseMessage> MakeRequestAsync()
{
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {_apiKey}");
return await client.GetAsync("https://api.example.com/data");
}
}
// appsettings.json (committed - NO secrets):
{
"ApiSettings": {
"ApiKey": "",
"ApiSecret": ""
}
}
// appsettings.Development.json (NOT committed - add to .gitignore):
{
"ApiSettings": {
"ApiKey": "your_dev_key_here",
"ApiSecret": "your_dev_secret_here"
}
}
// For production, use environment variables or Azure Key Vault
Why this works: IConfiguration lets you compose settings from safe providers (environment variables, user secrets, Key Vault) while keeping the committed files empty of secrets. The empty values in appsettings.json document required keys without leaking credentials; per-environment files and environment variables override them at runtime, so nothing sensitive hits source control. The constructor throws if a required key is missing, creating a fail-fast startup instead of running with blank credentials. Because configuration binding is centralized, rotating a secret means updating the external provider (Key Vault, user secrets, or an environment variable) and restarting - no code change or redeploy. Scoping the provider per environment also keeps each environment's credentials to least privilege.
User Secrets (Development Only)
// SECURE - User Secrets for development
// 1. Initialize User Secrets (from project directory):
// dotnet user-secrets init
// 2. Set secrets:
// dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;Database=mydb;User Id=admin;Password=DevPassword123;"
// dotnet user-secrets set "ApiSettings:ApiKey" "sk_test_your_dev_key"
// 3. Access in code (same as appsettings.json):
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
var connectionString = Configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
}
}
// Secrets stored in:
// Windows: %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json
// Linux/macOS: ~/.microsoft/usersecrets/<user_secrets_id>/secrets.json
Why this works: User Secrets store development-only credentials outside the repo under the user profile, so nothing enters Git history. The Secret Manager tool does not encrypt the stored values and should not be used as a trusted production store. Because the same Configuration pipeline reads User Secrets in Development and environment variables/Key Vault in Production, you keep a single code path while swapping providers per environment. The explicit CLI steps also make local updates easy, and the missing-secret check still happens at startup.
Azure Key Vault
// SECURE - Azure Key Vault integration
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Extensions.Configuration;
// Program.cs (.NET 6+)
var builder = WebApplication.CreateBuilder(args);
// Add Azure Key Vault
var keyVaultEndpoint = new Uri(Environment.GetEnvironmentVariable("KeyVaultEndpoint")!);
builder.Configuration.AddAzureKeyVault(keyVaultEndpoint, new DefaultAzureCredential());
var app = builder.Build();
// Access secrets like normal configuration:
var apiKey = app.Configuration["ApiKey"];
// Or inject into services:
public class ApiClient
{
private readonly string _apiKey;
public ApiClient(IConfiguration configuration)
{
_apiKey = configuration["ApiKey"];
}
}
// NuGet packages:
// <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
// <PackageReference Include="Azure.Identity" />
Why this works: Azure Key Vault centralizes secret management with managed access control and audit logging. DefaultAzureCredential uses Managed Identity in production and developer credentials locally, so application credentials are not embedded in code. Secrets are encrypted at rest and in transit. Key Vault integration surfaces secrets through the standard IConfiguration API, so code that already reads configuration does not change. Secret versioning supports staged rotation when the application and operations process handle refresh correctly.
AWS Secrets Manager
// SECURE - AWS Secrets Manager
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using System.Text.Json;
using System.Text.Json.Serialization;
public class SecretsService
{
private readonly IAmazonSecretsManager _secretsManager;
public SecretsService()
{
_secretsManager = new AmazonSecretsManagerClient();
}
public async Task<DatabaseCredentials> GetDatabaseCredentialsAsync()
{
var request = new GetSecretValueRequest
{
SecretId = "prod/database/credentials"
};
var response = await _secretsManager.GetSecretValueAsync(request);
// SecretString is a JSON document, and its keys are lower-case.
// System.Text.Json matches property names case-sensitively by default,
// so without JsonPropertyName every field would silently be "".
var creds = JsonSerializer.Deserialize<DatabaseCredentials>(response.SecretString)
?? throw new InvalidOperationException("Secret is not a JSON object");
if (string.IsNullOrEmpty(creds.Username) || string.IsNullOrEmpty(creds.Password))
{
throw new InvalidOperationException(
"Secret 'prod/database/credentials' has no username/password - check the key names");
}
return creds;
}
}
public class DatabaseCredentials
{
[JsonPropertyName("username")] public string Username { get; set; } = string.Empty;
[JsonPropertyName("password")] public string Password { get; set; } = string.Empty;
[JsonPropertyName("host")] public string Host { get; set; } = string.Empty;
[JsonPropertyName("port")] public int Port { get; set; }
}
// NuGet package:
// <PackageReference Include="AWSSDK.SecretsManager" />
// AWS credentials from environment or EC2 instance role
Why this works: AWS Secrets Manager keeps credentials out of code and source control, encrypts them with KMS, and lets you enforce access through IAM policies. The SDK automatically sources auth from the environment (profiles, EC2/ECS/Lambda roles), so the app never embeds AWS keys. Fetching secrets at runtime means you rotate centrally in Secrets Manager and get the new version without redeploying code. Versioning and CloudTrail auditing provide traceability and rollback during rotations.
Two details decide whether this works at all. SecretString is a JSON
document, not the secret itself, and the keys an RDS-managed secret uses are
username, password, host, port, engine, dbname - all lower-case.
JsonSerializer.Deserialize matches property names case-sensitively unless told
otherwise, so a DatabaseCredentials with plain Username/Password properties
deserializes without error into an object whose every field is "". The
null-coalescing throw does not fire, because the object is not null. Either the
[JsonPropertyName] attributes above or
new JsonSerializerOptions { PropertyNameCaseInsensitive = true } fixes it; the
explicit check on the populated values is what turns the remaining failure modes
into a startup error instead of a login attempt with an empty password.
Framework-Specific Guidance
ASP.NET Core
// SECURE - Full ASP.NET Core configuration setup
// Program.cs (.NET 6+)
var builder = WebApplication.CreateBuilder(args);
// Configuration sources, in the order they are added. Each one OVERRIDES the
// ones above it, so the last entry wins - appsettings.json is the weakest
// source, not the strongest.
// 1. appsettings.json
// 2. appsettings.{Environment}.json
// 3. User Secrets (Development only)
// 4. Environment variables
// 5. Command-line arguments
// 6. Azure Key Vault, because it is added below - after CreateBuilder
// Add Azure Key Vault in production
if (builder.Environment.IsProduction())
{
var keyVaultEndpoint = builder.Configuration["KeyVaultEndpoint"];
if (!string.IsNullOrEmpty(keyVaultEndpoint))
{
builder.Configuration.AddAzureKeyVault(
new Uri(keyVaultEndpoint),
new DefaultAzureCredential());
}
}
// Register services with configuration
builder.Services.Configure<ApiSettings>(
builder.Configuration.GetSection("ApiSettings"));
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
var app = builder.Build();
// ApiSettings class
public class ApiSettings
{
public string ApiKey { get; set; } = string.Empty;
public string ApiSecret { get; set; } = string.Empty;
}
// Inject settings into controller
[ApiController]
[Route("api/[controller]")]
public class DataController : ControllerBase
{
private readonly ApiSettings _apiSettings;
public DataController(IOptions<ApiSettings> apiSettings)
{
_apiSettings = apiSettings.Value;
}
[HttpGet]
public async Task<IActionResult> GetData()
{
// Use _apiSettings.ApiKey
return Ok();
}
}
Why this works: ASP.NET Core's configuration pipeline composes multiple sources in priority order, letting runtime secrets override committed defaults without touching code. User Secrets work in Development, environment variables in containers, and Key Vault in production - all feeding the same IConfiguration abstraction. Options pattern (IOptions<T>) injects strongly-typed settings into services, catching misconfigurations at startup via validation attributes. The environment check (IsProduction()) ensures Key Vault is only wired in prod, avoiding local dev complexity. Rotating a secret is then a provider update and a restart, not a code change.
.NET Framework (Legacy)
// SECURE - .NET Framework with ConfigurationManager
using System.Configuration;
public class DatabaseConnection
{
private readonly string _connectionString;
public DatabaseConnection()
{
// Read from app.config or web.config
_connectionString = ConfigurationManager.ConnectionStrings["DefaultConnection"].ConnectionString;
if (string.IsNullOrEmpty(_connectionString))
{
throw new InvalidOperationException("Connection string not configured");
}
}
}
// web.config or app.config (NOT committed):
/*
<configuration>
<connectionStrings>
<add name="DefaultConnection"
connectionString="Server=myserver;Database=mydb;User Id=admin;Password=#{DB_PASSWORD}#;" />
</connectionStrings>
<appSettings>
<add key="ApiKey" value="#{API_KEY}#" />
</appSettings>
</configuration>
*/
// Use web.config transforms or Azure App Service configuration substitution
Why this works: ConfigurationManager reads from web.config or app.config at runtime, letting you tokenize connection strings with placeholders (#{DB_PASSWORD}#) that deployment pipelines replace per environment. The explicit check fails startup with a clear message rather than letting an empty connection string through. Prefer deployment-time substitution from a secret store or platform configuration, and do not commit transformed production configs. This legacy approach predates modern secret stores but still separates code from config when operational controls keep the substituted secrets out of source and artifacts.
Entity Framework Core
// SECURE - EF Core with secure connection string
public class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<User> Users { get; set; }
}
// Startup.cs or Program.cs
public void ConfigureServices(IServiceCollection services)
{
// Connection string from configuration (environment variable)
var connectionString = Configuration.GetConnectionString("DefaultConnection");
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(connectionString));
}
// Set via environment variable:
// $env:ConnectionStrings__DefaultConnection="Server=myserver;Database=mydb;Integrated Security=true;"
// Note: Use Integrated Security or Managed Identity in production
Why this works: Entity Framework Core wires DbContext through dependency injection, pulling the connection string from IConfiguration at registration time. The double-underscore syntax (ConnectionStrings__DefaultConnection) maps environment variables to the nested config structure, letting containers and platforms override without code changes. Because the connection string is resolved once at startup (when AddDbContext runs), misconfiguration fails immediately rather than at first query. Integrated Security or Managed Identity removes passwords entirely, using OS-level or Microsoft Entra authentication tokens. This keeps EF agnostic to secret storage - swap providers by changing config sources, not data access code.
Managed Identity (Azure)
// SECURE - Azure SQL with Managed Identity (no passwords!)
using Azure.Identity;
using Microsoft.Data.SqlClient;
public class DatabaseConnection
{
public async Task<SqlConnection> GetConnectionAsync()
{
var connectionString = "Server=myserver.database.windows.net;Database=mydb;";
var connection = new SqlConnection(connectionString);
// Get token using Managed Identity
var credential = new DefaultAzureCredential();
var token = await credential.GetTokenAsync(
new Azure.Core.TokenRequestContext(new[] { "https://database.windows.net/.default" }));
connection.AccessToken = token.Token;
await connection.OpenAsync();
return connection;
}
}
// NuGet package:
// <PackageReference Include="Azure.Identity" />
// <PackageReference Include="Microsoft.Data.SqlClient" />
Why this works: Managed Identity eliminates passwords entirely by using Microsoft Entra authentication. The application's identity is granted database access through Azure RBAC, not username/password. Tokens are short-lived and rotated by Azure, so there is no password to store, rotate, or leak. The same DefaultAzureCredential works for Azure SQL, Storage, Key Vault, and other Azure services.
Detecting Hard-coded Secrets
Using git-secrets
# Install git-secrets
# https://github.com/awslabs/git-secrets
# Register the built-in AWS patterns, then add your own.
# Patterns are POSIX extended regular expressions - use [[:space:]], not \s.
# Escape the single quote as '' so PowerShell passes one argument, not three.
git secrets --register-aws
git secrets --add 'password[[:space:]]*=[[:space:]]*["'']'
git secrets --add 'apikey[[:space:]]*=[[:space:]]*["'']'
# Scan repository
git secrets --scan
# Add pre-commit hook
git secrets --install
Using TruffleHog
# Install TruffleHog. It is a Go binary - the PyPI "truffleHog" package is
# the abandoned v2 from 2018 and does not accept the arguments below.
docker pull trufflesecurity/trufflehog:latest
# Scan a repository
docker run --rm -it trufflesecurity/trufflehog:latest github --repo https://github.com/yourusername/yourrepo
Verification
To verify credentials are not hardcoded:
- Search source code: Grep for patterns like
password=,apiKey=,secret=, connection strings, and API keys in.csfiles - Review configuration: Check
appsettings.json,web.config, and other config files for hardcoded credentials - Check environment usage: Verify the application reads credentials from environment variables or secret managers at runtime
- Test without secrets: Run the application without setting environment variables - it should fail gracefully with clear error messages, not fall back to hardcoded values
- Review version control: Check git history for accidentally committed secrets (use tools like
git-secretsortrufflehog) - Verify
.gitignore: Ensure configuration files with secrets (e.g.,appsettings.Development.json) are excluded from version control - Use static analysis: Run tools like SonarQube or security scanners to detect hardcoded credentials
- Check build artifacts: Verify deployed packages don't contain hardcoded secrets
Additional Azure Key Vault Practices
The Azure Key Vault pattern shown above (Secure Patterns) covers the core integration. For production deployments, also:
- Use Managed Identity, not access keys: enable System-Assigned or User-Assigned Managed Identity on the App Service/VM/AKS resource and grant it a Key Vault access policy (
az keyvault set-policy --secret-permissions get list);DefaultAzureCredentialpicks it up automatically. - Separate vaults by environment: never share one Key Vault across dev, staging, and production - use different vaults and different Managed Identities per environment.
- Enable soft delete and purge protection (
--enable-soft-delete true --enable-purge-protection true) so secrets aren't lost to accidental deletion. - Monitor access: enable Azure Monitor diagnostics for the vault and alert on unusual
SecretGetcalls or failed authentication attempts. - Use secret versioning for rotation: fetch a specific version during staged rollout, or the latest version once rotation is complete, instead of hard-coding a version everywhere.
- Cache retrieved secrets briefly (for example with
IMemoryCache, a 15-30 minute TTL) to reduce Key Vault API calls without holding stale credentials indefinitely.
Common Pitfalls
- Moving a connection string from a
const stringin code intoappsettings.jsonand committing that file with the real password still in it -IConfigurationnow reads it "from configuration" instead of a literal, but the file is still tracked by git, so nothing about the exposure actually changed. - Using
dotnet user-secretscorrectly for local development but then copying the samesecrets.jsonvalues into a committedappsettings.Production.json"just to get it deployed" - User Secrets are explicitly documented as development-only and unencrypted; treating the file itself as portable defeats the reason it lives outside the repo. - Registering Azure Key Vault with
DefaultAzureCredentialbut leaving a fallbackEnvironment.GetEnvironmentVariable("DB_PASSWORD")in the same constructor for "local dev convenience," where that fallback value is set directly in a committedlaunchSettings.jsonor Docker Compose file - the fallback path reintroduces a hard-coded credential in version control even though the primary path is correct. - Granting the Managed Identity or service principal
get,list, andset/deletepermissions on the Key Vault instead ofget/listonly - an application that can write or delete secrets has a much larger blast radius if compromised than one that can only read the specific secrets it needs.