For software engineers, this workshop explains the mechanism of Kerberos and SPNEGO authentication, which are standard in enterprise environments, by building them using NGINX and Podman.
💡 Glossary: For technical terms such as Kerberos or SPN appearing in this workshop, please refer to the Glossary.
Build a KDC (Key Distribution Center) and an NGINX server on Podman, and understand the flow of "Single Sign-On" where authentication succeeds without password entry from a browser (or curl).
sequenceDiagram
participant C as Client (Host)
participant K as KDC (kdc.test.local)
participant N as NGINX (nginx.test.local)
Note over C,K: (1) kinit (Obtain TGT)
C->>K: AS-REQ (User ID)
K-->>C: AS-REP (TGT)
Note over C,K: (2) Request HTTP Service Ticket
C->>K: TGS-REQ (TGT + SPN)
K-->>C: TGS-REP (Service Ticket)
Note over C,N: (3) SPNEGO Authentication
C->>N: HTTP GET (Authorization: Negotiate <Ticket>)
N-->>C: 200 OK (User: user1@TEST.LOCAL)
What you will learn in this workshop:
- Basic Kerberos components: The roles of KDC, Principals, and Keytabs.
- SPN (Service Principal Name): Linking name resolution and authentication to identify services.
- SPNEGO (Negotiate): The mechanism for performing Kerberos authentication over the HTTP protocol.
- Integrating modules into NGINX: Extending authentication functionality using dynamic modules.
In corporate networks like Active Directory environments, a mechanism that allows automatic login to multiple web services after a single login is essential for both security and convenience.
- Passwords are not sent over the network: Ticket-based authentication reduces the risk of credential leakage.
- Mutual Authentication: Not only the client, but the server can also be verified as authentic.
- Standard Protocol: You can build a common authentication infrastructure across different OSs like Windows, Linux, and macOS.
Using Podman-compose, run two containers, KDC and NGINX, within the same network.
kerberos_lab/
├── docker-compose.yml
├── Dockerfile.kdc
├── Dockerfile.nginx
├── krb5.conf # Shared Kerberos configuration
├── nginx.conf # NGINX configuration with SPNEGO settings
├── index.txt # Fixed response returned after auth
├── kdc-data/ # Persistent KDC DB directory
└── (krb5.keytab) # Generated in STEP 2
# For Ubuntu/Debian
sudo apt update && sudo apt install -y krb5-user curl podman podman-composeAdd the following to the host machine's /etc/hosts so that container names can be resolved.
sudo sh -c 'echo "127.0.0.1 kdc.test.local nginx.test.local" >> /etc/hosts'Create a working directory and place the necessary files.
-
krb5.conf (Kerberos Configuration)
[libdefaults] default_realm = TEST.LOCAL dns_lookup_realm = false dns_lookup_kdc = false dns_canonicalize_hostname = false rdns = false [realms] TEST.LOCAL = { kdc = 127.0.0.1:10088 admin_server = 127.0.0.1 } [domain_realm] .test.local = TEST.LOCAL test.local = TEST.LOCAL
-
nginx.conf (NGINX Configuration)
load_module modules/ngx_http_auth_spnego_module.so; events {} http { server { listen 80; server_name nginx.test.local; root /usr/share/nginx/html; default_type text/plain; location / { auth_gss on; auth_gss_realm TEST.LOCAL; auth_gss_keytab /etc/nginx/krb5.keytab; auth_gss_service_name HTTP/nginx.test.local; auth_gss_allow_basic_fallback off; add_header X-Remote-User $remote_user always; try_files /index.txt =404; } } }
-
index.txt (Fixed response)
SPNEGO Authentication Successful
- Confirmed that
default_realminkrb5.confisTEST.LOCAL. - Confirmed that
ngx_http_auth_spnego_module.sois loaded innginx.conf.
Prepare Dockerfiles to integrate the SPNEGO module into NGINX.
-
Dockerfile.kdc
FROM docker.io/library/ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y krb5-kdc krb5-admin-server COPY krb5.conf /etc/krb5.conf CMD ["/bin/sh", "-c", "if [ ! -f /var/lib/krb5kdc/principal ]; then kdb5_util create -s -P admin_password; fi && exec krb5kdc -n"]
-
Dockerfile.nginx
FROM docker.io/library/nginx:1.25.1 AS builder RUN apt-get update && apt-get install -y git build-essential libkrb5-dev wget libpcre3-dev zlib1g-dev RUN wget http://nginx.org/download/nginx-1.25.1.tar.gz && tar zxvf nginx-1.25.1.tar.gz RUN git clone https://github.com/stnoonan/spnego-http-auth-nginx-module.git WORKDIR /nginx-1.25.1 RUN ./configure --with-compat --add-dynamic-module=../spnego-http-auth-nginx-module && make modules FROM docker.io/library/nginx:1.25.1 RUN apt-get update && apt-get install -y krb5-user && rm -rf /var/lib/apt/lists/* COPY --from=builder /nginx-1.25.1/objs/ngx_http_auth_spnego_module.so /etc/nginx/modules/
-
docker-compose.yml
version: "3" services: kdc: build: context: . dockerfile: Dockerfile.kdc image: localhost/krb_kdc:latest pull_policy: never volumes: - ./kdc-data:/var/lib/krb5kdc ports: - "10088:88" - "10088:88/udp" hostname: kdc.test.local nginx: build: context: . dockerfile: Dockerfile.nginx image: localhost/krb_nginx:latest pull_policy: never ports: - "8080:80" volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./index.txt:/usr/share/nginx/html/index.txt:ro - ./krb5.keytab:/etc/nginx/krb5.keytab:ro - ./krb5.conf:/etc/krb5.conf:ro depends_on: - kdc hostname: nginx.test.local
- Confirmed that
krb5.keytabis mounted to NGINX indocker-compose.yml. - Confirmed that
image: localhost/...andpull_policy: neverare set indocker-compose.yml.
Since NGINX cannot start without a Keytab file (server-side password file), start only the KDC first and generate it.
# Create directories for persistent KDC data
mkdir -p ./kdc-data
# Build images (both KDC and NGINX)
# Due to podman-compose specifications, all images defined in the YAML
# must exist locally even when starting only one service.
podman compose build --no-cache
# Start KDC
podman compose up -d kdc
# Create user principal (Password: userpass)
podman compose exec kdc kadmin.local -q "addprinc -pw userpass user1@TEST.LOCAL"
# Create SPN for NGINX
podman compose exec kdc kadmin.local -q "addprinc -randkey HTTP/nginx.test.local@TEST.LOCAL"
# Export Keytab and extract to host
podman compose exec kdc kadmin.local -q "ktadd -k /tmp/krb5.keytab HTTP/nginx.test.local@TEST.LOCAL"
# Actual container names vary by environment (e.g. krb_kdc_1 / krb5_kdc_1)
KDC_CID=$(podman ps -a -q --filter name=_kdc_ | head -n1)
echo "$KDC_CID" # Verify container ID
podman cp "$KDC_CID":/tmp/krb5.keytab ./krb5.keytab
chmod 644 ./krb5.keytab- Confirmed that
krb5.keytabwas created in the current directory.
After KDC startup and principal creation, quickly verify Kerberos client behavior first.
At this point, kinit only gets a TGT, so HTTP/nginx.test.local@TEST.LOCAL will not appear yet.
export KRB5_CONFIG=$(pwd)/krb5.conf
# Get TGT
kinit user1@TEST.LOCAL
# Password: userpass
# Check ticket
klist
# Destroy ticket
kdestroy
# Confirm destroy (typically shows "No credentials cache found")
klist# Start NGINX (already built in STEP 3, so just up)
podman compose up -d nginx
# 1. Obtain Ticket (TGT)
export KRB5_CONFIG=$(pwd)/krb5.conf
kinit user1@TEST.LOCAL
# Password: userpass
# 2. Verify Ticket (It's OK if krbtgt/TEST.LOCAL@TEST.LOCAL exists)
klist
# 3. Check whether curl supports SPNEGO
curl -V
# Confirm that SPNEGO and GSS-API are included in Features
# 4. Access via SPNEGO authentication
curl -i --negotiate -u : http://nginx.test.local:8080/
# 5. Verify Service Ticket acquisition
# Run this after curl. You should see HTTP/nginx.test.local@TEST.LOCAL added to the list.
klistSuccessful verification points:
X-Remote-User: user1@TEST.LOCALappears in the response headersHTTP/nginx.test.local@TEST.LOCALis added inklistcurl -VshowsSPNEGOandGSS-APIin Features
- Confirmed that in addition to the TGT (
krbtgt/...), the Service Ticket (HTTP/nginx.test.local@...) is displayed withklist. - Confirmed that
X-Remote-Useris present in thecurl -iresponse headers. - Confirmed that
curl -VincludesSPNEGOandGSS-APIin Features.
Behind curl --negotiate, the server also proves it has the correct service key. That is what completes mutual authentication.
sequenceDiagram
participant C as Client (user1)
participant K as KDC (AS/TGS)
participant S as Service (nginx)
Note over C: K_user = user's long-term key (from password)
Note over K: K_tgs = krbtgt key, K_svc = HTTP/nginx.test.local key
C->>K: AS-REQ (+ preauth: encrypt TS with K_user)
K-->>C: AS-REP (TGT{...}_K_tgs + K_c,tgs encrypted with K_user)
C->>K: TGS-REQ (TGT + Authenticator{...}_K_c,tgs + SPN)
K-->>C: TGS-REP (Ticket_svc{...}_K_svc + K_c,s encrypted with K_c,tgs)
C->>S: AP-REQ (Ticket_svc + Authenticator{...}_K_c,s)
S->>S: Decrypt Ticket_svc with K_svc from keytab
S-->>C: AP-REP (encrypt TS+1 with K_c,s)
Note over C,S: Mutual auth succeeds if C can decrypt AP-REP with K_c,s
-
AS-REQ / AS-REP (Get TGT)
- Client encrypts preauth (timestamp) with
K_user. - KDC decrypts preauth with
K_userto verify the user. - KDC encrypts
K_c,tgs(client-TGS session key) withK_user. - KDC encrypts the TGT body with
K_tgs(client cannot read TGT contents directly).
- Client encrypts preauth (timestamp) with
-
TGS-REQ / TGS-REP (Get service ticket)
- Client encrypts Authenticator with
K_c,tgs. - KDC decrypts TGT with
K_tgs, obtainsK_c,tgs, and decrypts Authenticator. - KDC encrypts
K_c,s(client-service session key) withK_c,tgs. - KDC encrypts
Ticket_svcwithK_svc(only the service can decrypt it).
- Client encrypts Authenticator with
-
AP-REQ / AP-REP (Service access + mutual auth)
- Client sends
Ticket_svcand Authenticator (encrypted withK_c,s). - Service decrypts
Ticket_svcusingK_svcin keytab and obtainsK_c,s. - Service decrypts Authenticator with
K_c,sto verify the client. - Service returns AP-REP (typically
TS+1) encrypted withK_c,s. - Client decrypts AP-REP with
K_c,sto verify the server.
- Client sends
With curl -v --negotiate, if WWW-Authenticate: Negotiate <token> appears in the final 200 OK response, AP-REP is returned, which indicates mutual auth completed.
curl --negotiate -u : -v http://nginx.test.local:8080/ -o /dev/nullReference output (key lines)
< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Negotiate
...
> Authorization: Negotiate YIIF... # Client AP-REQ
< HTTP/1.1 200 OK
< WWW-Authenticate: Negotiate YIGC... # Server AP-REP (mutual auth)
If 200 OK is returned but the second WWW-Authenticate: Negotiate <token> is missing, mutual-auth token return may not be happening (check module behavior/config).
podman compose down
rm krb5.keytab
# Delete entries in /etc/hosts manually if needed- MIT Kerberos Documentation
- spnego-http-auth-nginx-module
- RFC 4559 - SPNEGO-based Kerberos HTTP Authentication in Microsoft Windows
- Cause: No read permission for the Keytab file, or the path is incorrect.
- Solution: Run
chmod 644 ./krb5.keytaband double-check theauth_gss_keytabpath innginx.conf.
- Cause: TGT not obtained with
kiniton the host machine, orKRB5_CONFIGis not set correctly. - Solution: Check for tickets with
klist, and ensurecurlis run in the same terminal session whereexport KRB5_CONFIG=$(pwd)/krb5.confwas executed. Also runcurl -Vand confirm thatSPNEGOandGSS-APIare included in Features. If they are missing, thatcurlbuild cannot use--negotiate.
- Cause:
curlsyntax error (-u:) or host mismatch (localhost) prevents the SPNEGO flow from starting. - Solution: Use
curl --negotiate -u : -v http://nginx.test.local:8080/exactly. Use-u :(with a space), not-u:. Usenginx.test.local, notlocalhost.
- Cause: Basic fallback is enabled in the NGINX SPNEGO module settings.
- Solution: Add
auth_gss_allow_basic_fallback off;to the targetlocationinnginx.conf, then apply withpodman compose up -d --force-recreate nginx.
- Cause: Host canonicalization (canonicalize/reverse DNS) or proxy routing can make
nginx.test.localbe treated aslocalhost. - Solution: In
[libdefaults]ofkrb5.conf, setdns_canonicalize_hostname = falseandrdns = false. Also check proxy settings withenv | grep -i proxy, and if needed rununset http_proxy https_proxy HTTP_PROXY HTTPS_PROXYbefore retrying.
- Cause: Rootless Podman cannot publish privileged ports below 1024 (such as 88).
- Solution A (default in this tutorial): Use a high host port. This tutorial uses
10088:88(TCP/UDP) andkdc = 127.0.0.1:10088from the beginning. - Solution B: Run rootful Podman or change sysctl. Examples:
sudo podman compose up -d kdc,sudo sysctl -w net.ipv4.ip_unprivileged_port_start=88
- Cause: In
podman exec -it $(podman ps -q -f name=kdc) ..., the$(...)part is empty, sokadmin.localis interpreted as a container name. - Solution: Use
podman compose exec kdc .... Forpodman cp, get the ID viapodman ps -a -q --filter name=_kdc_and verify withecho "$KDC_CID". Names vary by environment (e.g.krb_kdc_1,krb5_kdc_1), so confirm withpodman ps -a --format '{{.ID}} {{.Names}}'if needed.
- Cause: On
podman compose v1.0.6, auto-generated image-name resolution can be unstable, causing either unqualified-name errors or failed pulls tohttps://localhost/v2. - Solution: Explicitly set
image: localhost/krb_nginx:latestandpull_policy: neverindocker-compose.yml(same for KDC). Do not rely onup --build; runpodman compose build --no-cache nginxand thenpodman compose up -d nginxas separate commands.
- Cause: Due to
podman composespecifications, all images listed indocker-compose.ymlmust exist locally, even when starting only a specific service (e.g.,kdc). - Solution: Run
podman compose build --no-cacheto build all images first. Both KDC and NGINX images are required.
- When accessing from a browser on the Windows host side, you will need
hostsand Kerberos settings on the Windows side as well. It is recommended to complete the workshop within the WSL2 terminal (curl).