September 19, 2026 · 8 min read · Cory Crabb

Windows Auth to SQL Server from ECS, With a Kerberos Sidecar

Disclaimer: Written by Claude

Someone hands you a .NET service, a SQL Server on the corporate network, and a connection string that ends in Integrated Security=true. It has worked for nine years on a domain-joined Windows box. Your job is to move it to ECS Fargate, on Linux, where nothing is domain-joined and nothing ever will be.

The instinct is to give up and ask for a SQL login. Sometimes that's the right call. But often the DBAs have turned SQL authentication off entirely, the account is already provisioned in AD with the right role memberships, and switching auth modes means a change review that outlives your tenure.

So: Kerberos in a sidecar. It works, it's boring once it's running, and almost all of the pain is in the parts nobody writes down.

What SSPI actually needs

"Integrated Security" is two things wearing one coat. On Windows it's SSPI, which will negotiate Kerberos and quietly fall back to NTLM when Kerberos can't work. That fallback is why so many people have never had to learn how any of this works.

On Linux there is no fallback. Microsoft.Data.SqlClient talks GSSAPI through the MIT krb5 libraries, and it needs exactly two things:

  1. A krb5.conf that knows your realm and how to reach a KDC.
  2. A credential cache containing a valid TGT, found via KRB5CCNAME.

The client library will not acquire that ticket for you. There is no connection-string keyword that means "here's a domain password, go get a ticket" — Authentication=ActiveDirectoryPassword is Entra ID, not your on-prem forest. Something outside the library has to run kinit.

That something is the sidecar.

        ECS task
  ┌────────────────────────────────────────┐
  │  ┌───────────┐         ┌────────────┐  │
  │  │  kerberos │  kinit  │    app     │  │
  │  │  sidecar  │────┐    │ (.NET,     │  │
  │  └───────────┘    │    │  Linux)    │  │
  │        │          ▼    └────────────┘  │
  │        │   ┌─────────────┐      │      │
  │        └──▶│  /krb5      │◀─────┘      │
  │            │  ccache     │  read       │
  │            └─────────────┘             │
  └────────────────────────────────────────┘
                     │ TCP 88 / 1433
                     ▼
           KDC + SQL Server (corp network)

One shared volume. The sidecar writes tickets into it; the app reads them. Neither container needs to know the other exists beyond the path.

The keytab

A keytab is the AD service account's long-term key, extracted to a file. It is a password in a trench coat, so treat it like one.

Create it on a domain controller:

ktpass -princ svc-app@CORP.EXAMPLE.COM ^
       -mapuser CORP\svc-app ^
       -pass <password> ^
       -crypto AES256-SHA1 ^
       -ptype KRB5_NT_PRINCIPAL ^
       -out svc-app.keytab

Two things about ktpass that have cost me hours. It changes the account password when you pass -pass, which means any other consumer of that account breaks on the spot. And it increments the key version number, so every previously issued keytab for that principal is now dead. Generate the keytab once, from a service account nothing else uses, and distribute that one file.

Don't bake it into the image. Base64 it into Secrets Manager and let ECS inject it:

"secrets": [
  {
    "name": "KRB5_KEYTAB_B64",
    "valueFrom": "arn:aws:secretsmanager:us-east-1:111122223333:secret:prod/krb5-keytab"
  }
]

Yes, that means the keytab passes through an environment variable. It's a few hundred bytes, it's encrypted in transit and at rest, and the alternative — a layer in ECR that every docker pull copies to disk — is worse.

The sidecar

The whole thing is a small image and a shell loop.

