Software Securityhome
Fix a findingSearch
Fix card · A07:2025 Authentication Failures

Fix hard-coded secrets in C# and ASP.NET Core

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

Bind the key with AddOptions<T>().BindConfiguration("Forecast"), mark it [Required] and call ValidateDataAnnotations() and ValidateOnStart() so a missing or blank key stops the app at startup. Supply it as Forecast__ApiKey, user secrets or Azure Key Vault, never in a committed appsettings file, and send it in the Authorization header, not the URL.

Stack
  • Language C# 14
  • Runtime .NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
  • Framework ASP.NET Core 10 minimal API with the options pattern and IHttpClientFactory
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks3 cards
Download the project ZIP, 33 KB
On this page

Steps

  1. Find the keys: search the code, every appsettings*.json file and the history for credentials, and decode base64 strings next to key-like names. Revoke and replace every key you find at the provider before anything else; deleting the commit does not unpublish it.
  2. Bind the secret with the options pattern: AddOptions<ForecastOptions>().BindConfiguration("Forecast").ValidateDataAnnotations().ValidateOnStart(), with [Required] on ApiKey. [Required] rejects empty and whitespace-only strings.
  3. Supply the value from outside the repository: on a developer machine, dotnet user-secrets init and dotnet user-secrets set Forecast:ApiKey (loaded only in the Development environment); in production, the environment variable Forecast__ApiKey filled from the secret store, a key-per-file mount (builder.Configuration.AddKeyPerFile) or Azure Key Vault (builder.Configuration.AddAzureKeyVault, secret name Forecast--ApiKey).
  4. Keep only settings that are not secret in appsettings.json and appsettings.{Environment}.json.
  5. Set the key on the named HttpClient's DefaultRequestHeaders.Authorization, never in the query string.
  6. On partner failures log the status code or error type and return a generic 502; leave the developer exception page to the Development environment.
  7. Add regression tests like this card's: scan src/ for the key, start the app through WebApplicationFactory without it and expect OptionsValidationException, and record every log message during partner failures with an ILoggerProvider.

Vulnerable, fixed and wrong

The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.

The vulnerable code

Vulnerable…/ForecastService/impl/VulnerableForecast.csC#
private const string ForecastApiKey = "FAKE-forecast-key-for-tests-only";

public void AddSettings(IServiceCollection services)
{
    // Nothing to configure: the key is compiled in.
}

With IMPL=vulnerable, 6 of 11 tests fail (3 exploit tests).

The vulnerable code: the key in the URL and the log

Vulnerable…/ForecastService/impl/VulnerableForecast.csC#
public void AddPartnerClient(IServiceCollection services) =>
    services.AddHttpClient("partner", http => http.Timeout = TimeSpan.FromSeconds(5));

public void MapEndpoints(IEndpointRouteBuilder app) =>
    app.MapGet("/forecast", async (string? city, IConfiguration config, IHttpClientFactory clients, ILogger<VulnerableForecast> log) =>
    {
        if (string.IsNullOrWhiteSpace(city))
        {
            return Results.BadRequest(new { error = "city is required" });
        }
        var url = $"{config["Forecast:BaseUrl"]}/v1/forecast?city={Uri.EscapeDataString(city)}&api_key={ForecastApiKey}";
        try
        {
            var forecast = await clients.CreateClient("partner").GetFromJsonAsync<Forecast>(url);
            return Results.Ok(forecast! with { City = city });
        }
        catch (HttpRequestException e)
        {
            log.LogWarning(e, "GET {Url} failed", url);
            return Results.Json(new { error = e.Message }, statusCode: StatusCodes.Status502BadGateway);
        }
    });

With IMPL=vulnerable, 6 of 11 tests fail (3 exploit tests).

The fix

Fixedsrc/ForecastService/impl/FixedForecast.csC#
public sealed class ForecastOptions
{
    [Required(ErrorMessage = "Forecast:ApiKey is not set. Provide Forecast__ApiKey from your secret store.")]
    public string ApiKey { get; set; } = "";

