mise and Infisical for .NET: One file for tools, zero files for secrets
Onboarding onto a .NET service usually goes like this: install the right SDK, ask someone for the connection string, paste it into dotnet user-secrets, discover the Stripe key is stale, ask again. CI does its own version of the same dance with a different set of variables.
Two tools remove most of that:
- mise pins the toolchain (the .NET SDK and the Infisical CLI itself) and holds the tasks everyone runs.
- Infisical holds the secrets and injects them into the process as environment variables when it starts. Nothing is written to disk.
.NET needs no adapter for either. The default host already reads environment variables into IConfiguration.
The sample
The example is a small Orders API: an ASP.NET Core app that needs three secrets, a database connection string and two Stripe keys, plus one integration test.
To follow along you need an Infisical instance. Infisical is open source, so this post runs its own in Docker: three containers, no account anywhere. Already on Infisical Cloud? Skip to Create the project and use your Cloud URL wherever this post says http://localhost:18080.
Running Infisical locally
Save this as infisical/compose.yml in your repo. It pins the same Infisical version the output below came from, with Postgres and Redis next to it:
# A throwaway self-hosted Infisical for following along. Not a production deployment: no TLS,
# no SMTP, and the two keys it needs come from .env next to this file.
name: mise-infisical-sample
services:
backend:
image: infisical/infisical:v0.165.12
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
env_file: .env
environment:
NODE_ENV: production
DB_CONNECTION_URI: postgres://infisical:infisical@db:5432/infisical
REDIS_URL: redis://redis:6379
SITE_URL: http://localhost:18080
ports:
- "18080:8080"
redis:
image: redis:7-alpine
restart: unless-stopped
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: infisical
POSTGRES_PASSWORD: infisical
POSTGRES_DB: infisical
volumes:
- pg_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U infisical"]
interval: 5s
timeout: 5s
retries: 20
volumes:
pg_data:
Infisical refuses to start without an encryption key and an auth secret. Generate both into infisical/.env, which the compose file reads, and start it:
printf 'ENCRYPTION_KEY=%s\nAUTH_SECRET=%s\n' "$(openssl rand -hex 16)" "$(openssl rand -base64 32)" > infisical/.env
docker compose -f infisical/compose.yml up -d --wait
The first run pulls about 2 GB of images. After that, --wait returns once all three containers report healthy, in about 15 seconds; on the very first start the API is still running database migrations at that point, so give it a few more seconds. Then open http://localhost:18080 and create the first account, which becomes the instance’s admin, along with an organization. Scripting this instead? infisical bootstrap --domain=http://localhost:18080 --email=... --password=... --organization=... does the same from the CLI you install in the next section.
Keep infisical/.env out of git, and do not lose it either: ENCRYPTION_KEY is what decrypts the secrets stored in Postgres, so without it they cannot be recovered.
This is a sandbox for following along: no TLS and no SMTP, so teammates cannot be invited by email. For a real team, use Infisical Cloud or follow the self-hosting guide.
Create the project
In the dashboard at http://localhost:18080 (or on Cloud):
- Create a project. It comes with
dev,stagingandprodenvironments. - Add three secrets to
devand tostaging:ConnectionStrings__Orders,Stripe__ApiKeyandStripe__WebhookSecret. Any values will do, except that the test below expects the Stripe key to start withsk_test_. The double underscores are explained further down. - Copy the project id from the project settings. It goes into
mise.tomlin the next section.
Versions: mise 2026.8, Infisical CLI 0.43.132, .NET SDK 10.0.401. The output in this post comes from the local Infisical v0.165.12 above, with the project set up exactly as described. Your values will differ, which does not matter: the app only ever prints their lengths.
Pin the toolchain
Everything the repo needs lives in one mise.toml at the root:
[tools]
dotnet = "10.0.401"
infisical = "0.43.132"
[env]
ASPNETCORE_ENVIRONMENT = "Development"
DOTNET_CLI_TELEMETRY_OPTOUT = "1"
# Not secrets: they say where the project is, not how to get in. The CLI reads both.
# The local instance from infisical/compose.yml. Infisical Cloud: https://app.infisical.com (US)
# or https://eu.infisical.com (EU).
INFISICAL_DOMAIN = "http://localhost:18080"
INFISICAL_PROJECT_ID = "00000000-0000-0000-0000-000000000000"
SECRETS_ENV = "dev"
dotnet is a core mise plugin that uses Microsoft’s install script. infisical resolves to the GitHub releases of Infisical/cli, and mise verifies the artifact attestations on download.
A new developer runs mise trust once (mise will not load env or tasks from a config it has not been told to trust), then mise install, and has both tools at exactly these versions.
NOTE
mise is a generic tool. The same [tools] table pins Node, pnpm, Python, Go or Terraform next to the SDK, so a repo with a frontend beside the API still has one file that says which versions it needs, and nobody installs Node from a README. Tasks cross languages too: a task can depend on the API’s dev and the frontend’s dev, and mise runs both in parallel, prefixing each line with the task that wrote it. In a monorepo (monorepo_root = true at the root), a web/mise.toml can pin its own node and pnpm while inheriting the root’s [env], Infisical settings included, so the frontend’s tasks wrap infisical run exactly like the API’s. What changes is what is safe to hand the process: anything a frontend build reads, such as Vite’s VITE_ variables, ends up in the bundle every browser downloads, so give it only values meant to be public.
Where the Infisical CLI fits
The app never talks to Infisical. The infisical binary that mise just installed does, on the app’s behalf, and this post needs it for two things.
Once per machine, infisical login. It opens the browser, you sign in to the Infisical instance at INFISICAL_DOMAIN, and the CLI keeps the session in the OS keychain. Run it through mise, so it is the pinned CLI and it sees the [env] above:
mise exec -- infisical login
With mise activated in your shell, plain infisical login is the same thing.
Every time the app starts, infisical run. infisical run --env=dev -- <command> uses that session to fetch the dev secrets of the project in INFISICAL_PROJECT_ID, starts <command> with those secrets added to its environment variables, and exits when the command does. Nothing is written to disk; the values exist only in that one process’s environment.
For the check task later in this post, the chain looks like this:
mise run check
mise sets INFISICAL_DOMAIN, INFISICAL_PROJECT_ID and SECRETS_ENV, then runs:
infisical run --env=dev -- dotnet run ...
fetches the dev secrets from Infisical, as you
starts dotnet run with them as environment variables
the default .NET host reads them into IConfiguration
You never type infisical run yourself: every task below wraps its command in it.
The two settings in mise.toml are what the CLI needs to find your secrets:
INFISICAL_DOMAINsays which Infisical to talk to: here, the local instance. On Infisical Cloud it ishttps://app.infisical.com(US, and the CLI’s default when the variable is unset) orhttps://eu.infisical.com(EU).INFISICAL_PROJECT_IDsays which project, and saves passing--projectIdon every command.
Neither grants access: that comes from the login session, or in CI from a machine identity’s token. So both belong in the committed file.
Name secrets the way .NET reads them
The environment variable provider turns __ into the : section separator. So the secret names in Infisical are just configuration paths:
| Infisical secret | .NET configuration key |
|---|---|
ConnectionStrings__Orders | ConnectionStrings:Orders |
Stripe__ApiKey | Stripe:ApiKey |
Stripe__WebhookSecret | Stripe:WebhookSecret |
The application code does not know Infisical exists. It binds options as usual:
public sealed class StripeOptions
{
public string ApiKey { get; set; } = "";
public string WebhookSecret { get; set; } = "";
}
builder.Services
.AddOptions<StripeOptions>()
.Bind(builder.Configuration.GetSection("Stripe"))
.Validate(o => o.ApiKey.Length > 0, "Stripe:ApiKey is empty. Run through `mise run dev`.")
.ValidateOnStart();
ValidateOnStart is the part worth copying. Someone who runs dotnet run directly, outside the tasks below, gets a clear failure at boot instead of a 401 from Stripe on the first checkout:
Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException: Stripe:ApiKey is empty. Run through `mise run dev`.
Wrap every entry point in a task
Add the tasks to the same mise.toml:
[tasks.build]
run = "dotnet build --nologo -v q"
[tasks.dev]
description = "Run the API with secrets injected from Infisical"
run = "infisical run --env={{env.SECRETS_ENV}} --watch -- dotnet run --project src/Orders.Api"
[tasks.test]
description = "Run the tests with secrets injected from Infisical"
depends = ["build"]
run = "infisical run --env={{env.SECRETS_ENV}} --silent -- dotnet test --no-build"
[tasks.check]
description = "Report which settings resolved, without printing them"
depends = ["build"]
run = "infisical run --env={{env.SECRETS_ENV}} --silent -- dotnet run --project src/Orders.Api --no-build -- --check"
Each task hands its command to infisical run, which fetches the secrets for the chosen environment, sets them on the child process, and starts it. mise run dev starts the API that way, and its --watch flag makes the CLI poll Infisical and restart the app when a secret changes.
{{env.SECRETS_ENV}} is a mise template, rendered by mise before any shell sees the command. $SECRETS_ENV would depend on the shell the task runs under, and that shell differs on Windows.
The check task is a cheap sanity probe. The app, started with --check, prints whether each setting resolved and how long it is, never the value:
var settings = new[] { "ConnectionStrings", "Stripe" }
.SelectMany(section => builder.Configuration.GetSection(section).GetChildren());
foreach (var setting in settings)
{
var value = setting.Value;
Console.WriteLine($"{setting.Path,-26} {(string.IsNullOrEmpty(value) ? "missing" : $"set ({value.Length} chars)")}");
}
INF Injecting 3 Infisical secrets into your application process
ConnectionStrings:Orders set (65 chars)
Stripe:ApiKey set (36 chars)
Stripe:WebhookSecret set (15 chars)
Tests go through the same door. An integration test hosting the app with WebApplicationFactory sees exactly what the running app sees:
public class StartupTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Boots_with_secrets_from_the_environment()
{
var client = factory.CreateClient();
Assert.Equal("orders api", await client.GetStringAsync("/", TestContext.Current.CancellationToken));
Assert.StartsWith("sk_test_", factory.Services.GetRequiredService<IOptions<StripeOptions>>().Value.ApiKey);
}
}
INF Injecting 3 Infisical secrets into your application process
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1 - Orders.Api.Tests.dll (net10.0)
Switch environments with MISE_ENV
Pointing at staging secrets is tempting to do as SECRETS_ENV=staging mise run check. That does nothing. Values in mise.toml win over the calling shell, so the task still pulls dev.
Use a mise config environment instead. Add mise.staging.toml next to the main file:
[env]
SECRETS_ENV = "staging"
MISE_ENV=staging layers it on top. Same three keys, different values (compare the lengths with the dev run above):
INF Injecting 3 Infisical secrets into your application process
ConnectionStrings:Orders set (75 chars)
Stripe:ApiKey set (32 chars)
Stripe:WebhookSecret set (13 chars)
CI: the same task, a machine identity
In CI there is no browser to log in with. Create a machine identity with Universal Auth, and keep it narrow: viewer role on this one project, a short access token lifetime (one hour here). Store its client id and client secret in the CI secret store. Those two values are the only secrets CI holds directly.
The runner also has to reach Infisical, and a GitHub-hosted runner cannot reach localhost on your laptop. In a real repository INFISICAL_DOMAIN in mise.toml names the team’s Cloud or self-hosted instance, and CI picks it up from there like everything else. The output below comes from running the same steps on the machine that hosts the local instance.
The job’s steps in GitHub Actions:
- uses: actions/checkout@v5
- uses: jdx/mise-action@v4
- name: Test
env:
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}
MISE_ENV: staging
run: |
export INFISICAL_TOKEN=$(mise exec -- infisical login --method=universal-auth --silent --plain)
mise run test
infisical login --plain prints only the access token, and every later infisical command picks up INFISICAL_TOKEN automatically. mise-action installs the same pinned SDK and CLI the laptops use. The Test step’s script, run as written with that identity’s credentials:
INF Injecting 3 Infisical secrets into your application process
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1 - Orders.Api.Tests.dll (net10.0)
This is where INFISICAL_PROJECT_ID earns its place. infisical init writes a .infisical.json holding the project id, but a machine identity does not use it. Given only that file:
Project ID is required when using machine identity
With the project id in mise.toml, laptops and CI share one set of task definitions, and you can skip .infisical.json entirely.
What you end up with
- One file to read.
mise.tomlsays which SDK, which CLI, which environments, and how to run, test and check the app. - No secrets on disk. No
secrets.jsonunder your profile, no.envto accidentally commit. - One way in. Laptops and CI run the same tasks with the same pinned tools; only the login differs.
- The app stays plain .NET. Remove Infisical tomorrow and
IConfigurationstill reads whatever sets those environment variables.
A caveat on that last point: environment variables are visible to anything that can inspect the process, such as a crash dump or a careless env in a log. That is the same exposure as any other env-based configuration, and fine for development and CI. For production, prefer the platform’s native secret delivery or Infisical’s Kubernetes operator or agent.