DEV Community

Martin Oehlert
Martin Oehlert

Posted on

Configuration Done Right: Settings, Secrets, and Key Vault

Azure Functions for .NET Developers: Series


You add a Service Bus connection string to appsettings.json. You deploy. The trigger fails at startup with something like:

Microsoft.Azure.WebJobs.Host.Listeners.FunctionListenerException:
The listener for function 'ProcessMessage' was unable to start.
Microsoft.Azure.WebJobs.ServiceBus.Listeners.ServiceBusListener:
Connection string not found.
Enter fullscreen mode Exit fullscreen mode

Your code reads it fine. But the trigger cannot start because the host never sees appsettings.json.

Azure Functions has two distinct configuration surfaces that solve different problems:

  • The host process (func.exe) resolves trigger and binding connections from environment variables.
  • The worker process (your .NET code) reads IConfiguration, which includes appsettings.json, environment variables, and any other sources you add.

These two processes share environment variables but nothing else. This article covers how to configure each correctly, how to move secrets to Key Vault without changing your code, and how to use the options pattern for strongly-typed settings.

Azure Functions configuration flow diagram showing local.settings.json and Azure App Settings flowing through environment variables to the host and worker processes, with Key Vault references resolved at startup

All code from this article is available in the azure-functions-samples repository under ConfigurationDemo/.


local.settings.json

In local development, local.settings.json is how you supply environment variables to both processes simultaneously. Core Tools (func.exe) reads this file at startup and injects every entry in the Values section as a process environment variable before launching the worker.

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "ServiceBusConnection": "Endpoint=sb://...",
    "Api__BaseUrl": "https://api.example.com",
    "Api__TimeoutSeconds": "30"
  }
}
Enter fullscreen mode Exit fullscreen mode

Everything in Values is a flat string pair, no nesting. For hierarchical settings, use double underscore as the separator: Api__BaseUrl becomes Api:BaseUrl in IConfiguration. All values are strings; the configuration binder converts them to the correct type when binding to options classes.

The ConnectionStrings section is a trap. It exists for ORMs like Entity Framework that expect connection strings under ConnectionStrings:*. Core Tools loads these with a ConnectionStrings: prefix — so ConnectionStrings.MyDb becomes the environment variable ConnectionStrings:MyDb. The Functions host looks for binding connections by their bare name. Put a Service Bus or Storage connection string in ConnectionStrings and the trigger cannot find it, even though your application code can read it fine with configuration.GetConnectionString("MyDb").

The rule: trigger and binding connections go in Values, always.

Azure ignores this file entirely. It is consumed only by Core Tools locally. When you deploy, you configure settings separately in the portal, via CLI, or in Bicep. If you use func azure functionapp publish --publish-local-settings, only the Values section is copied. The ConnectionStrings section is never published — another reason to avoid it.

Add local.settings.json to .gitignore. The Functions project template does this automatically, but verify it before your first commit. This file will contain connection strings, API keys, and storage credentials.


Azure App Settings

In Azure, every Application Setting is an environment variable injected into the host process. The Functions runtime treats them identically to what local.settings.json provides locally.

The portal lists them under Settings > Environment Variables > App settings. Values are encrypted at rest and masked in the UI. Any change to Application Settings causes the function app to restart.

Use the Application Settings blade, not the Connection strings blade. The Connection strings section in the portal adds a type prefix to the environment variable name. A custom connection string named ServiceBus becomes CUSTOMCONNSTR_ServiceBus in the environment. The Service Bus trigger looking for ServiceBus will not find it.

The Connection strings portal section exists for ASP.NET compatibility. For Azure Functions, put everything in Application Settings.

Hierarchical settings

Flat environment variables do not support nesting. The convention on Azure (which runs on Linux) is to use double underscore as the hierarchy separator:

App Setting name IConfiguration key
Api__BaseUrl Api:BaseUrl
Api__TimeoutSeconds Api:TimeoutSeconds

Colon (:) works as a separator on Windows only. Double underscore works on both platforms. Always use __ in App Setting names to ensure your functions run correctly whether the app is on a Windows or Linux hosting plan.

Slot settings

Deployment slots each have their own App Settings. By default, settings swap along with the code when you swap slots. Slot settings (sticky settings) stay with the slot and do not swap.