    [Required]
    public Uri? BaseUrl { get; set; }
}

public void AddSettings(IServiceCollection services) =>
    services.AddOptions<ForecastOptions>()
        .BindConfiguration("Forecast")
        .ValidateDataAnnotations()
        .ValidateOnStart(); // a missing key stops the app at startup, not on the first request

With IMPL=fixed, 11 of 11 tests pass.

The fix: send the key in a header and keep errors generic

Fixedsrc/ForecastService/impl/FixedForecast.csC#
private const string Unavailable = "The forecast service is unavailable. Try again later.";

public void AddPartnerClient(IServiceCollection services) =>
    services.AddHttpClient("partner", (provider, http) =>
    {
        var options = provider.GetRequiredService<IOptions<ForecastOptions>>().Value;
        http.BaseAddress = options.BaseUrl;
        http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", options.ApiKey);
        http.Timeout = TimeSpan.FromSeconds(5);
    });

public void MapEndpoints(IEndpointRouteBuilder app) =>
    app.MapGet("/forecast", async (string? city, IHttpClientFactory clients, ILogger<FixedForecast> log) =>
    {
        if (string.IsNullOrWhiteSpace(city))
        {
            return Results.BadRequest(new { error = "city is required" });
        }
        try
        {
            var forecast = await clients.CreateClient("partner")
                .GetFromJsonAsync<Forecast>($"/v1/forecast?city={Uri.EscapeDataString(city)}");
            return Results.Ok(forecast! with { City = city });
        }
        catch (HttpRequestException e) when (e.StatusCode is { } status)
        {
            log.LogWarning("Forecast partner returned HTTP {Status}", (int)status);
        }
        catch (Exception e) when (e is HttpRequestException or TaskCanceledException)
        {
            log.LogWarning("Forecast partner unreachable: {Error}", e.GetType().Name);
        }
        return Results.Json(new { error = Unavailable }, statusCode: StatusCodes.Status502BadGateway);
    });

With IMPL=fixed, 11 of 11 tests pass.

A fix that looks safe but is not

Wrong fixsrc/ForecastService/appsettings.wrong.jsonJSON
// Moved out of the C# code "into configuration". This file is committed with the code and
// copied into the build and publish output, so every clone, build and image still has the key.
"Forecast": {
  "ApiKey": "FAKE-forecast-key-for-tests-only"
}

With IMPL=wrong, 2 of 11 tests fail (1 exploit test).

The regression test

The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.

Testtests/ForecastService.Tests/ExploitTests.csC#
public sealed class ExploitTests : IClassFixture<ForecastFixture>
{
    private readonly ForecastFixture _fixture;

    public ExploitTests(ForecastFixture fixture)
    {
        _fixture = fixture;
        fixture.Partner.Reset();
    }

    [Fact]
    public void TheKeyIsNotInAnyFileThatShipsWithTheApp()
    {
        var files = Secrets.ShippedFiles();
        Assert.Contains(files, file => Path.GetFileName(file).StartsWith(Secrets.Impl, StringComparison.OrdinalIgnoreCase));
        var leaks = files.Where(file => Secrets.RevealsKey(File.ReadAllText(file))).Select(file => Path.GetRelativePath(Secrets.Root, file));
        Assert.True(!leaks.Any(), $"the key can be read from: {string.Join(", ", leaks)}");
    }

    [Fact]
    public async Task TheKeyIsNotSentInTheUrlWhereProxiesAndAccessLogsKeepIt()
    {
        await _fixture.App.GetAsync("/forecast?city=Oslo");
        Assert.True(_fixture.Partner.Requests.Count == 1, "the app did not call the partner API");
        var url = Uri.UnescapeDataString(_fixture.Partner.Requests.Single().Url);
        Assert.False(Secrets.RevealsKey(url), $"the key is in the request URL: {Secrets.Redact(url)}");
    }

