# Sync to Azure Key Vault

Key Vault is a store rather than a runtime: nothing redeploys when a value
changes, and whatever reads the vault — App Service, AKS with the Secrets Store
CSI driver, or an Azure SDK client — picks up the new version on its next read.
A binding owns one vault.

| At a glance | |
| --- | --- |
| **What seekrit writes** | One vault secret per name, each push adding a new **version** |
| **Addressed by** | The vault name (`acme-prod`), plus an optional name prefix |
| **Connection carries** | The Entra tenant and application IDs, and which Azure cloud they live in |
| **Token permission** | The **Key Vault Secrets Officer** role on the vault |
| **Takes effect** | On the next read by your app — no deployment is triggered |
| **Value visibility** | Readable by any principal with `get` on the vault |

New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the
decryption grant, name mapping, deletions, and failure handling are the same on
every destination.

> **Warning:** **Key Vault cannot store an underscore, so `DATABASE_URL` is written as `DATABASE-URL`.** Secret names in Key Vault allow only letters, digits, and hyphens. That rules out the shape of almost every environment variable ever written, so seekrit rewrites `_` as `-` by default rather than failing your whole environment. The rename is never silent: it is stated on the binding, shown in the dashboard, and visible in each run's log. **Your application has to read the hyphenated name** — that is what is in the vault. If you would rather name every secret yourself, pass `--name-mode reject` and seekrit will fail the names it cannot store instead, leaving you to map them with the binding's `--prefix` and rename. Because the rewrite is many-to-one, two secrets can collide — `A_B` and `A-B` both give `A-B`. seekrit fails **both** names and tells you which, rather than letting whichever ran last win. This is what every tool that writes to Key Vault does — Doppler and Infisical both document the same substitution — because Azure has [declined](https://github.com/Azure/azure-sdk-for-net/issues/39975) to allow underscores. What seekrit adds is the collision check and the `reject` escape hatch.

## 1. Register an application

Key Vault authenticates through Microsoft Entra, so a connection signs in as a
**service principal** rather than holding an API key. In the Entra admin center,
under **App registrations → New registration**, create one for seekrit. Its
**Overview** page gives you the two IDs a connection needs, and
**Certificates & secrets → New client secret** gives you the credential.

```bash
printf '%s' "$AZURE_CLIENT_SECRET" \
  | seekrit sync connect --name acme-azure --provider azure-key-vault \
      --azure-tenant-id 72f988bf-86f1-41af-91ab-2d7cd011db47 \
      --azure-client-id 0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9
```

The tenant and client IDs are identifiers, not credentials — they appear in
every token request and in your sign-in logs — so they are stored in the clear,
which is what lets the dashboard show you which app a connection uses. Only the
client secret is encrypted to the connection's key.

> **Note:** **Sovereign clouds.** Add `--azure-cloud usgov` or `--azure-cloud china`. The cloud sets both the sign-in authority and the vault's DNS suffix, and — unlike AWS, where the China partition falls out of the region string — nothing about a tenant GUID or a vault name reveals which cloud it belongs to, which is why the connection states it. `public` is the default. | `--azure-cloud` | Authority | Vault suffix | | --- | --- | --- | | `public` (default) | `login.microsoftonline.com` | `vault.azure.net` | | `usgov` | `login.microsoftonline.us` | `vault.usgovcloudapi.net` | | `china` | `login.chinacloudapi.cn` | `vault.azure.cn` |

## 2. Grant it access to the vault

On the vault, under **Access control (IAM)**, assign the app the **Key Vault
Secrets Officer** role. On a vault still using the older access-policy model,
grant the `set`, `delete`, `recover`, and `list` secret permissions instead —
`list` is what `seekrit sync verify` uses to prove the connection.

## 3. Bind an environment

A destination is one vault:

```bash
seekrit sync verify acme-azure --provider azure-key-vault --vault acme-prod

seekrit sync enable --connection acme-azure --provider azure-key-vault \
  --vault acme-prod \
  --app storefront --env production --acknowledge-decryption
```

`--vault` is the vault name, not its URL. `--path` adds a prefix to every name
(`--path storefront-`). Key Vault names are flat — there are no folders, and `/`
is not a character it accepts — so unlike Parameter Store's path this is a
naming convention and nothing more.

Verify lists one secret, which is why the connection needs `list` on top of the
write permissions. It proves the three things you can get wrong from the
connection dialog at once: the client secret is current, the tenant will issue a
token, and the vault exists and answers to this principal.

## How a push behaves

- **One request per secret, and no listing.** `SetSecret` is a true upsert — it
  creates the secret or adds a version to the one already there — so unlike the
  AWS connectors there is no create/update branch. seekrit never reads values
  back to compare them either: that would pull plaintext it does not own into
  the sync engine.
- **Every push mints a new version.** Key Vault keeps the old ones, so a
  rotation is recoverable at the destination and your app's next read gets the
  current value.
- **Secrets seekrit writes are tagged `managed-by: seekrit`**, so anyone reading
  the vault in the portal can see which entries a binding owns. Tags are
  metadata, never values.
- **A run is capped at 300 operations.** That is Key Vault's scarcest documented
  bucket — *CREATE secret* allows 300 transactions per 10 seconds per vault,
  where everything else gets 4,000, and every write here spends from it. A
  bigger environment finishes across consecutive runs rather than throttling
  itself, and the run ledger says so.
- **A `429` stops the run and waits one throttling window.** Key Vault's
  guidance documents no `Retry-After` and tells clients to back off on their
  own; the header is honored when it does appear.
- **A `401` or `403` fails the whole run once**, rather than repeating the same
  fact beside all fifty names — an application that cannot write to a vault is
  one fact about the connection.
- **A value the service echoes back in an error is scrubbed** before the reason
  is stored on the run row, and the oversize guard names only a byte count.

## Name and value rules

> **Note:** **Deletions are soft, and the name stays reserved.** Every vault created since 2020 has soft delete on, and it cannot be turned off. A removed secret goes to the vault's recycle bin for its retention window — 90 days by default — and during that time the name cannot be re-created. seekrit handles the round trip: a name that comes back is **recovered** and then written, so an `exclude` glob edited twice does not strand a secret for three months. Recovery is not instantaneous, so a name that returns within seconds of its deletion may take one more run to land. Nothing here ever purges a secret — that is irreversible, and not a secrets manager's call to make. One deletion is deliberately skipped: a removed name whose *vault* name a live secret has taken over in the same run (`DB_URL` dropped as `DB-URL` is added) is left alone, because deleting it would destroy the value that just replaced it.

> **Note:** **.NET configuration survives the trip intact.** ASP.NET Core's Key Vault configuration provider reads a **double** dash as the `:` section separator, which is the same job `__` does in an environment variable. So a secret named `Database__ConnectionString` is stored as `Database--ConnectionString` and read back as `Database:ConnectionString` — the hierarchy you meant, without a code change. That is not a coincidence: `--` exists in Key Vault precisely because `:` is not allowed there either.

> **Note:** Key Vault caps a secret **value** at 25KB and a **name** at 127 characters, and a name that breaks either is reported as a failure against that name alone — the rest of the environment still pushes. A client secret in Entra expires after at most 24 months, so a connection that starts failing to authenticate has usually just outlived its credential.

> **Warning:** **The vault must allow public network access.** seekrit calls Key Vault from its own network, so a vault whose firewall is restricted to selected virtual networks or private endpoints will refuse the connection with a 403. If your vault has to stay network-restricted, keep decryption on your side instead — [`seekrit run`](/docs/guides/run), the [SDKs](/docs/guides/sdks), or [ESO](/docs/guides/kubernetes) all reach it from inside your own network.

> **Note:** **There is no `json-bundle` layout here.** The reason one exists for AWS Secrets Manager is billing — AWS charges per secret per month — and Key Vault charges per *operation*, so fifty names cost the same stored fifty ways. One secret per name is simply correct.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| Entra will not issue a token | The client secret expired (Entra caps them at 24 months), or a tenant/application ID is wrong | Create a new client secret and re-create the connection |
| `403` on every name | The app has no role assignment on the vault, no equivalent access policy, or the vault's firewall is restricted to selected networks | Assign **Key Vault Secrets Officer**, or open the vault to all networks — seekrit calls the public endpoint |
| A name failed with "cannot contain `_`" | The destination is set to `--name-mode reject` | Rename it in the binding, or switch the destination to rewrite underscores |
| Two names failed, each naming the other | Both map to the same vault name (`A_B` and `A-B`) | Rename one in the binding, or give it its own prefix |
| One name failed on size | The value is over Key Vault's 25KB limit, or the mapped name over 127 characters | Shorten it; the rest of the environment still pushed |
| A name is "being recovered" | It was soft-deleted seconds ago and Key Vault is still restoring it | Nothing to do — the next run writes it |
| A run reports `moreWork` | It hit the 300-operation budget for one vault | Nothing to do — the engine re-runs immediately until the environment is caught up |
| The app still reads the old value | Nothing redeploys on a Key Vault write | Restart or let its cache expire — App Service and the CSI driver both cache |

## See also

- [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions
- [Kubernetes with ESO](/docs/guides/kubernetes) — the pull-based path, for a network-restricted vault
- [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag
