Azure Security

Azure App Configuration in Production: Dynamic Refresh, Feature Flags, Key Vault References, and Snapshots

Configuration sprawl is a quiet production risk. The moment you have more than one service, more than one environment, and more than one secret, appsettings.json and app-settings blades stop scaling: values drift between environments, a rollout requires a redeploy to flip a flag, and connection strings end up pasted into seven places. Azure App Configuration centralizes the lot — typed key-values, feature flags, and Key Vault references — behind a single endpoint with labels for environment separation, dynamic refresh so apps pick up changes without a restart, and immutable snapshots so a config release is as reviewable and rollback-able as a code release. This guide walks the production-grade setup end to end.

A quick mental model before the steps: App Configuration is not a secret store. It holds non-secret values directly and holds pointers (Key Vault references) to secrets. Key Vault stays the system of record for anything sensitive. Get that boundary right and the rest falls into place.

1. Store design: keys, labels, and per-environment separation

Create the store and decide its separation strategy before you write a single key. App Configuration gives you two axes: key namespaces (hierarchical prefixes like OrderService:) and labels (orthogonal variants of the same key, typically environment).

az appconfig create \
  --name appcs-platform-prod \
  --resource-group rg-platform-config \
  --location eastus \
  --sku Standard \
  --enable-purge-protection true \
  --assign-identity '[system]'

The Standard SKU is the one you want in production: it unlocks geo-replication, private endpoints, snapshots, and a much higher request quota than Free. The Developer/Free tier is for prototypes only.

The separation decision: one store with environment labels versus a store per environment. The honest answer is store-per-environment for prod isolation, labels-per-environment for dev/test/staging where blast radius is lower.

Strategy Pros Cons Use when
Labels per environment (dev/staging/prod) in one store Cheap, easy diffing, single import target Shared RBAC plane, prod and dev in one resource Lower environments, small teams
Store per environment Hard isolation, separate RBAC, separate failure domain More resources, duplicated bootstrap Production isolation, regulated workloads

A pragmatic hybrid that I have shipped repeatedly: a single store for dev/staging using labels, and a dedicated production store with its own private endpoint and locked-down RBAC. Whatever you choose, key naming stays consistent so the same code reads both.

# Hierarchical keys, environment expressed as a label
az appconfig kv set \
  --name appcs-platform-prod \
  --key "OrderService:MaxConcurrentBatches" \
  --value "16" \
  --label prod \
  --yes

az appconfig kv set \
  --name appcs-platform-prod \
  --key "OrderService:MaxConcurrentBatches" \
  --value "4" \
  --label dev \
  --yes

Use the colon (:) as the hierarchy separator — the .NET configuration system maps OrderService:MaxConcurrentBatches directly onto IConfiguration["OrderService:MaxConcurrentBatches"] and onto the MaxConcurrentBatches property of a bound OrderServiceOptions. Assign App Configuration Data RBAC roles, not just control-plane roles: App Configuration Data Reader for apps, App Configuration Data Owner for pipelines. The management-plane Contributor role does not grant data access.

2. Dynamic configuration refresh and sentinel-key cache invalidation

The headline feature: change a value in the portal or pipeline and have running apps pick it up without a restart. The naive approach — polling every individual key — is wasteful and races. The correct pattern is a sentinel key: a single watched key whose value you bump last after writing a batch of changes. The SDK polls only the sentinel; when its ETag changes, it reloads the whole registered key set atomically.

# Update several real keys...
az appconfig kv set -n appcs-platform-prod --key "OrderService:RetryCount" --value "5" --label prod --yes
az appconfig kv set -n appcs-platform-prod --key "OrderService:Timeout"    --value "30" --label prod --yes

# ...then bump the sentinel LAST to trigger a coherent refresh
az appconfig kv set -n appcs-platform-prod --key "Sentinel" --value "v42" --label prod --yes

