Apache GSSAPI SSO with FreeIPA
HTTP/fqdn.lower.case@REALM.IN.UPPER.CASE, and the keytab's KVNO must match what's in FreeIPA right now. If you rotate the keytab in IPA and don't re-fetch, you get a spooky-silent 401 where klist -k looks fine.
Architecture
Kerberos SSO needs three cooperating systems:
- FreeIPA (the KDC) — issues service and user tickets. Covered in FreeIPA.
- The Apache host — holds a keytab for the
HTTP/www.example.comservice principal and runsmod_auth_gssapi. - The browser — either domain-joined (Windows workstation that already has a TGT) or a Linux workstation that has run
kinit user@REALM. The browser performs an SPNEGO handshake automatically when Apache sendsWWW-Authenticate: Negotiate.
In practice on Rocky/Alma/RHEL 9 we also run gssproxy — it holds the keytab out of Apache's address space so a rogue module can't leak the key. Apache then talks to gssproxy over a Unix socket.
Install mod_auth_gssapi
dnf install -y httpd mod_auth_gssapi mod_session gssproxy krb5-workstation
# Debian/Ubuntu:
# apt install -y apache2 libapache2-mod-auth-gssapi gss-ntlmssp gssproxy
Sanity-check the KDC side from this host before you touch Apache at all:
kinit alice@EXAMPLE.COM
klist
kvno HTTP/www.example.com@EXAMPLE.COM
# If kvno succeeds, your krb5.conf and DNS are right.
/etc/krb5.conf should already be written by ipa-client-install. If not, the minimum is:
[libdefaults]
default_realm = EXAMPLE.COM
dns_lookup_realm = true
dns_lookup_kdc = true
rdns = false
[realms]
EXAMPLE.COM = {
kdc = ipa.example.com
master_kdc = ipa.example.com
admin_server = ipa.example.com
}
[domain_realm]
.example.com = EXAMPLE.COM
example.com = EXAMPLE.COM
Create the HTTP service principal in FreeIPA
On the FreeIPA server (or any admin-enrolled host):
kinit admin
ipa host-show www.example.com &>/dev/null || \
ipa host-add www.example.com --password --random
ipa service-add HTTP/www.example.com
ipa service-allow-retrieve-keytab HTTP/www.example.com --users=admin
Common mistakes here:
HTTPmust be upper-case; the FQDN must be lower-case.http/Www.Example.Comlooks similar and fails silently.- The Apache host must already be enrolled in IPA (
ipa host-find). If not, doipa-client-installfirst — see FreeIPA. - If you front Apache with a load balancer, the SPN must match the name in the URL, not the backend hostname. Add a DNS A record and an IPA host entry for the VIP name.
Fetch and install the keytab
On the Apache host:
kinit admin
ipa-getkeytab -s ipa.example.com \
-p HTTP/www.example.com \
-k /etc/httpd/conf/http.keytab
# Lock it down. gss-proxy reads it as root; Apache never does.
chown root:root /etc/httpd/conf/http.keytab
chmod 0600 /etc/httpd/conf/http.keytab
restorecon -v /etc/httpd/conf/http.keytab
# Verify:
klist -kte /etc/httpd/conf/http.keytab
The output should show the principal and a current KVNO. Compare it with what FreeIPA thinks:
kvno -k /etc/httpd/conf/http.keytab HTTP/www.example.com@EXAMPLE.COM
# If the number here disagrees with klist -kte, you have a stale keytab.
ipa-getkeytab rotates the key. Do not run it casually — every running service that relies on the keytab must get the new copy immediately, or existing tickets issued with the old key will fail to accept.
httpd.conf: GssapiCredStore, sessions
Drop a file under conf.d:
# /etc/httpd/conf.d/sso.conf
<VirtualHost *:443>
ServerName www.example.com
DocumentRoot /var/www/html
SSLEngine on
SSLCertificateFile /etc/pki/tls/certs/www.example.com.crt
SSLCertificateKeyFile /etc/pki/tls/private/www.example.com.key
# Session cookies so we don't re-negotiate on every single request:
Session On
SessionCookieName SessionIPA path=/;HttpOnly;Secure
SessionHeader X-Replace-Session
SessionCryptoPassphraseFile /etc/httpd/session.key
<Location />
AuthType GSSAPI
AuthName "Kerberos SSO"
GssapiCredStore keytab:/etc/httpd/conf/http.keytab
GssapiCredStore client_keytab:/etc/httpd/conf/http.keytab
GssapiUseSessions On
GssapiSessionKey file:/etc/httpd/session.key
GssapiDelegCcacheDir /var/lib/gssproxy/clients
# Apache 2.4 style:
Require valid-user
</Location>
# Expose REMOTE_USER to the app (stripped of realm):
RequestHeader set X-Remote-User "%{REMOTE_USER}s"
</VirtualHost>
Pre-generate the session key (used to encrypt the Session cookie and to cache the SPNEGO negotiation):
dd if=/dev/urandom of=/etc/httpd/session.key bs=32 count=1
chown root:apache /etc/httpd/session.key
chmod 0640 /etc/httpd/session.key
Why both keytab and client_keytab? The first is used when Apache acts as a service (accepting incoming tickets). The second is used when Apache needs to act on behalf of the logged-in user to contact a downstream service (constrained delegation) — harmless if you don't use it.
SELinux booleans & gss-proxy
On RHEL-family with SELinux in enforcing, Apache is confined and cannot read arbitrary keytabs. Two options:
Option A (recommended): gss-proxy
setsebool -P gssd_read_tmp on
setsebool -P httpd_use_gssapi on
# /etc/gssproxy/80-httpd.conf
[service/HTTP]
mechs = krb5
cred_store = keytab:/etc/httpd/conf/http.keytab
cred_store = client_keytab:/etc/httpd/conf/http.keytab
cred_store = ccache:FILE:/var/lib/gss-proxy/clients/krb5cc_%U
euid = apache
socket = /var/lib/gssproxy/default.sock
allow_any_uid = yes
systemctl enable --now gssproxy
systemctl restart httpd
Apache now asks gss-proxy (via the socket) to do all GSSAPI work. It never holds the key itself.
Option B: give httpd direct keytab access
setsebool -P httpd_use_gssapi on
chown root:apache /etc/httpd/conf/http.keytab
chmod 0640 /etc/httpd/conf/http.keytab
semanage fcontext -a -t httpd_sys_content_t '/etc/httpd/conf/http.keytab'
restorecon -v /etc/httpd/conf/http.keytab
Simpler but the keytab file is inside the Apache process's reachable world. Fine for small deployments, gss-proxy is better once you have more than one GSSAPI-using service on the box. See SELinux for the general audit-to-policy workflow when you get denials.
Fallback to form / basic auth
External users, mobile devices and anything not joined to the IPA realm cannot do SPNEGO. Give them a working path:
<Location />
AuthType GSSAPI
AuthName "SSO"
GssapiCredStore keytab:/etc/httpd/conf/http.keytab
GssapiBasicAuth On
GssapiBasicAuthMech krb5
GssapiAllowedMech krb5
GssapiLocalName On
Require valid-user
</Location>
GssapiBasicAuth On is the clever bit: when the browser refuses SPNEGO, Apache falls back to HTTP Basic auth and verifies the user/password by doing a Kerberos AS-REQ on their behalf. From the app's perspective REMOTE_USER still contains the IPA username; it just got there via a password rather than a ticket.
If you prefer a real HTML login form, combine with mod_auth_form:
<Location /login>
SetHandler form-login-handler
AuthFormLoginRequiredLocation "/login-form.html"
AuthFormProvider file
AuthUserFile /etc/httpd/htpasswd
AuthType form
AuthName "Login"
Session On
SessionCookieName formcookie path=/
</Location>
Browser negotiate-auth config
Edge / Chrome on Windows
Group Policy or registry:
HKLM\SOFTWARE\Policies\Microsoft\Edge\AuthServerAllowlist = *.example.com
HKLM\SOFTWARE\Policies\Google\Chrome\AuthServerAllowlist = *.example.com
By default these browsers only honour Negotiate auth for sites in the Intranet Zone. Add *.example.com to the Intranet Zone via Control Panel → Internet Options, or via GPO (Site to Zone Assignment List).
Firefox
about:config:
network.negotiate-auth.trusted-uris=.example.comnetwork.negotiate-auth.delegation-uris=.example.com(only if you use constrained delegation)
Linux workstations
kinit alice@EXAMPLE.COM
klist # TGT present, good
# Then open Firefox/Chrome as that user; SPNEGO works.
Testing end-to-end
# 1. Fail first — no ticket:
curl -v https://www.example.com/
# Expect: 401 WWW-Authenticate: Negotiate
# 2. Get a ticket:
kinit alice@EXAMPLE.COM
# 3. Succeed:
curl -v --negotiate -u : https://www.example.com/
# Expect: 200 and the app seeing REMOTE_USER=alice@EXAMPLE.COM
# 4. From the server side — tail the SSO debug log:
tail -f /var/log/httpd/ssl_error_log | grep -i -E 'gssapi|kerberos'
If step 3 fails but step 1 works, the problem is in the ticket exchange, not Apache config. Go to Troubleshooting.
Troubleshooting
| Symptom / log | Root cause | Fix |
|---|---|---|
NO_CRED: gss_accept_sec_context() failed: Major (851968): ... No credentials cache found |
SELinux denies Apache reading the keytab, or gss-proxy can't see it. | setsebool -P httpd_use_gssapi on. If using gss-proxy, verify allow_any_uid = yes in its drop-in and systemctl restart gssproxy httpd. |
Server not found in Kerberos database |
SPN does not exist, or case mismatch (http/ vs HTTP/). |
ipa service-find HTTP/www.example.com. Recreate with correct case. |
Key version number for principal in key table is incorrect / KVNO mismatch |
Keytab rotated but old copy still on disk; or multiple places have different copies. | klist -kte vs kvno HTTP/www.example.com; re-run ipa-getkeytab on one host, rsync to others. |
| Browser prompts for a username/password instead of SSO | Browser has no Negotiate-auth allowlist entry, or user has no TGT. | Check klist. Configure allowlist / Intranet Zone. See Browser config. |
Unsupported mechanism requested in logs |
Client sending NTLM instead of Kerberos. | Usually a non-domain Windows machine or an older Safari. Fall back to form auth; or on Windows verify the host is actually domain-joined. |
Auth works for browser but REMOTE_USER in PHP/Python app is empty |
CGI env stripped by proxy, or GssapiLocalName On not set. |
Set RequestHeader set X-Remote-User "%{REMOTE_USER}s" and read that in the app. With WSGI/uwsgi, ensure PassEnv REMOTE_USER. |
| Intermittent 401s under load | Session cookie not shared — you have multiple Apache backends without shared SessionCryptoPassphraseFile. |
Deploy the same session.key to every backend, or offload sessions to a shared store. |
GSSAPI Error: Unspecified GSS failure. Minor code may provide more information (Decrypt integrity check failed) |
Classic KVNO mismatch between ticket and keytab. | Re-fetch keytab, restart Apache; force a fresh kinit on the client to avoid cached ticket from old KVNO. |
| Works on direct request, fails behind a load balancer | SPN in ticket matches VIP FQDN but backend hostname mismatch, or sticky sessions off. | Add SPN for the VIP, keep sticky sessions on for the SSO cookie path, or use shared session crypto (above). |