Apache GSSAPI SSO with FreeIPA

Kerberos single sign-on for Apache using mod_auth_gssapi, a FreeIPA-issued HTTP/ keytab, gss-proxy, the SELinux booleans nobody remembers, and a clean fallback to form auth for browsers that aren't domain-joined.

SPN case and keytab freshness are 90% of the failures. The service principal must be 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:

  1. FreeIPA (the KDC) — issues service and user tickets. Covered in FreeIPA.
  2. The Apache host — holds a keytab for the HTTP/www.example.com service principal and runs mod_auth_gssapi.
  3. 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 sends WWW-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:

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.
Every 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:

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 / logRoot causeFix
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).
Reusable. Once this is working on one host, repeat: new SPN per FQDN, one keytab, same sso.conf. Cross-link: Apache for baseline config, FreeIPA for realm and CA, SELinux for the boolean workflow if you see AVC: denied.