Skip to main content
ANVISoftware Solutions
Lesson 9 of 14Intermediate18 min

Managing Secrets

By the end of this lesson

Keep credentials out of source control and inject them safely.

A secret is any value that grants access: a database password, an API key, a signing key, a client secret, a storage account key, a token for a partner's service. It is the part of your configuration that you cannot show to anyone.

Everything else about configuration is ordinary. A page size, a feature flag, a timeout, a base URL — those belong in appsettings.json, in the repository, reviewed alongside the code that reads them. Secrets are the exception, and they need somewhere else to live.

The orders application has four of them in a typical deployment: the database connection string, the signing key for its tokens, the API key for the courier's tracking service, and the credential for the storage account holding invoice PDFs. This lesson is about where each one lives, how it reaches the running process, and what to do on the day one of them ends up somewhere public.

Five places a secret can live, from worst to best. The list is ordered on purpose, because the argument for each one is that it removes a weakness of the one above:

In a file in the repository
A password in appsettings.json, a key in a test fixture, a token in a deployment script. Everyone with read access to the repository has it, including everyone who forked or cloned it, and every build agent that ever checked it out. It is also permanent: see the callout below. There is no version of this that is acceptable, including a value you intend to replace later.
In user secrets, for local development only
The .NET SDK stores values in your own user profile, outside the working tree, keyed by an id in the project file. Nothing to add to .gitignore and nothing to commit by accident, because the file is not in the repository at all. It is not encrypted, so treat it as a way to keep secrets out of git rather than as protected storage, and never use it for production values.
In environment variables
The standard way to hand configuration to a container, and a real improvement on a committed file. The limit is visibility: the value is readable by anything that can inspect the process or read the deployment specification, it appears in diagnostic dumps and in a shell opened on the container, and child processes inherit it. There is also no rotation story and no record of who read it.
In a managed secret store
A dedicated service holding the value, with access granted per identity, an audit trail of every read, versioning, and rotation you can perform without a deployment. The application fetches the value at startup or on demand. This is the right home for a secret that has to exist as a value — a third party's API key, for instance, because they issued it and you cannot change how it works.
Nowhere, because there is no secret
A managed identity or workload identity means the platform gives the running process a short-lived token for its own identity, and your code presents that token to the database or storage account. No password is generated, stored, configured, rotated or leaked, because none exists. Where the target service supports it, this is the option to reach for first — it removes the problem rather than managing it.

A secret has been found in the repository. What to do, in this order:

  1. Rotate the credential

    Generate a replacement, put it in the proper store, deploy it, and then invalidate the old value. Rotate first and investigate afterwards: every minute spent working out how it got there is a minute the old credential still works. If rotation will cause an outage, say so and plan it, but keep it as the first item rather than the last.

  2. Work out what the credential could reach, and for how long

    Which systems accepted it, what rights it carried, and when it was first committed. If the target service keeps an access log, read it for the period the value was exposed. This is also the moment you discover whether the credential had far more permission than the application used, which is its own finding.

  3. Remove the value from the working tree and configure it properly

    Take it out of the file, add the setting name with no value or with a placeholder so the shape of the configuration is still documented, and put the real value in user secrets for development and the secret store for everything else. Make the application fail at startup when the setting is missing, so a misconfigured deployment stops rather than falling back to something.

  4. Decide about history deliberately

    Rewriting history is reasonable for a private repository with few clones, and it is disruptive: every commit id changes, open branches and pull requests need rebasing, and anyone holding a copy keeps the old one. Weigh that against the benefit, which is only that the dead value is harder to read. If the repository was ever public, assume the value was indexed and treat rewriting as cosmetic.

  5. Add scanning so the next one is caught before it lands

    A pre-commit hook that scans staged changes stops most of these at the point they are created, when the fix costs nothing. Run the same scan in the pipeline, because a hook only runs on machines where someone installed it. Both together: the hook for speed, the pipeline for coverage.

  6. Write down what happened

    A short note: which credential, when it was committed, when it was rotated, what the access logs showed. It takes ten minutes and it is the only thing that answers the same question next year. It also makes the case for scanning far better than an argument in the abstract.

Local development, and catching a secret before it lands
Shell
# One-off per project: records a UserSecretsId in the .csproj file.
# The values themselves are stored in your own user profile, not in the repo.
dotnet user-secrets init --project src/Orders.Api