The common use case: staging and production connect to different databases or service bus namespaces. Mark those connection strings as slot settings so a staging deployment cannot accidentally point production code at the staging database.

To mark a setting as sticky, check Deployment slot setting when editing it in the portal. In Bicep, set "slotSetting": true on the app setting object.

One gotcha: a sticky setting must exist in every slot involved in a swap. If a sticky setting exists in staging but not in production, and you swap, the setting disappears from staging after the swap. Create the setting (with the appropriate value for each environment) in every slot before you start using sticky settings.


Key Vault references

Key Vault references let you store secrets in Azure Key Vault and reference them from App Settings. The Functions host resolves the reference at startup. Your code reads the setting with the same IConfiguration["MyKey"] call it has always used.

The reference syntax in the App Setting value:

@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/ApiKey/)
Enter fullscreen mode Exit fullscreen mode

Or by name, if the Key Vault is in the same subscription:

@Microsoft.KeyVault(VaultName=myvault;SecretName=ApiKey)
Enter fullscreen mode Exit fullscreen mode

Both forms produce identical behavior at runtime. Omit the secret version from the URI to always get the latest version. The platform caches the resolved value and re-fetches it every 24 hours. Rotating a secret takes effect automatically within that window without a deployment or restart.

Failed references are silent. If the reference cannot be resolved (wrong vault name, missing permissions, deleted secret), the App Setting receives the literal reference string as its value: @Microsoft.KeyVault(...). This propagates through to your code, which typically throws because it receives an unexpected format instead of the secret value. To diagnose: open the setting in the portal and look for an error status indicator in the edit dialog. In the Azure portal, Platform features > Diagnose and solve problems also has a Key Vault Application Settings Diagnostics detector.

Managed identity setup

The function app needs permission to read secrets from the vault. The recommended approach is a system-assigned managed identity.

Enable it in the portal under Settings > Identity > System assigned > Status: On. Via CLI:

az webapp identity assign \
  --resource-group <rg> \
  --name <app-name>
Enter fullscreen mode Exit fullscreen mode

Then assign the Key Vault Secrets User role to the identity on the vault:

az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee "<principalId>" \
  --scope "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault-name>"
Enter fullscreen mode Exit fullscreen mode

The --assignee value is the principalId from the identity assignment output, not the client ID and not the app's resource ID.

Key Vault Secrets User, not Contributor. The Contributor role manages the vault as an Azure resource (creating, deleting, modifying it). It does not grant access to read secret values. Key Vault Secrets User grants data-plane read access to secrets. These are separate role planes and are frequently confused.

Creating a vault with RBAC enabled

az keyvault create \
  --name "<vault-name>" \
  --resource-group "<rg>" \
  --location "eastus" \
  --enable-rbac-authorization true
Enter fullscreen mode Exit fullscreen mode

The --enable-rbac-authorization true flag is important. Without it, the vault uses the legacy access policy model. Microsoft's current guidance is to use RBAC for all new vaults. The access policy model has a privilege escalation risk: any user with Contributor on the vault can modify access policies to grant themselves secret access. Under RBAC, only Owner and User Access Administrator can modify role assignments.

Once the vault exists, add a secret:

az keyvault secret set \
  --vault-name "<vault-name>" \
  --name "ApiKey" \
  --value "<your-secret>"
Enter fullscreen mode Exit fullscreen mode

Local development

Key Vault references are a portal-level feature. They do not apply locally. For local development, put the actual secret values directly in local.settings.json:

{
  "Values": {
    "ApiKey": "dev-key-here"
  }
}
Enter fullscreen mode Exit fullscreen mode

The app code does not change. The same configuration["ApiKey"] call works locally (reading from the environment variable injected by Core Tools) and in Azure (reading from the Key Vault reference resolved by the platform).

If your team needs local access to the actual Key Vault for testing, use DefaultAzureCredential in your service registration and run az login with an account that has Key Vault Secrets User on the vault. The credential chain tries Azure CLI authentication, so the logged-in developer account gets used automatically.


Strongly-typed configuration with the options pattern

Reading configuration with configuration["MyKey"] works but gives you a stringly-typed API with no validation and no structure. The options pattern solves this by binding a configuration section to a typed class.