In ASP.NET Core, wire refresh with a cache expiration so you do not hammer the service. The SDK only issues a conditional GET (an ETag check) on the sentinel after the cache window elapses, so a 30-second window is cheap.

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(
            new Uri(builder.Configuration["AppConfig:Endpoint"]!),
            new DefaultAzureCredential())
        .Select(KeyFilter.Any, LabelFilter.Null)        // unlabeled defaults first
        .Select(KeyFilter.Any, builder.Environment.EnvironmentName) // env overrides win
        .ConfigureRefresh(refresh =>
        {
            refresh.Register("Sentinel", refreshAll: true)
                   .SetRefreshInterval(TimeSpan.FromSeconds(30));
        });
});

Two subtleties that trip people up. First, the order of Select matters: load the null-label defaults, then the environment label, so environment values override defaults for matching keys. Second, refreshAll: true on the sentinel means a single sentinel change reloads every key, giving you the atomic batch semantics you want. Without it, only the sentinel itself reloads.

Refresh is not automatic — something has to call the middleware. In a web app, add it to the request pipeline:

app.UseAzureAppConfiguration();   // checks the sentinel per request, refreshes when due

For a worker service or background job with no HTTP pipeline, inject IConfigurationRefresherProvider and call TryRefreshAsync() on a timer yourself.

3. Feature flags with percentage, targeting, and time-window filters

Feature flags in App Configuration are key-values under the reserved .appconfig.featureflag/ namespace, but you manage them through dedicated commands and the Microsoft.FeatureManagement library — never hand-edit the raw JSON. The three filters you actually use in production are Percentage (gradual rollout), Targeting (named users/groups plus a rollout percentage), and TimeWindow (scheduled enablement).

# Create a flag, disabled by default, scoped to prod
az appconfig feature set \
  --name appcs-platform-prod \
  --feature CheckoutV2 \
  --label prod \
  --yes

# Add a targeting filter: 100% of the "beta" group, 25% of everyone else
az appconfig feature filter add \
  --name appcs-platform-prod \
  --feature CheckoutV2 \
  --label prod \
  --filter-name Microsoft.Targeting \
  --filter-parameters \
    'Audience={"Groups":[{"Name":"beta","RolloutPercentage":100}],"DefaultRolloutPercentage":25}' \
  --yes

Targeting is sticky: the same user (identified by a TargetingContext.UserId) consistently lands on the same side of the rollout because evaluation hashes the user id against the percentage — no flapping between requests. Wire it up in code by registering feature management and a targeting context accessor:

builder.Services.AddFeatureManagement()
    .WithTargeting<HttpContextTargetingContextAccessor>();
// Gate code paths declaratively
if (await _featureManager.IsEnabledAsync("CheckoutV2"))
{
    return await _checkoutV2.PlaceOrderAsync(cart);
}
return await _checkoutLegacy.PlaceOrderAsync(cart);

The HttpContextTargetingContextAccessor (you implement ITargetingContextAccessor) pulls the user id and groups from the authenticated principal so targeting decisions are per-user. For MVC actions you can also decorate with [FeatureGate("CheckoutV2")] to short-circuit the action when the flag is off.

Feature-flag changes refresh through the same sentinel mechanism. Call UseFeatureFlags() inside AddAzureAppConfiguration and set its SetRefreshInterval so flag flips propagate to running instances without a sentinel bump on the data keys.

A scheduled rollout uses the time-window filter — no human awake at 02:00 to flip it:

az appconfig feature filter add \
  --name appcs-platform-prod \
  --feature HolidayBanner \
  --label prod \
  --filter-name Microsoft.TimeWindow \
  --filter-parameters Start='Mon, 01 Dec 2025 00:00:00 GMT' End='Sat, 27 Dec 2025 00:00:00 GMT' \
  --yes

4. Key Vault references and managed-identity secret resolution

App Configuration never stores secret material. Instead you store a Key Vault reference — a key whose value is a JSON pointer to a Key Vault secret URI — and the SDK resolves it at load time using the app’s managed identity. The secret value never lands in App Configuration’s storage.