FROM debian:bookworm-slim
RUN apt-get update \
 && apt-get install -y --no-install-recommends krb5-user ca-certificates \
 && rm -rf /var/lib/apt/lists/*
COPY krb5.conf /etc/krb5.conf
COPY renew.sh /usr/local/bin/renew.sh
RUN chmod +x /usr/local/bin/renew.sh
USER 10001:10001
ENTRYPOINT ["/usr/local/bin/renew.sh"]
#!/usr/bin/env bash
set -euo pipefail

: "${KRB5_PRINCIPAL:?}" "${KRB5_KEYTAB_B64:?}"
CCACHE=/krb5/ccache
KEYTAB="$(mktemp)"
trap 'rm -f "$KEYTAB"' EXIT

echo "$KRB5_KEYTAB_B64" | base64 -d > "$KEYTAB"

while true; do
  # kinit into a temp cache, then rename. An in-place write can be observed
  # half-finished by the app and surfaces as a baffling auth failure.
  tmp="$(mktemp -p /krb5 .ccache.XXXXXX)"
  if kinit -k -t "$KEYTAB" -c "FILE:$tmp" "$KRB5_PRINCIPAL"; then
    mv -f "$tmp" "$CCACHE"
    echo "$(date -Is) acquired TGT for $KRB5_PRINCIPAL"
  else
    rm -f "$tmp"
    echo "$(date -Is) kinit FAILED" >&2
    sleep 60
    continue
  fi
  sleep 14400   # 4h; default ticket life is 10h
done

Re-running kinit beats kinit -R. You hold the long-term key, so a fresh TGT is always available and you never have to reason about the renewable lifetime expiring while the task was throttled. If kinit fails, retry on a short interval rather than exiting — a KDC that's briefly unreachable shouldn't take down a healthy task.

Wiring it into the task definition

Three pieces matter: the shared volume, the health check, and the dependency.

{
  "volumes": [{ "name": "krb5" }],
  "containerDefinitions": [
    {
      "name": "kerberos",
      "image": "111122223333.dkr.ecr.us-east-1.amazonaws.com/krb5-sidecar:1",
      "essential": true,
      "user": "10001:10001",
      "mountPoints": [{ "sourceVolume": "krb5", "containerPath": "/krb5" }],
      "environment": [
        { "name": "KRB5_PRINCIPAL", "value": "svc-app@CORP.EXAMPLE.COM" }
      ],
      "healthCheck": {
        "command": ["CMD-SHELL", "KRB5CCNAME=FILE:/krb5/ccache klist -s"],
        "interval": 30, "timeout": 5, "retries": 3, "startPeriod": 30
      }
    },
    {
      "name": "app",
      "image": "111122223333.dkr.ecr.us-east-1.amazonaws.com/app:1",
      "essential": true,
      "user": "10001:10001",
      "mountPoints": [{ "sourceVolume": "krb5", "containerPath": "/krb5", "readOnly": true }],
      "environment": [
        { "name": "KRB5CCNAME", "value": "FILE:/krb5/ccache" }
      ],
      "dependsOn": [{ "containerName": "kerberos", "condition": "HEALTHY" }]
    }
  ]
}

klist -s exits non-zero when there's no valid ticket, which makes it a perfect health check: the sidecar isn't "healthy" until the app could actually authenticate. Pair it with dependsOn: HEALTHY and your app never starts into a ticketless void.

Both containers run as 10001. UIDs are numeric and shared across containers on the same kernel, and a credential cache is mode 600 — mismatch the UIDs and the app gets EACCES on a file it can plainly see. This is the single most common way this setup fails on the first try.

Make the sidecar essential: true. If the renewal loop dies, the task should die with it and get replaced, rather than limping along until the last ticket expires and every query starts failing at 3am.

On Fargate the cache lives on the task's ephemeral storage, which is encrypted at rest. On EC2 launch type you can do better with linuxParameters.tmpfs and keep it out of any filesystem entirely.

krb5.conf, with opinions

[libdefaults]
  default_realm = CORP.EXAMPLE.COM
  dns_lookup_kdc = false
  rdns = false
  udp_preference_limit = 1
  forwardable = true

[realms]
  CORP.EXAMPLE.COM = {
    kdc = dc01.corp.example.com
    kdc = dc02.corp.example.com
  }

Every non-default line there is load-bearing.

udp_preference_limit = 1 forces TCP. AD tickets carry a PAC, the PAC is large, and UDP fragments crossing a VPN or Transit Gateway get dropped in ways that look like a hung connection rather than an error.

rdns = false stops the client from doing a reverse lookup to decide which SPN to ask for. In a VPC, reverse DNS for a corporate IP usually resolves to something like ip-10-0-1-25.ec2.internal or nothing at all, and the client goes looking for an SPN nobody registered.

dns_lookup_kdc = false with explicit KDCs is deliberate. SRV-record discovery means your auth path depends on a resolver configuration you probably don't control, and the failure mode is a 30-second timeout instead of a refusal.

Then DNS ruins your week

The app container needs to resolve sql.corp.example.com and the KDCs. ECS tasks use the VPC resolver, which knows nothing about your forest, so you need Route 53 Resolver outbound endpoints with forwarding rules for the corporate zones. Without this, nothing above works and every error message blames Kerberos.

Then the part that actually bites: the name you connect to must match a registered SPN. SQL Server's service account needs MSSQLSvc/sql.corp.example.com:1433. If you connect to a CNAME, a load balancer name, or a short hostname, the client asks for an SPN that doesn't exist and you get a failure that reads like a credentials problem.

Check it from a domain-joined machine:

setspn -L CORP\svc-sql
setspn -Q MSSQLSvc/sql.corp.example.com:1433

A duplicate registration is worse than a missing one — it fails intermittently, on some tickets and not others, and it will absolutely be the last thing you check.

When you genuinely can't get the SPN and the hostname to agree, Microsoft.Data.SqlClient 5.0+ lets you say it outright:

Server=sql-alias.corp.example.com,1433;
Database=AppDb;
Integrated Security=true;
Encrypt=true;
ServerSPN=MSSQLSvc/sql.corp.example.com:1433;

The app image

Two gotchas. The .NET runtime-deps images don't ship GSSAPI, so Microsoft.Data.SqlClient fails to load libgssapi_krb5.so.2 with an exception that never mentions Kerberos:

RUN apt-get update \
 && apt-get install -y --no-install-recommends libgssapi-krb5-2 krb5-user \
 && rm -rf /var/lib/apt/lists/*

(krb5-user is only there for klist when you're debugging. Drop it once you trust the thing.)

And stay on Debian or Ubuntu bases. Alpine's musl krb5 story has been a recurring source of subtle breakage, and the several hours of savings on image size is not the trade you want here.

Debugging

KRB5_TRACE=/dev/stderr is the whole game. Set it in a task revision and the library narrates every step: which KDC it dialed, which SPN it requested, which encryption type it settled on.

The errors worth memorizing:

SymptomCause
Cannot generate SSPI contextSPN missing, wrong, or duplicated
Server not found in Kerberos databaseConnecting by a name with no matching SPN
KRB5_KDC_UNREACHNo route, no resolver rule, or UDP fragmentation
Preauthentication failedStale keytab — the account password changed
KRB5KRB_AP_ERR_SKEWClock skew over 5 minutes
Permission denied on the ccacheThe two containers run as different UIDs

What this doesn't give you

The app authenticates as the service account, not as the end user. There's no delegation path from a browser session through ECS to SQL Server, so anything built on per-user permissions or SUSER_SNAME() auditing needs rethinking before you start.

Password rotation is the operational tax. When the service account's password changes, every running task holds a dead keytab and begins failing preauth. Either the account gets a non-expiring password with compensating controls, or you build a pipeline that regenerates the keytab into Secrets Manager and forces a service redeploy. Decide which before you ship, because discovering it later means discovering it during an outage.

Worth knowing: AWS also ships credentials-fetcher, a daemon that backs gMSA for Linux containers on ECS, and if your directory is AWS Managed Microsoft AD it's likely the better road — no keytab to protect, no rotation problem. Check the current docs for launch-type support before committing. The sidecar earns its place when the forest belongs to someone else, gMSA isn't on the table, and a keytab is the only credential you're going to be handed.

Which, in my experience, is most of the time.


← back to the index