Published on

I stopped putting connection strings in my AKS apps — here is what replaced them

Authors
  • avatar
    Name
    Krzysztof Kozłowski
    Twitter

For a long time, every AKS app I built had the same boring problem: "how do I get the connection string in?"

Connection string for Azure SQL. Connection string for Storage. Connection string for Service Bus. All of them in Kubernetes Secrets. All of them rotated rarely, because rotating meant pod restarts and runbook anxiety.

Then I learned about Workload Identity — Azure's way of giving each pod its own identity, signed by AKS, trusted by Azure. The pod proves who it is via an OIDC token. No shared password, no client secret anywhere in the cluster.

And once that was set up, the obvious question hit me:

The pod already has its own Azure identity. Why am I still giving it a password for SQL? For Storage? For Service Bus?

Turns out, for most Azure services, you don't have to. This post is the pattern I landed on — DefaultAzureCredential once in app code, RBAC grants on the Azure side, and the connection strings just... go away.

I'll cover the Workload Identity setup itself in a follow-up post. For now, this post assumes you already have a Managed Identity attached to your pod through Workload Identity — if you don't, the short version is: enable OIDC on AKS, create a Managed Identity in Azure, link the two via a federated credential, label your pod.

The mental model

A connection string is a password with extra steps. Server name + login + secret, glued together with ;. When the secret leaks, every copy of that string is compromised.

Azure RBAC lets you grant identities access to resources at a fine-grained level. Workload Identity already gave your pod an identity. The connection now becomes:

  1. Pod gets a token from Workload Identity (automatic, you set this up once).
  2. SDK presents the token to Azure SQL / Storage / Service Bus.
  3. The service checks RBAC: "does this identity have access here?"
  4. If yes, the operation goes through.

No password anywhere. No connection string in a Secret. No rotation runbook.

One pattern. Three services. Same code in every app.

DefaultAzureCredential — the only handle you need

This class is in every Azure SDK (.NET, Java, Python, Node, Go). It's a credential chain — at runtime, it tries Workload Identity, then Managed Identity, then env vars, then Visual Studio / Azure CLI, then a few more. The first one that works, wins.

In a pod running with Workload Identity, it picks up the projected token automatically. You don't pass anything to it.

using Azure.Identity;

var credential = new DefaultAzureCredential();

That's the entire setup. Every SDK client below takes this credential object. No client IDs in env vars. No client secrets anywhere.

Locally, the same code works because DefaultAzureCredential falls back to your az login or Visual Studio identity. No "dev versus prod" credential plumbing.

Azure Storage — the easiest one

Storage was where I started, because it has clean RBAC support and the SDK is simple.

On Azure (Terraform): grant your Workload Identity the right role on the storage account.

resource "azurerm_role_assignment" "app_storage" {
  scope                = azurerm_storage_account.this.id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = azurerm_user_assigned_identity.app.principal_id
}

The role Storage Blob Data Contributor covers read + write at the data plane. Storage Account Contributor is a different thing — that's the management plane (creating/deleting accounts), which apps almost never need.

In your app:

using Azure.Identity;
using Azure.Storage.Blobs;

var accountUrl = new Uri("https://mystorageaccount.blob.core.windows.net");
var blobService = new BlobServiceClient(accountUrl, new DefaultAzureCredential());

var container = blobService.GetBlobContainerClient("uploads");
await container.UploadBlobAsync("file.txt", BinaryData.FromString("hello"));

No connection string. No SAS token. No account key. Just the account URL — which is public information.

If anyone ever leaked your old account key, they had full access until you rotated it. With RBAC, an attacker who somehow lifts the access token has minutes before it expires — and you can see exactly what was touched in Azure Activity Log.

Service Bus — same idea

Service Bus has the same pattern. Pick a data plane role, grant it, use the SDK.

Roles to know:

  • Azure Service Bus Data Sender — write to queues / topics
  • Azure Service Bus Data Receiver — read from queues / subscriptions
  • Azure Service Bus Data Owner — both, plus management