az appconfig kv set-keyvault \
  --name appcs-platform-prod \
  --key "OrderService:Db:ConnectionString" \
  --label prod \
  --secret-identifier "https://kv-platform-prod.vault.azure.net/secrets/orders-db-conn" \
  --yes

That reference is just a key-value with a special content type (application/vnd.microsoft.appconfig.keyvaultref+json). For the SDK to dereference it, you must register a Key Vault credential and grant the identity Key Vault Secrets User on the vault:

builder.Configuration.AddAzureAppConfiguration(options =>
{
    var credential = new DefaultAzureCredential();
    options.Connect(new Uri(endpoint), credential)
           .ConfigureKeyVault(kv => kv.SetCredential(credential));   // resolves KV references
});
# The app identity needs read on the vault's secrets, data-plane RBAC
az role assignment create \
  --assignee "$APP_IDENTITY_OBJECT_ID" \
  --role "Key Vault Secrets User" \
  --scope "/subscriptions/$SUB/resourceGroups/rg-platform-config/providers/Microsoft.KeyVault/vaults/kv-platform-prod"

Pin the reference to a specific secret version in the URI when you need deterministic rollouts (the value will not change under you when someone rotates the secret); leave the version off when you want the app to always resolve the current version. There is a real trade-off: unversioned references mean a Key Vault rotation can change behavior on the next config refresh without a config change in App Configuration, so for tightly controlled releases, version-pin and bump the reference deliberately.

5. Immutable snapshots and safe configuration releases with rollback

A snapshot is an immutable, point-in-time, named bundle of key-values selected by a filter. Once created it cannot be modified — only archived. This turns “what config was live at 14:00 when the incident started?” from an unanswerable question into a named artifact, and gives you a one-line rollback target.

az appconfig snapshot create \
  --name appcs-platform-prod \
  --snapshot-name "orders-2026-06-08-rel-118" \
  --filters '[{"key":"OrderService:*","label":"prod"}]' \
  --retention-period 7776000   # 90 days, in seconds

The release pattern: write the new desired state to the live keys, validate, snapshot it as the new “known good”, and keep the previous snapshot as the rollback target. Because snapshots are immutable, a rollback is “re-apply the values from snapshot N-1”, not “hope you remember what they were”. You can load a snapshot directly in the SDK, which pins an instance to that exact bundle:

options.Connect(new Uri(endpoint), credential)
       .SelectSnapshot("orders-2026-06-08-rel-118");
# List and archive snapshots as part of release hygiene
az appconfig snapshot list --name appcs-platform-prod -o table
az appconfig snapshot archive --name appcs-platform-prod --snapshot-name "orders-2026-06-01-rel-115"

Snapshots also bound your blast radius: a fat-fingered key edit on the live store does not affect any instance pinned to a snapshot. The trade-off is that pinned instances do not get dynamic refresh — they are frozen by design. Most teams run live keys with sentinel refresh for fast-moving flags and reserve snapshots for the connection-string-grade config they want frozen per release.

6. Private endpoint, geo-replication, and high-availability patterns

For production, lock the data plane to your network and disable public access. A private endpoint projects the store into your VNet over Private Link; pair it with a private DNS zone so the public hostname resolves to the private IP.

az appconfig update \
  --name appcs-platform-prod \
  --resource-group rg-platform-config \
  --enable-public-network false

az network private-endpoint create \
  --name pe-appcs-prod \
  --resource-group rg-platform-config \
  --vnet-name vnet-platform \
  --subnet snet-privatelink \
  --private-connection-resource-id "$(az appconfig show -n appcs-platform-prod -g rg-platform-config --query id -o tsv)" \
  --group-id configurationStores \
  --connection-name appcs-prod-conn

The private DNS zone for App Configuration is privatelink.azconfig.io. Link it to the VNet and create the A record (the portal/CLI private-endpoint DNS integration does this for you when you pass --private-dns-zone).