# Set a value. The key uses the same colon-separated path as appsettings.json,
# so the application reads it with no code change.
dotnet user-secrets set "ConnectionStrings:Orders" "Server=localhost;Database=Orders;Trusted_Connection=True" --project src/Orders.Api
dotnet user-secrets set "Courier:ApiKey" "REPLACE_WITH_YOUR_OWN_DEV_KEY" --project src/Orders.Api

# See what is set, without going looking for the file.
dotnet user-secrets list --project src/Orders.Api

# Scan staged changes before the commit is created. Wire this into a
# pre-commit hook so it runs without anyone remembering to.
gitleaks protect --staged --redact

# Scan the whole history once, to find what is already there.
gitleaks detect --redact
  • user-secrets init adds an id to the project file. The id is not a secret and is meant to be committed; it is the key under which your machine stores the values.
  • The key path matters more than it looks. ConnectionStrings:Orders is the same configuration key that appsettings.json would use, so the application reads it through the ordinary configuration system and has no idea where the value came from. That is the property you want: nothing in your code is development-specific.
  • User secrets are added to the configuration by the default host builder in the Development environment only. In any other environment the provider is not registered, so a value set this way on a server would be ignored — which is the correct behaviour, and worth knowing before you spend an afternoon on it.
  • The values are stored as plain JSON in your profile. That keeps them out of git, which is the goal here. It does not protect them from anything with access to your machine, so development credentials should be development credentials, with no reach into a shared system.
  • The placeholder for the courier key is deliberately obvious. Never put a real value in a command you might paste into a ticket, a wiki page or a chat message — shell history is also a file somebody can read.
  • gitleaks is one of several scanners; your hosting platform probably offers push protection as well, and using both is reasonable. The important part is not which tool, it is that the check runs automatically. A scan somebody has to remember is a scan that stops happening in the week you are busy.
  • Run the staged scan in a pre-commit hook and the full scan in the pipeline. Hooks are local and can be skipped; the pipeline cannot. Expect a few false positives on test fixtures and sample data, and handle them with a documented allow list rather than by switching the scan off.
Program.cs — reading secrets, and removing one altogether
C#
var builder = WebApplication.CreateBuilder(args);

// FLAWED SHAPE. A fallback that contains a working credential is how a
// misconfigured deployment silently connects to the wrong database — or how
// the same development password ends up in production. Never write this.
// var connection = builder.Configuration.GetConnectionString("Orders")
//                  ?? "Server=dev-sql;Database=Orders;User Id=sa;Password=Passw0rd!";

// CORRECT. Missing configuration stops the application at startup, loudly,
// before it serves a single request.
var ordersConnection = builder.Configuration.GetConnectionString("Orders")
    ?? throw new InvalidOperationException(
        "ConnectionStrings:Orders is not configured. Set it with dotnet user-secrets locally, "
        + "or in this environment's secret store.");

builder.Services.AddDbContext<OrdersDbContext>(options =>
    options.UseSqlServer(ordersConnection));