Grant the narrowest one your app actually needs.

using Azure.Identity;
using Azure.Messaging.ServiceBus;

var ns = "my-namespace.servicebus.windows.net";
await using var client = new ServiceBusClient(ns, new DefaultAzureCredential());

await using var sender = client.CreateSender("orders");
await sender.SendMessageAsync(new ServiceBusMessage("new order"));

That's the whole thing. The SAS-key-based connection string is gone.

Azure SQL — the surprise

This is the one I expected to be a pain, and it wasn't.

Azure SQL supports AAD authentication natively. The trick: RBAC on the SQL resource doesn't grant data plane access. You have to add the identity as a user inside each database, with T-SQL.

Once per database, run as an Azure AD admin:

CREATE USER [id-app] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [id-app];
ALTER ROLE db_datawriter ADD MEMBER [id-app];

id-app here is the name of your Managed Identity. That's how Azure SQL looks it up.

In your app:

using Microsoft.Data.SqlClient;

await using var connection = new SqlConnection(
    "Server=my-server.database.windows.net;" +
    "Database=mydb;" +
    "Encrypt=True;" +
    "Authentication=Active Directory Default;"
);
await connection.OpenAsync();

That's it. No SqlCredential, no manual token plumbing, no client secret. Microsoft.Data.SqlClient ships with Authentication=Active Directory Default built in — under the hood it uses the same DefaultAzureCredential chain you saw above. In a pod with Workload Identity, that means the federated OIDC token gets exchanged for a SQL access token automatically.

The connection string is now server + database + auth mode. No login. No password. No rotation.

What's gone now

After this pattern, here's what gets deleted from your repo and cluster:

  • The connection string Secret for SQL.
  • The storage account key Secret.
  • The Service Bus SAS connection string Secret.
  • The SOPS-encrypted Helm values blocks that wrapped all of them.
  • The "rotate Storage key" runbook.
  • The "rotate SQL password" runbook.
  • Three or four env: blocks in every Deployment.

What stays:

  • DefaultAzureCredential in your app, once.
  • Three or four Terraform azurerm_role_assignment blocks — one per service.
  • A CREATE USER FROM EXTERNAL PROVIDER migration in your SQL setup.

The first time you ship a new service after this, it's noticeable how little credential plumbing is left.

The one trap

Azure RBAC roles on the SQL resource (subscription, resource group, server) don't grant data plane access. SQL DB Contributor lets you manage the database — it does not let your app SELECT a row.

You have to add the Managed Identity as a SQL user inside the database, with T-SQL, as an Azure AD admin. If you skip this step, the connection succeeds, but every query returns Cannot open database "mydb".

The first time it happened to me, I assumed my role assignment was wrong and spent 30 minutes in the Azure Portal poking at IAM blades. The fix was one CREATE USER statement away. Put it in your database migrations and forget about it.

Key takeaways

  • Connection strings are passwords. Workload Identity replaces them with per-pod tokens.
  • DefaultAzureCredential is the single pattern your app code needs. Same line in every app, in every language.
  • Storage and Service Bus: pure RBAC. Grant a data plane role, you're done.
  • Azure SQL: RBAC at the server, plus CREATE USER FROM EXTERNAL PROVIDER inside each database. Easy to miss.
  • The whole kubectl create secret ritual for cloud credentials disappears.

What's next

Not every Azure service supports AAD authentication yet. Storage, Service Bus, SQL, Cosmos, Event Hubs, Key Vault — yes. Some older services and a lot of third-party APIs still need a real secret somewhere. That's where External Secrets Operator comes in — Workload Identity pulls the value from Key Vault, the K8s Secret becomes a short-lived cache, and the rotation story gets clean again.

Two follow-ups in the pipeline:

  • How Azure Workload Identity actually works — the four pieces, the OIDC token flow, and the minimal setup that gets you the per-pod identity this post depends on.
  • External Secrets Operator — the pattern for services that still need a real secret value at runtime.

If you've hit a specific service that fought back against AAD auth, ping me on LinkedIn — happy to dig into it.