    [Fact]
    public async Task APartnerErrorDoesNotPutTheKeyInTheResponse()
    {
        _fixture.Partner.Outage = true;
        var (status, body) = await _fixture.App.GetAsync("/forecast?city=Oslo");
        Assert.False(Secrets.RevealsKey(body), $"the {status} response contains the key: {Secrets.Redact(body)}");
    }

    [Fact]
    public async Task AnUnreachablePartnerDoesNotPutTheKeyInTheResponse()
    {
        using var down = new ForecastAppFactory(ForecastAppFactory.Settings(Secrets.Key, PartnerStub.UnreachableUrl()));
        var (status, body) = await down.GetAsync("/forecast?city=Oslo");
        Assert.Equal(502, status);
        Assert.False(Secrets.RevealsKey(body), $"the 502 response contains the key: {Secrets.Redact(body)}");
    }

    [Fact]
    public async Task TheKeyIsNeverWrittenToTheLogs()
    {
        var verbose = ForecastAppFactory.Settings(Secrets.Key, _fixture.Partner.Url);
        verbose["Logging:LogLevel:Default"] = "Trace"; // every category at every level, HttpClient's included
        using var up = new ForecastAppFactory(verbose);
        using var down = new ForecastAppFactory(new Dictionary<string, string>(verbose) { ["Forecast:BaseUrl"] = PartnerStub.UnreachableUrl() });
        await up.GetAsync("/forecast?city=Oslo");
        _fixture.Partner.Outage = true;
        await up.GetAsync("/forecast?city=Oslo");
        await down.GetAsync("/forecast?city=Oslo");
        var logs = up.Logs.Text + "\n" + down.Logs.Text;
        Assert.False(Secrets.RevealsKey(logs), $"the logs contain the key:\n{Secrets.Redact(logs)}");
    }
}

Pitfalls

  • A key in appsettings.json or appsettings.Production.json is committed with the code and copied into the build and publish output; dotnet publish of this card's app copied appsettings.wrong.json next to the DLL. Environment variables override it, so the leak goes unnoticed in production.
  • A key in a const is stored in the compiled assembly; anyone with the DLL can read it.
  • Without ValidateOnStart(), options are validated on first use, so a missing key surfaces as a failed request instead of a failed start.
  • Secret Manager is for development only: it stores secrets unencrypted in a JSON file in the user profile, outside the project tree.
  • Since .NET 9, HttpClientFactory logs redact all header values and the query string by default; a RedactLoggedHeaders(_ => false) override or the System.Net.Http.DisableUriRedaction switch turns that off. Your own log messages are not redacted unless you add redaction yourself: the vulnerable implementation leaks the key through LogWarning(e, "GET {Url} failed", url).
  • HttpClient drops the Authorization header when a redirect leads to another origin but forwards custom headers such as X-Api-Key (checked on .NET 10.0.12).
  • A record holding the key prints it in ToString(); keep secrets in classes, as here, or override PrintMembers.

Verify the fix

  1. Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs hardcoded-secrets/csharp-aspnetcore does this for you).
  2. Start the app without the key and expect OptionsValidationException with "Forecast:ApiKey is not set" at startup.
  3. Scan the working tree and the publish output with a secret scanner, for example detect-secrets scan --all-files, and the git history with a scanner that reads it, such as gitleaks or TruffleHog; treat every hit as a key to rotate.

Scanner rules

What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.