// The courier issued this key, so it has to exist as a value. It belongs in
// the secret store, bound to a typed options class like any other setting.
builder.Services
    .AddOptions<CourierOptions>()
    .Bind(builder.Configuration.GetSection("Courier"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

// No credential at all. The platform hands this process a short-lived token
// for its own identity, so there is no stored value to leak or rotate.
builder.Services.AddSingleton(new BlobServiceClient(
    new Uri("https://ordersfiles.example-company.com"),
    new DefaultAzureCredential()));

var app = builder.Build();
  • The commented-out fallback is here to be recognised, not used. Search your own configuration code for the null-coalescing operator next to a credential: it is a quiet way for a development value to become the production value on the day an environment variable is misspelled.
  • Throwing at startup is the point of the corrected version. A missing secret is a deployment fault, and the useful behaviour is to fail immediately with a message naming the setting, rather than to start and then fail on the first request that touches the database.
  • ValidateOnStart does the same job for the options class. Without it, a missing or malformed courier key surfaces the first time someone tracks a delivery, which may be hours after the deploy and in front of a customer.
  • Nothing in this file says where the values come from. Configuration providers are layered by the host — appsettings.json, then the environment-specific file, then user secrets in development, then environment variables, then whatever you add for the secret store — and a later provider wins. Your code reads a key and stays out of it.
  • The storage client takes a credential object rather than a key. The platform issues a short-lived token for the identity assigned to this container, the SDK refreshes it, and no secret is configured anywhere. DefaultAzureCredential is one cloud's implementation of the idea; workload identity on Kubernetes and instance roles elsewhere are the same pattern, and Microsoft Entra authentication does it for SQL Server too.
  • That last point is worth taking further than it first appears. Every secret you remove is one you never have to store, rotate, scan for or explain. Before designing where a credential will live, check whether the service it is for supports an identity-based connection, because that removes the work rather than organising it.
  • One honest cost: identity-based access is harder to set up the first time, and harder to run locally, since your laptop is not the deployed workload. Development usually falls back to your own signed-in identity, which means the local and deployed paths differ slightly. That is a real trade against not holding a credential, and it is a trade worth making.

Summary

  • A secret is any configuration value that grants access; everything else belongs in the repository as ordinary settings
  • A secret committed once is in the history permanently, so the response is rotation, not deletion of the line
  • User secrets keep development values out of git; a managed secret store is the home for production values
  • Environment variables transport a secret but do not protect it, and no store beats having no credential at all
  • Fail at startup when a secret is missing, and scan staged changes so the next one never reaches a commit

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Think about it

The colleague who deleted the line

A colleague notices that a storage account key was committed in appsettings.json eight months ago. They remove the line, commit the change with the message 'remove hard-coded key', and tell you it is dealt with.

Is it? Say what you would do, in order, and what you would tell them about the commit they made.

Show solution

It is not dealt with. The key is still in the repository — the earlier commit contains it, and any clone, fork, build agent cache or mirror has it too. Their new commit changed the current file and nothing about the exposure.

Rotate the key first. Issue a new one, put it in the secret store, deploy, then invalidate the old one. Until that is done, the value in history is a working credential; after it, the same value is inert and the incident is closed.

Then read the storage account's access log for the eight months it was exposed, and check what the key could reach. A storage account key is usually far broader than one application needs, so this step often produces a second piece of work: replacing the key with a scoped identity so the next exposure is smaller.

About their commit: the message is the problem, not the change. 'Remove hard-coded key' announces to anyone reading the history that there is a key a few commits back, and points at the file. It is a reasonable thing to do after rotation and a poor thing to do before it. Rotate, then tidy up, and keep the commit message plain.

Finally, add scanning. This one was found by chance after eight months. A staged-changes scan in a pre-commit hook plus the same scan in the pipeline would have caught it in the first minute, and that is the difference worth arguing for.

Try it yourself

Place the four secrets

The orders application needs: a database connection string, a signing key for its own tokens, the courier's API key, and access to the storage account holding invoice PDFs.

For each one, say where the value lives for local development and for production, and which of the four you could remove entirely. Then say what the application should do at startup if any of them is missing.

Show solution

Database connection string: user secrets locally, and in production no password at all — use Entra or your platform's equivalent so the API connects as its own identity. That removes a secret rather than storing one, and it also removes the rotation work.

Signing key: user secrets locally, secret store in production, with a deliberate rotation plan, because you issue the tokens and you control the key's lifetime. Two keys live at once during a rotation so tokens signed with the outgoing key still validate until they expire.

Courier API key: user secrets locally with a sandbox key, secret store in production. This one cannot be removed, because a third party issued it and decided how it works. It is the clearest example of a secret that genuinely has to exist as a value.

Storage account: removable. Use a managed identity with rights on that one container rather than an account key that opens everything in the account. Two secrets gone, from four.

On startup, every one of them should be validated and the application should refuse to start if something is missing, with a message naming the setting. The tempting alternative — start and fail later — turns a deployment fault into an intermittent runtime error that looks like something else entirely. Fail at boot, where the person who caused it is still watching.

There is a judgement call worth naming. Failing hard at startup means a single misconfigured setting stops the whole application, including the parts that do not need that setting. That is usually right for a credential, because running half-configured is worse than not running. For a genuinely optional integration, a feature flag that disables the feature and logs the reason is a defensible alternative.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

A database password was committed six months ago and removed from the file in a later commit. What is the first thing to do?
Why are environment variables not a complete answer for production secrets?

Saved in this browser only.