Azure SDK for Rust Migration Guide: REST to GA Crates
The Azure SDK for Rust hit GA with stable 1.0 crates. This guide shows how to migrate from raw REST calls to the azure_identity, Key Vault, and Storage clients.
Prerequisites
- Rust 1.75 or newer with Cargo installed
- An Azure subscription with permission to read a Key Vault secret and a Storage blob
- Azure CLI installed and signed in with az login
Tools Used
If you have been hitting Azure REST endpoints from Rust with hand-rolled reqwest calls and a pile of header-signing code, you can delete most of it now. The Azure SDK for Rust reached general availability in mid-2026, shipping stable 1.0 crates for Core, Identity, Key Vault, and Storage. This guide walks you through replacing raw REST access with the official clients: adding the crates, wiring up DefaultAzureCredential, reading a Key Vault secret, downloading a blob, and turning on retries and tracing. Every step maps a piece of REST plumbing you can retire to the typed client that replaces it.
The migration is not a rewrite. The clients follow the same design patterns as the .NET, Python, Go, and Java SDKs, so the shapes are predictable. What changes is that authentication, retries, and pagination stop being your problem and become the SDK’s.
What actually went GA
The GA wave promoted a specific set of crates to stable 1.0. Knowing which ones are production-ready keeps you from pinning a preview crate by accident:
| Crate | Purpose |
|---|---|
azure_core |
Shared pipeline: HTTP, retries, auth traits, error types |
azure_identity |
Credential types, including DefaultAzureCredential |
azure_security_keyvault_secrets |
Key Vault secrets client |
azure_security_keyvault_keys |
Key Vault keys client |
azure_security_keyvault_certificates |
Key Vault certificates client |
azure_storage_blob |
Blob upload, download, and container operations |
azure_storage_queue |
Queue send and receive |
azure_core_opentelemetry |
Distributed tracing bridge for the pipeline |
Two things did not make the GA cut, and you should plan around them. Event Hubs is slated for the next stable wave, and Cosmos DB support is in active development with a stable release expected later in 2026. If your service depends on either, keep your existing REST or preview code for those paths and migrate the rest now. Microsoft’s Rust on Azure overview tracks the current crate status if you want to confirm before you pin a version.
Step 1: add the crates
Start with a clean dependency set. From your crate root, let Cargo resolve the latest stable versions rather than guessing patch numbers:
$ cargo add azure_identity azure_security_keyvault_secrets azure_storage_blob tokio
That pulls azure_core transitively, so you rarely add it by hand. If you prefer to pin versions explicitly, your Cargo.toml ends up looking like this:
[dependencies]
azure_identity = "1.0"
azure_security_keyvault_secrets = "1.0"
azure_storage_blob = "1.0"
azure_core = "1.0"
tokio = { version = "1", features = ["full"] }
The SDK is async-first and expects a Tokio runtime, which is why tokio is in the list with the full feature. If your service already runs on async-std or a custom runtime, you will need a compatibility shim, because the clients are built and tested against Tokio.
Verify the tree resolved cleanly before you write any client code:
$ cargo tree -p azure_core
You should see a single azure_core 1.x in the output. If two versions show up, one of your other Azure crates is still on a preview release, and mixing a 1.0 core with a 0.x client is the most common source of trait-mismatch errors during migration.
Step 2: replace your auth code with azure_identity
This is where you delete the most code. If your REST client was fetching tokens from the IMDS endpoint or juggling a client secret from an environment variable, DefaultAzureCredential replaces all of it with one type that tries a chain of sources in order: environment variables, workload identity, managed identity, and your local developer sign-in.
Here is the full pattern for constructing a credential and handing it to a client:
use azure_identity::DefaultAzureCredential;
use azure_security_keyvault_secrets::SecretClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let credential = DefaultAzureCredential::new()?;
let client = SecretClient::new(
"https://your-vault-name.vault.azure.net/",
credential.clone(),
None,
)?;
// client is now ready to make authenticated calls
Ok(())
}
The credential.clone() is cheap. Credentials are reference-counted internally, so cloning shares the same token cache rather than re-authenticating. Build one credential at startup and clone it into every client you construct.
For local development, DefaultAzureCredential will pick up the identity you signed in with through az login. Confirm that works before you run anything:
$ az login
$ az account show --query user.name -o tsv
If you want to be explicit about using developer tooling and skip the managed-identity probes (which add latency and noisy log lines when you run locally), swap in DeveloperToolsCredential:
use azure_identity::DeveloperToolsCredential;
let credential = DeveloperToolsCredential::new(None)?;
A note on scope. In production, prefer managed identity so there is no secret to leak, and grant the identity only the specific Key Vault and Storage roles it needs. The same discipline that keeps secrets out of your CI pipelines applies here: the fewer long-lived credentials your Rust service holds, the smaller your blast radius. If you run across several Azure subscriptions, the multi-subscription patterns from the Terraform side carry over, because DefaultAzureCredential honors the same AZURE_SUBSCRIPTION_ID and tenant environment variables.
Step 3: migrate a Key Vault secret read
A typical pre-SDK secret fetch was a signed GET against https://your-vault.vault.azure.net/secrets/{name}?api-version=7.4, plus JSON parsing to dig the value out of the response envelope. Replace the whole thing with a typed call:
use azure_identity::DefaultAzureCredential;
use azure_security_keyvault_secrets::SecretClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let credential = DefaultAzureCredential::new()?;
let client = SecretClient::new(
"https://your-vault-name.vault.azure.net/",
credential.clone(),
None,
)?;
let secret = client
.get_secret("database-password", "", None)
.await?
.into_body()
.await?;
if let Some(value) = secret.value {
println!("secret length: {}", value.len());
}
Ok(())
}
Three details matter here. First, the empty string for the version argument means “current version,” which is what you almost always want. Pass a specific version string only when you need to pin to a historical value. Second, the response is a two-stage unwrap: .await? gives you the HTTP response, and .into_body().await? deserializes it into the typed Secret model. Third, never log the secret value itself. Print its length or a hash if you need a sanity check, as the example does above.
Run it with your vault name substituted in:
$ cargo run
If you get a 403, the problem is almost always RBAC rather than code. Grant your identity the Key Vault Secrets User role:
$ az role assignment create \
--role "Key Vault Secrets User" \
--assignee "$(az ad signed-in-user show --query id -o tsv)" \
--scope "/subscriptions/<sub-id>/resourceGroups/<rg>/providers/Microsoft.KeyVault/vaults/<vault-name>"
The authentication overview on Microsoft Learn documents the full credential chain and which environment variables each link reads.
Step 4: migrate blob storage access
Blob access follows the same construction pattern. You build a client against the account URL, then operate on containers and blobs. Here is a download that streams the blob body into memory:
use azure_identity::DefaultAzureCredential;
use azure_storage_blob::BlobClient;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let credential = DefaultAzureCredential::new()?;
let blob_client = BlobClient::new(
"https://yourstorageaccount.blob.core.windows.net/",
"reports".to_string(),
"2026-q3.json".to_string(),
credential.clone(),
None,
)?;
let response = blob_client.download(None).await?;
let body = response.into_body().collect().await?;
println!("downloaded {} bytes", body.len());
Ok(())
}
The constructor arguments are the account URL, the container name, the blob name, the credential, and an options struct. Passing None for options accepts the defaults, which is the right starting point. The download call returns a response whose body you collect into bytes. For large blobs you would stream chunks rather than collecting the whole body, but collecting is fine for config files and small artifacts.
The same authorization rule applies: the identity needs the Storage Blob Data Reader role (or Contributor if you also write). Assign it the same way you did for Key Vault, swapping the role name and the scope to your storage account.
Uploading is the mirror image. You build the same client and hand it the bytes plus a length:
use azure_core::http::RequestContent;
let data = b"{\"generated_at\":\"2026-07-31\"}".to_vec();
let len = data.len() as u64;
blob_client
.upload(RequestContent::from(data), true, len, None)
.await?;
The boolean argument is the overwrite flag. Passing true replaces an existing blob of the same name, and false fails the call if the blob already exists, which is the safer default when you are writing an object that should be created exactly once. As with the download, the options struct is None until you need to set content type, metadata, or an access tier.
Step 5: retries and resilience are already on
One of the quieter wins in the GA release is that resilience is built into azure_core’s pipeline and on by default. Transient failures (HTTP 429, 503, and connection resets) are retried automatically with exponential backoff. You do not write retry loops anymore, and you should delete any you carried over from your REST client, because doubling up on retries turns a brief throttle into a much longer stall.
When you do need to tune the behavior, you set it through the client options struct rather than wrapping calls yourself. The pattern looks like this:
use azure_core::http::policies::RetryOptions;
// Construct retry options and pass them through the client's options struct
let retry = RetryOptions::exponential(Default::default());
The GA release also added challenge-based authentication, so the clients work correctly in sovereign and private cloud environments where the token audience is discovered from a challenge response rather than assumed. If you previously special-cased Azure Government or a private cloud, that branch can likely go.
Start with the defaults. The built-in policy is tuned for the common case, and premature retry tuning is a frequent way to make throttling worse rather than better.
Step 6: turn on distributed tracing
If your service already emits OpenTelemetry spans, you can thread Azure SDK calls into the same traces using the azure_core_opentelemetry crate. It bridges the SDK’s internal pipeline instrumentation to your OpenTelemetry tracer, so every Key Vault or Storage call shows up as a child span under your request.
Add the crate:
$ cargo add azure_core_opentelemetry
You wire it in by attaching the OpenTelemetry tracer provider to the client options, after which HTTP calls the SDK makes are recorded as spans with the operation name, target, and status. The HTTP logging layer sanitizes secrets by default, so authorization headers and secret values are redacted before anything reaches your log sink. That default matters: it means turning on verbose SDK logging during an incident will not accidentally dump a Key Vault secret into your log aggregator.
If you are new to wiring OpenTelemetry through an application, the mechanics of tracer setup and exporters are the same ones covered in this walkthrough on setting up observability with OpenTelemetry; the Azure crate simply feeds the SDK’s own spans into that pipeline instead of you instrumenting each call by hand.
Handle errors with the typed error model
Your REST client probably branched on raw status codes pulled out of a response struct. The SDK gives you a typed azure_core::Error instead, and the useful move during migration is to inspect its HTTP status when you need to distinguish a genuine “not found” from a transient failure the pipeline already gave up retrying.
A common case is treating a missing secret as an expected outcome rather than a hard failure:
use azure_core::http::StatusCode;
match client.get_secret("optional-flag", "", None).await {
Ok(response) => {
let secret = response.into_body().await?;
println!("found: {}", secret.value.unwrap_or_default().len());
}
Err(err) if err.http_status() == Some(StatusCode::NotFound) => {
println!("secret not set, using default");
}
Err(err) => return Err(err.into()),
}
The pattern is worth internalizing because it is identical across every GA client. A Key Vault 404, a Storage 404, and an Identity failure all surface through the same azure_core::Error type with the same http_status() accessor. That consistency is a large part of why migrating the second and third service is faster than the first: once you have written error handling for one client, you have written it for all of them.
Resist the urge to match on error strings. The status accessor is stable across releases; the human-readable message is not, and matching on it will break the next time the wire format changes a word.
Deploying to AKS with workload identity
The payoff of DefaultAzureCredential shows up in production, where you want zero secrets in the container. On Azure Kubernetes Service, workload identity federates your pod’s service account to an Azure managed identity, and the credential picks it up automatically through environment variables the workload-identity webhook injects. Your Rust code does not change at all between laptop and cluster, which is the point.
The cluster-side wiring is three annotations and a federated credential:
$ az identity federated-credential create \
--name rust-app-federated \
--identity-name rust-app-identity \
--resource-group <rg> \
--issuer "$(az aks show -g <rg> -n <cluster> --query oidcIssuerProfile.issuerUrl -o tsv)" \
--subject "system:serviceaccount:<namespace>:<service-account>"
Once the federated credential exists and your pod’s service account carries the azure.workload.identity/client-id annotation, the same binary you tested locally authenticates as the managed identity with no code path difference. Grant that identity the same Key Vault and Storage roles you used during local testing, scoped to production resources, and you have a service with no long-lived credential anywhere in the deployment.
A migration checklist
Work through your codebase in this order to keep the change reviewable:
- Inventory every place you call an Azure REST endpoint from Rust. Group them by service (Key Vault, Storage, and anything not yet GA).
- Add the GA crates for the services you found, and run
cargo treeto confirm a singleazure_core 1.xin the graph. - Replace token acquisition with a single
DefaultAzureCredentialbuilt at startup and cloned into each client. - Convert one service at a time. Migrate Key Vault first, since it is usually the smallest surface, then Storage.
- Delete your hand-written retry loops and header-signing helpers once the typed client covers that path.
- Leave Event Hubs and Cosmos DB on your existing code until their stable crates ship, and tag those spots with a comment so you remember to revisit.
- Add
azure_core_opentelemetrylast, once functionality is proven, so tracing reflects the new call paths.
Migrating incrementally like this is the same principle behind any staged platform move, including the Azure DevOps to GitHub playbook: change one bounded surface, verify it in production, then move to the next. Do not try to flip every service in a single pull request.
Common migration gotchas
A few things trip people up on the first pass:
- Mixed crate versions. A preview
0.xclient against a1.0azure_coreproduces confusing trait errors. Pin everything to the 1.0 line and re-runcargo tree. - Forgetting
.into_body(). The first.await?gives you the response, not the parsed model. The typed value comes from the second.into_body().await?. Skipping it is a frequent compile-time confusion. - Rebuilding credentials per request. Constructing
DefaultAzureCredentialinside a request handler defeats the token cache and adds latency. Build once, clone many. - RBAC, not code. A 401 usually means the audience or tenant is wrong; a 403 almost always means a missing role assignment. Check
az role assignment listbefore you suspect the SDK.
Where this leaves you
After this migration, your Azure access code in Rust is smaller, typed, and consistent with how the rest of your Azure fleet is written in other languages. Authentication is one credential built at startup. Retries and secret redaction are handled by the pipeline. Tracing plugs into the OpenTelemetry setup you already run. The two gaps to watch are Event Hubs and Cosmos DB, both of which have stable crates on the roadmap, so keep those integration points isolated and ready to swap.
Start with a single non-critical service, prove the pattern end to end in a staging environment, and use the checklist above to roll it out service by service. The crate status page on Microsoft Learn is worth a bookmark, because the GA surface is still expanding and the next wave will let you delete even more REST plumbing.
Related articles
Get the next article in your inbox
Practical DevOps tips, tutorials, and guides. No spam, unsubscribe anytime.