Scanner rules for Fix hard-coded secrets in C# and ASP.NET Core
ToolRuleWhat it found here
Semgrepgeneric.secrets.security.detected-generic-api-key.detected-generic-api-keyNo findings on this card: the rule wants 32 to 45 letters and digits after a name like ApiKey, and the card's fake key contains hyphens. On a probe copy with a random 32-character key it flagged the constant in VulnerableForecast.cs but not appsettings.wrong.json, where a quote follows "ApiKey".Checked: Semgrep 1.180.0 (CE)
Semgrepgeneric.secrets.gitleaks.generic-api-key.generic-api-keyNo findings on this card, because its stopword list (fake, test) excludes the card's key. On the probe copy with a random key it flagged both the constant in VulnerableForecast.cs and appsettings.wrong.json.Checked: Semgrep 1.180.0 (CE)
CodeQLcs/hardcoded-credentialsNot expected to report anything: its sinks are parameters and properties whose names look like a password, user name or certificate (HardcodedCredentialsQuery.qll). A constant called ForecastApiKey that ends up in a URL reaches none of them.Checked: github/codeql main at b5b8165; not executed
CodeQLcs/cleartext-storage-of-sensitive-informationUncertain. Its sinks include the arguments of ILogger extension methods (ExternalLocationSink.qll), so the vulnerable LogWarning(e, "GET {Url} failed", url) is a sink; whether a const field named ForecastApiKey counts as a sensitive source was not established.Checked: github/codeql main at b5b8165; not executed
.NET code analysisCA5390Do not hard-code encryption keyNo security warnings for any implementation. The analyzers have no rule for hard-coded API keys or passwords; CA5390, off by default, only covers keys passed to symmetric encryption APIs.Checked: .NET SDK 10.0.107 with -p:AnalysisMode=All -p:AnalysisModeSecurity=All
detect-secrets (Yelp)KeywordDetectorSecret KeywordFlagged VulnerableForecast.cs line 14 and appsettings.wrong.json line 6 even with the card's fake key, because it keys on the name. Nothing in FixedForecast.cs.Checked: detect-secrets 1.5.0

Libraries and versions

Libraries and versions
ComponentVersion
LanguageC# 14
Runtime.NET 10.0.12 (SDK 10.0.112, ASP.NET Core runtime 10.0.12)
FrameworkASP.NET Core 10 minimal API with the options pattern and IHttpClientFactory
Microsoft.AspNetCore.App (shared framework: configuration, options validation, IHttpClientFactory)10.0.12 at run time
Microsoft.AspNetCore.Mvc.Testing10.0.12
xunit.v34.0.1 (Microsoft Testing Platform 2.4.0)

Run the tests

Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the hardcoded-secrets/csharp-aspnetcorefolder.

Shell
dotnet restore tests/ForecastService.Tests --locked-mode
Shell
# Expected: every test passes
IMPL=fixed dotnet test --project tests/ForecastService.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/ForecastService.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

# Expected: a test fails
IMPL=wrong dotnet test --project tests/ForecastService.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml

Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs hardcoded-secrets/csharp-aspnetcore runs all three modes and checks the expected results.

Download the project ZIP, 33 KB

Verified

Machine
macOS 27.0 (Darwin 27.0.0, arm64)
Toolchain
  • dotnetSdk 10.0.112
  • aspNetCoreRuntime 10.0.12
  • mvcTesting 10.0.12
  • xunitV3 4.0.1
  • microsoftTestingPlatform 2.4.0
Matrix
fixed: 11 of 11 tests pass; vulnerable: 6 of 11 tests fail (3 exploit tests); wrong: 2 of 11 tests fail (1 exploit test)
Test results
fixed 11/11 pass; vulnerable 6/11 fail (3 of the 5 exploit tests); wrong 2/11 fail (1 exploit test), offline after install
Dependency freshness
Microsoft.AspNetCore.Mvc.Testing 10.0.12 (2026-09-08) is the current 10.0.x patch. xunit.v3 4.0.1 (2026-09-12) is used rather than 4.0.2 (2026-10-09), which is a bug-fix release under 14 days old. dotnet list package --vulnerable --include-transitive found nothing. Rerun with SDK 10.0.112 and runtime 10.0.12, the September 2026 security patch.

Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.

References

  1. OWASP Secrets Management Cheat Sheet
  2. Safe storage of app secrets in development in ASP.NET Core (Secret Manager, environment variables)
  3. Options pattern in .NET: options validation and ValidateOnStart
  4. Configuration in ASP.NET Core: key-per-file provider and source order
  5. Azure Key Vault configuration provider in ASP.NET Core
  6. .NET 9 breaking change: HttpClientFactory logging redacts header values by default
  7. .NET 9 breaking change: URI query redaction in HttpClientFactory logs
  8. GitHub Docs: Removing sensitive data from a repository

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.