Define the options class:

public class ApiOptions
{
    [Required]
    public required string BaseUrl { get; init; }

    [Range(1, 300)]
    public int TimeoutSeconds { get; init; } = 30;
}
Enter fullscreen mode Exit fullscreen mode

Register and validate it in Program.cs:

var builder = FunctionsApplication.CreateBuilder(args);

builder.Services
    .AddOptions<ApiOptions>()
    .BindConfiguration("Api")
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Build().Run();
Enter fullscreen mode Exit fullscreen mode

BindConfiguration("Api") binds from the Api section of IConfiguration. After GetSection strips the prefix, ApiOptions.BaseUrl maps to the Api:BaseUrl key, which in turn comes from the Api__BaseUrl environment variable. The same property name works whether the setting originates from appsettings.json, local.settings.json, or an Azure App Setting.

Inject the options into a function class:

public class ProcessOrderFunction(IOptions<ApiOptions> options)
{
    private readonly ApiOptions _api = options.Value;

    [Function("ProcessOrder")]
    public async Task<IActionResult> Run(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequest req)
    {
        // _api.BaseUrl and _api.TimeoutSeconds are validated and available
        using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(_api.TimeoutSeconds) };
        var response = await client.GetAsync($"{_api.BaseUrl}/orders");
        return new OkResult();
    }
}
Enter fullscreen mode Exit fullscreen mode

ValidateOnStart

Without ValidateOnStart(), validation fires when .Value is first accessed. A misconfigured but rarely-invoked function can pass through a cold start and fail only at runtime, when you least expect it. With ValidateOnStart(), the host throws OptionsValidationException during startup and refuses to run:

Microsoft.Extensions.Options.OptionsValidationException:
  DataAnnotation validation failed for 'ApiOptions' members:
    'BaseUrl' with the error: 'The BaseUrl field is required.'
Enter fullscreen mode Exit fullscreen mode

This converts a silent runtime failure into an explicit startup failure, which is much easier to diagnose.

IOptions vs IOptionsSnapshot vs IOptionsMonitor

Use IOptions<T> in Azure Functions. The value is cached per singleton lifetime, which is correct: App Settings do not change at runtime without a restart, and a restart rebuilds the singleton anyway.

IOptionsSnapshot<T> is Scoped. Injecting it into a singleton throws at runtime. Functions does not have the same clear scope-per-request lifecycle as ASP.NET Core, so IOptionsSnapshot<T> causes subtle failures.

IOptionsMonitor<T> is safe in singletons and works if you need to respond to live configuration changes (for example, from Azure App Configuration with a refresh sentinel). For standard App Settings, it is more complexity than the scenario requires.

local.settings.json for options

The Values section is a flat dictionary. To represent the Api section locally, use the __ separator:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated",
    "Api__BaseUrl": "https://api.example.com",
    "Api__TimeoutSeconds": "30"
  }
}
Enter fullscreen mode Exit fullscreen mode

In Azure, create App Settings with the same names: Api__BaseUrl and Api__TimeoutSeconds. The binding is identical in both environments.


The three-layer mental model

When a configuration problem comes up, the question to ask is: which process needs this value?

Trigger and binding connections are resolved by the host process. They must be environment variables: either a Values entry in local.settings.json, an Azure App Setting, or a Key Vault reference on an App Setting. No other configuration source reaches the host.

Application settings (anything your code reads via IConfiguration) come from the full configuration pipeline: environment variables, appsettings.json, appsettings.{Environment}.json, and any sources you add in Program.cs. Because environment variables are part of this pipeline, all App Settings are also available in IConfiguration.

Secrets live in Key Vault and are surfaced as App Settings via the reference syntax. Your code sees them as ordinary environment variables and reads them through IConfiguration like any other setting.

The full working ConfigurationDemo project — ApiOptions, registration in Program.cs, and ProcessOrderFunction — is in the azure-functions-samples repository. Clone it, copy local.settings.json.example to local.settings.json, and run func start to see ValidateOnStart in action.


What do you use to manage secrets in your Azure Functions projects: Key Vault references, Azure App Configuration, or something else? Drop it in the comments.

AzureFunctions #DotNet #Azure #Security #KeyVault


Azure Functions for .NET Developers: Series

Top comments (0)