Geo-replication gives you data-plane resilience. A replica is a read/write copy of the store in a second region with its own dedicated endpoint, and it counts toward your effective request quota. The .NET SDK has built-in failover: pass multiple endpoints and it routes to a healthy replica automatically.

az appconfig replica create \
  --name appcs-platform-prod \
  --resource-group rg-platform-config \
  --location westus2 \
  --replica-name westus2
// Primary plus replica; the SDK fails over automatically
options.Connect(
    new[]
    {
        new Uri("https://appcs-platform-prod.azconfig.io"),
        new Uri("https://appcs-platform-prod-westus2.azconfig.io")
    },
    new DefaultAzureCredential());

App Configuration is replicated by region and reads are served from local copies, so a regional outage in the primary degrades to the replica without a redeploy. Geo-replication is a Standard-SKU feature; budget for it on anything customer-facing.

7. Import/export pipelines and config-as-code from Git

Treat configuration as code. Keep the desired state in Git, review it in PRs, and let the pipeline import it into the store. App Configuration imports from a file (JSON/YAML/properties), from another store, or from an App Service settings export.

# Import a YAML file of key-values for the staging label, with a content-type
az appconfig kv import \
  --name appcs-platform-prod \
  --source file \
  --path ./config/staging.yaml \
  --format yaml \
  --label staging \
  --content-type "application/json" \
  --yes

In a pipeline, gate the import on a PR merge and bump the sentinel afterward so the change goes live coherently:

# azure-pipelines.yml (excerpt)
- task: AzureCLI@2
  displayName: Publish config to App Configuration
  inputs:
    azureSubscription: sc-platform-prod
    scriptType: bash
    scriptLocation: inlineScript
    inlineScript: |
      az appconfig kv import \
        --name appcs-platform-prod \
        --source file --path $(Build.SourcesDirectory)/config/prod.yaml \
        --format yaml --label prod --yes
      # bump sentinel last so running apps refresh atomically
      az appconfig kv set -n appcs-platform-prod --key Sentinel \
        --value "$(Build.BuildId)" --label prod --yes

Export the running state back to a file for drift detection — diff the export against Git in CI and fail the build if someone made an out-of-band portal edit. The --export-as-reference and content-type flags preserve Key Vault references and feature flags through a round-trip, so config-as-code survives import/export cycles intact.

8. SDK integration for .NET and ASP.NET Core with health checks

Pulling the pieces together, the production bootstrap loads from App Configuration, resolves Key Vault references, enables feature flags, registers sentinel refresh, and binds typed options. Add the Microsoft.Extensions.Configuration.AzureAppConfiguration and Microsoft.Azure.AppConfiguration.AspNetCore packages plus Microsoft.FeatureManagement.AspNetCore.

var builder = WebApplication.CreateBuilder(args);
var credential = new DefaultAzureCredential();

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(new Uri(builder.Configuration["AppConfig:Endpoint"]!), credential)
           .Select(KeyFilter.Any, LabelFilter.Null)
           .Select(KeyFilter.Any, builder.Environment.EnvironmentName)
           .ConfigureKeyVault(kv => kv.SetCredential(credential))
           .ConfigureRefresh(r => r.Register("Sentinel", refreshAll: true)
                                    .SetRefreshInterval(TimeSpan.FromSeconds(30)))
           .UseFeatureFlags(ff => ff.SetRefreshInterval(TimeSpan.FromSeconds(30)));
});

builder.Services.AddAzureAppConfiguration();   // registers the refresher/middleware services
builder.Services.AddFeatureManagement()
       .WithTargeting<HttpContextTargetingContextAccessor>();

// Strongly-typed options bound to the OrderService: namespace
builder.Services.Configure<OrderServiceOptions>(
    builder.Configuration.GetSection("OrderService"));

var app = builder.Build();
app.UseAzureAppConfiguration();   // drives sentinel-based refresh per request
app.MapHealthChecks("/healthz");
app.Run();

For health checks, the package AspNetCore.HealthChecks.AzureKeyVault covers the vault dependency; for App Configuration itself, a lightweight readiness check that reads the sentinel key confirms the data plane is reachable. Crucially, fail fast at startup if config cannot load — the default behavior throws if the store is unreachable during the initial load, which is what you want: a pod that cannot read its config should not pass readiness and take traffic.

Use IOptionsMonitor<OrderServiceOptions>, not IOptions<T>, anywhere you want refreshed values. IOptions<T> is a singleton snapshot captured at startup and will never see a refresh; IOptionsMonitor<T>.CurrentValue re-reads on each access and reflects sentinel-driven updates.

Verify

Confirm each layer works before declaring victory.

# 1. Keys and labels are present per environment
az appconfig kv list -n appcs-platform-prod --label prod --key "OrderService:*" -o table

# 2. Feature flag state and filters
az appconfig feature show -n appcs-platform-prod --feature CheckoutV2 --label prod -o json

# 3. Key Vault reference resolves (check content type, then app identity has KV access)
az appconfig kv show -n appcs-platform-prod --key "OrderService:Db:ConnectionString" --label prod \
  --query contentType -o tsv
# expect: application/vnd.microsoft.appconfig.keyvaultref+json

# 4. Snapshot exists and is the expected size
az appconfig snapshot show -n appcs-platform-prod --snapshot-name "orders-2026-06-08-rel-118" -o json

# 5. Public access is off and the replica is online
az appconfig show -n appcs-platform-prod -g rg-platform-config --query publicNetworkAccess -o tsv
az appconfig replica list -n appcs-platform-prod -g rg-platform-config -o table

For dynamic refresh, the definitive test is behavioral: change a value, bump the sentinel, and watch IOptionsMonitor.CurrentValue change in a running instance within the refresh interval — no redeploy, no restart. If it does not update, you almost certainly forgot app.UseAzureAppConfiguration() in the pipeline or are injecting IOptions<T> instead of IOptionsMonitor<T>.

Enterprise scenario

A payments platform team ran a single shared App Configuration store across dev, staging, and prod using labels, with apps reading values via IOptionsMonitor and sentinel refresh on a 30-second window. During a routine rollout, an engineer used the portal to update the prod Payments:Gateway:Timeout key but, working fast, edited it under the null label instead of the prod label. Because their bootstrap loaded null-label defaults and then the prod label, the correct prod value still won — so nothing broke in prod. But the dev cluster, which read the null label as its baseline, suddenly inherited the production timeout and started failing integration tests, paging the on-call at 23:00.

The constraint: they could not give up labels (cost and tooling were built around a single store for lower environments), but they needed prod config edits to be impossible to fat-finger into the wrong scope, and they needed dev to stop silently inheriting stray null-label writes.

The fix had three parts. First, they carved production into its own store with a private endpoint and App Configuration Data Owner granted only to the release pipeline’s identity — humans lost write access to prod entirely, so portal edits could not target it. Second, in the lower-environment store they stopped relying on null-label inheritance: every environment got an explicit label and the bootstrap selected only that label, so a stray null-label write affected nobody. Third, the pipeline snapshotted prod on every release for a clean rollback target.

// Lower-environment bootstrap: explicit label only, no null-label inheritance
options.Connect(new Uri(endpoint), credential)
       .Select(KeyFilter.Any, builder.Environment.EnvironmentName);  // e.g. "dev" — no LabelFilter.Null

The deeper lesson: label-based separation is convenient but it is a soft boundary — anyone with data-write access can edit any label. For production, isolation has to come from RBAC and a separate resource, not from a naming convention. Reserve labels for environments where a misroute is an annoyance, not an incident.

Checklist

AzureApp ConfigurationFeature FlagsConfigurationDevOps

Comments

Keep Reading