Windows Server for beginners. Part 30: How does Kerberos single sign-on work?

All parts of the series “Windows Server for beginners”

In part 29 alice signed in to lnx1 with her password, and after sign-in she had a TGT. Part 6 described how a TGT gets you a ticket to any service without presenting the password again. On Windows this happens by itself: \\srv1\share, RDP, WinRM, LDAP — nobody ever asks for a password. On Linux and across platforms the same is possible, but it isn't on by default and needs an understanding of two things: how an application gets to Kerberos and how a service gets its key. This part is about single sign-on from the Linux side: SSH, the web, databases and everything that speaks GSSAPI.


GSSAPI and Negotiate

Applications don't work with Kerberos directly. Between them sits GSSAPI (Generic Security Services API, RFC 2743): a standard interface behind which there can be Kerberos (the mechanism is described in RFC 4121), NTLM or something else. The application asks to "establish a security context with host/lnx1.corp.example.com", the library gets a ticket by itself, builds an AP-REQ and returns a token that the application sends to the server over its own protocol; the server hands the token to its library, which checks it with the key from the keytab and returns the client's name. The application doesn't care what is inside the token: it only carries bytes back and forth.

On top of GSSAPI sits SPNEGO (RFC 4178), also known as Negotiate: mechanism negotiation for when the two sides may know different ones. The client's first token lists the mechanisms it supports and already carries a token for the preferred one (usually Kerberos); the server answers which one was chosen. Windows uses SPNEGO everywhere, Linux where it needs to be compatible with Windows. Where it is built in:

Protocol Mechanism What it looks like
SSH gssapi-with-mic (RFC 4462) GSSAPIAuthentication yes on both sides
HTTP Negotiate (RFC 4559) the WWW-Authenticate: Negotiate header, the token in Authorization
LDAP SASL GSSAPI or GSS-SPNEGO part 5
SMB SPNEGO in Session Setup Samba with security = ads, smbclient --use-kerberos=required
PostgreSQL gss in pg_hba.conf SPN postgres/host
NFSv4 sec=krb5, krb5i, krb5p SPN nfs/host, rpc.gssd
RPC and DCOM on Windows Negotiate everything from part 15

SASL in the LDAP row is a framework for pluggable authentication mechanisms inside a protocol: the client names a mechanism (GSSAPI — Kerberos through GSSAPI, GSS-SPNEGO — the same through SPNEGO) and exchanges its tokens in the Bind operation. SMB is the Windows file sharing protocol; its Linux implementation is Samba. PostgreSQL is a database server with its own protocol over TCP 5432. NFS is the Unix network file system, in version 4 with authentication through RPCSEC_GSS; more on these three below. RPC and DCOM are Windows remote procedure calls, which the management consoles and WMI rely on.

The library on Linux is MIT Kerberos (libkrb5, libgssapi_krb5), on Windows it is SSPI, and they are compatible at the protocol level because there is only one Kerberos.


SSH

Linux → Linux. lnx1 has had host/lnx1.corp.example.com in its keytab since it joined; sshd needs to be allowed to use the mechanism:

$ sudo tee /etc/ssh/sshd_config.d/60-gssapi.conf <<'EOF'
GSSAPIAuthentication yes
GSSAPICleanupCredentials yes
EOF
$ sudo systemctl reload ssh

GSSAPICleanupCredentials yes (also the default) deletes the user's forwarded TGT when the session closes. Another default, GSSAPIStrictAcceptorCheck yes, requires the ticket to be issued for exactly host/<this server's name>: sshd won't accept a ticket for another name from the same keytab.

On a client where alice has a TGT (a password sign-in through sssd or kinit alice), signing in looks like an ordinary ssh:

alice@lnx2:~$ ssh -K lnx1.corp.example.com hostname

-K turns on GSSAPI and delegation for one call; permanently, it goes into ~/.ssh/config or /etc/ssh/ssh_config.d/:

Host *.corp.example.com
    GSSAPIAuthentication yes
    GSSAPIDelegateCredentials yes

The OpenSSH client tries GSSAPI first (PreferredAuthentications starts with gssapi-with-mic by default), and keys and passwords remain fallback methods where there is no ticket. What the exchange looks like in ssh -v is shown in "Looking at it by hand".

Delegation here is the same as in part 16: along with the ticket for the server the client forwards the TGT, it travels to lnx1, and there klist -f shows the f (forwarded) flag on the TGT, after which you can go further from lnx1 — to \\srv1, to lnx3, to LDAP — as alice. And the same risk: the user's TGT is on the server, and anyone who is root there can use it. The MIT Kerberos client forwards the TGT when asked, regardless of what the directory thinks of the server (enforce_ok_as_delegate = false is the default in krb5.conf). The Windows client doesn't: per the MS-KILE specification it forwards the TGT only to a server whose ticket carries the ok_as_delegate flag, and the KDC sets that flag for accounts trusted for unconstrained delegation (part 6). Domain controllers have that trust; LNX1$ doesn't.

Windows → Linux. The built-in Windows client from part 16 with GSSAPIAuthentication yes in ~\.ssh\config signs in to lnx1 with a ticket from the Windows session: LNX1$ has the SPN host/lnx1.corp.example.com, the keytab matches, and the Windows client doesn't care what sshd is on the other side. The user name is -l alice or User alice in the same section: without it the client sends corp\alice, and the Linux server knows no such user. Delegation (-K) from Windows to lnx1 won't happen, for the reason above: the client asks honestly, gets a refusal from its own library, and the session on lnx1 has no ticket. Turning on unconstrained delegation for LNX1$ just for this isn't worth it — then the TGT of everyone who signs in to the server stays on it; if you need a ticket on lnx1, kinit in the session is simpler.

Linux → Windows. ssh -K dc1.corp.example.com to Windows OpenSSH with GSSAPI enabled works the same way: a domain controller always has the SPN host/dc1.corp.example.com, and Windows sshd checks the ticket through SSPI.


Caches and working without a password

On Linux the TGT lives in a credential cache, and its type is set in krb5.conf (default_ccache_name). Three options: FILE:/tmp/krb5cc_%{uid} — a file, the classic, one per user; KEYRING:persistent:%{uid} — in the kernel keyring, invisible through the file system; KCM: — a daemon (for sssd it is sssd-kcm) that keeps caches for all users and supports several caches per user. RHEL and Fedora with sssd use KCM: the sssd-kcm package puts default_ccache_name = KCM: into /etc/krb5.conf.d/. Ubuntu stays with files by default: on a password sign-in pam_sss puts the TGT into /tmp/krb5cc_<uid>_<random suffix> and sets KRB5CCNAME in the session, and without it the library looks for /tmp/krb5cc_<uid>. klist shows the current cache, klist -A all of them, kdestroy deletes, KRB5CCNAME switches.

The ticket appears on a password sign-in through pam_sss and does not appear on a sign-in with an SSH key or with GSSAPI without delegation: sssd has no password at that moment, so there is nothing to get a TGT with. This is the first reason for "I signed in, and klist is empty" — what it looks like is also in "Looking at it by hand". The way out is kinit by hand or a sign-in with delegation.

Services that need their own tickets for scheduled access to the directory or an SMB share don't need a password: the key from the keytab replaces it:

$ sudo kinit -k -t /etc/krb5.keytab 'LNX1$@CORP.EXAMPLE.COM'
$ sudo klist

After that klist shows LNX1$@CORP.EXAMPLE.COM as the default principal and a single krbtgt/CORP.EXAMPLE.COM ticket issued for the lifetime set by domain policy (10 hours by default). This is how an SMB backup or a script that reads LDAP works as the machine; in cron, put kinit -k before the command, or use k5start for long-running processes, or gssproxy, which hands tickets to services without giving them the keytab.


SPNs and keytabs for services

A service on Linux that users will sign in to with Kerberos needs a class/name@REALM principal in the directory and its key in a keytab the service can read.

For the machine's own name many classes already exist, even though servicePrincipalName doesn't show them. The sPNMappings attribute of the CN=Directory Service,CN=Windows NT,CN=Services object in the configuration partition declares dozens of classes aliases of host, http among them: for a request for HTTP/lnx1.corp.example.com the KDC issues a ticket under the LNX1$ key, although the account only has host/lnx1.corp.example.com recorded. A site under a different name (intranet.corp.example.com) needs an explicit SPN. Two ways.

On the machine account. The SPN is added to LNX1$, the key is the same as for host/, and adcli also writes entries with the new name into the keytab:

$ sudo adcli update --add-service-principal=HTTP/intranet.corp.example.com --verbose
$ sudo klist -k | grep HTTP

After that klist -k shows HTTP/intranet.corp.example.com@CORP.EXAMPLE.COM with the same KVNO as host/, one line per encryption type. The SPN can also be added from the Windows side — setspn -S HTTP/intranet.corp.example.com lnx1 — but that changes only the directory, no keytab entry appears; adcli does both.

The machine keytab belongs to root; the web server needs a separate file with just the keys it needs: ktutil with rkt /etc/krb5.keytab, delent for the extra ones, wkt /etc/apache2/http.keytab, and the permissions chown www-data, chmod 0400. Such a copy has a shelf life: sssd changes the machine password every 30 days (ad_maximum_machine_account_password_age), the KVNO in the directory goes up, /etc/krb5.keytab is updated, but the copy isn't, and a month later the web server stops decrypting tickets. The copy has to be rebuilt after every password change; it's more reliable to give the service the key through gssproxy or to create a separate account for it.

On a separate account. For services that live on several nodes behind a load balancer (one SPN must belong to one principal, while the key must be on every node), or that need rights of their own — a domain account svc-intranet, with its keytab made on Windows:

PS C:\> New-ADUser -Name svc-intranet -SamAccountName svc-intranet -UserPrincipalName HTTP/intranet.corp.example.com@CORP.EXAMPLE.COM -Enabled $true -AccountPassword (Read-Host -AsSecureString) -PasswordNeverExpires $true -KerberosEncryptionType AES128, AES256
PS C:\> ktpass -princ HTTP/intranet.corp.example.com@CORP.EXAMPLE.COM -mapuser CORP\svc-intranet -crypto AES256-SHA1 -ptype KRB5_NT_PRINCIPAL -pass * -out C:\Temp\intranet.keytab

ktpass registers the SPN, sets the UPN, changes the account's password to the one given in -pass (an asterisk means prompt) and writes the key to the file; the key version number vno goes up, and older keytabs for this account stop working. The file is copied to the nodes with the same permissions as above. On Linux msktutil does the same, creating the account and the keytab right on that side.

The group managed service accounts from part 13 aren't supported by sssd and adcli: Linux services are left with the machine keytab and an ordinary account with a long password changed on a schedule with ktpass or msktutil --auto-update.


A web application

Apache with the mod_auth_gssapi module turns any application into a single sign-on application: the browser of a domain user presents a ticket, Apache checks it with its keytab and passes the name to the application in REMOTE_USER.

$ sudo apt install libapache2-mod-auth-gssapi
$ sudo tee /etc/apache2/conf-available/intranet-auth.conf <<'EOF'
<Location />
    AuthType GSSAPI
    AuthName "Corp intranet"
    GssapiCredStore keytab:/etc/apache2/http.keytab
    GssapiBasicAuth Off
    GssapiNegotiateOnce On
    GssapiLocalName On
    GssapiAllowedMech krb5
    Require valid-user
</Location>
EOF
$ sudo a2enmod auth_gssapi && sudo a2enconf intranet-auth && sudo systemctl reload apache2

GssapiLocalName On passes alice rather than alice@CORP.EXAMPLE.COM; GssapiBasicAuth Off doesn't offer a password prompt to those without a ticket (with On, Apache would accept a password and fetch a ticket itself — a fallback for machines outside the domain, but then the password goes through the web server); GssapiNegotiateOnce On doesn't repeat the Negotiate challenge if the first token didn't work. The exchange per RFC 4559:

GET / HTTP/1.1
Host: intranet.corp.example.com

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Negotiate

GET / HTTP/1.1
Host: intranet.corp.example.com
Authorization: Negotiate <base64: SPNEGO token with an AP-REQ for HTTP/intranet.corp.example.com>

HTTP/1.1 200 OK
WWW-Authenticate: Negotiate <base64: SPNEGO response with an AP-REP>

The first response is the challenge; the second request carries a SPNEGO token with an AP-REQ for HTTP/intranet.corp.example.com; the 200 response carries an AP-REP for mutual authentication. No password, no sign-in form. A real exchange with a web server in the domain is in "Looking at it by hand".

A browser doesn't send a ticket to just any site, only to permitted ones. Chrome and Edge on Windows without a policy ask the Windows zones: by default these are sites of the "Local intranet" zone — names without dots and whatever the "Site to Zone Assignment List" policy adds; an explicit list is set by the AuthServerAllowlist policy (formerly AuthServerWhitelist). Chrome on Linux and macOS uses the same policy or the --auth-server-allowlist=*.corp.example.com switch; Firefox uses network.negotiate-auth.trusted-uris. As long as a site isn't on the list, instead of single sign-on the user sees a password prompt or, if the server offers only Negotiate, a 401 page. Delegation from the browser (so that the web application goes further on the user's behalf) is a separate list, AuthNegotiateDelegateAllowlist, and it is off by default.

The name a browser asks a ticket for is HTTP/<host name>; the class is always HTTP, regardless of TLS. Which host name is up to the client: Chrome and Edge resolve CNAMEs by default and ask for a ticket for the canonical name (a CNAME intranet → lnx1 produces a request for HTTP/lnx1.corp.example.com; the DisableAuthNegotiateCnameLookup policy turns that off), while curl and many other clients use the name from the URL. If no account has the requested SPN, the KDC answers KDC_ERR_S_PRINCIPAL_UNKNOWN (part 6), and the client falls back to NTLM, which Apache doesn't have — that is, just a 401. It is most reliable when the site is opened by a name that has an SPN and the keytab on the server holds that account's key. nginx has no built-in module; people usually install spnego-http-auth-nginx-module or move authentication to a proxy in front of nginx.


Delegation from the Linux side

A web application on lnx1 has to reach SQL Server or an SMB share on behalf of the signed-in user. Unconstrained delegation from the browser is almost never turned on; the right path is constrained delegation, and MIT Kerberos supports it since version 1.8 through the S4U2Self and S4U2Proxy extensions from part 6. On the directory side it is the same as for Windows:

PS C:\> Set-ADUser svc-intranet -Add @{ 'msDS-AllowedToDelegateTo' = 'MSSQLSvc/sql1.corp.example.com:1433', 'cifs/fs1.corp.example.com' }
PS C:\> Set-ADAccountControl svc-intranet -TrustedToAuthForDelegation $true     # with protocol transition, if the user didn't come with Kerberos

On Linux mod_auth_gssapi has GssapiDelegCcacheDir — it stores delegated tickets (if the browser sent one) or tickets obtained through S4U2Proxy (GssapiUseS4U2Proxy On) in a directory, from which the application takes them through KRB5CCNAME. An application in Python, Java or .NET on Linux gets a ticket for MSSQLSvc/sql1 as alice without her password and without her TGT — through the svc-intranet keytab of its own. Resource-based delegation (msDS-AllowedToActOnBehalfOfOtherIdentity on sql1) works the same way and is configured by the owner of the resource, as in part 14.


Other services

PostgreSQL. The SPN postgres/db1.corp.example.com on the machine account (adcli update --add-service-principal=postgres/db1.corp.example.com), a keytab for the postgres user, krb_server_keyfile = '/etc/postgresql/krb5.keytab' in postgresql.conf, and hostgssenc all all 192.168.0.0/16 gss include_realm=0 krb_realm=CORP.EXAMPLE.COM in pg_hba.conf. alice with a TGT connects with psql -h db1.corp.example.com without a password, and the connection is also encrypted by GSS (hostgssenc accepts only such connections). The role name in the database must match the user name without the realm. From Windows it's the same psql or any libpq client, with the ticket from the session.

NFS. The SPN nfs/nfs1.corp.example.com on the server, rpc.gssd on the clients, an export with sec=krb5p: each user accesses files with their own ticket, the server checks it and substitutes the UID from sssd — unlike NFS with sec=sys, where the server trusts whatever UID the client names. krb5 is authentication only, krb5i adds integrity of every request, krb5p adds encryption. Windows Server can be such a server too: Server for NFS accepts krb5, krb5i and krb5p and maps Windows users to UIDs and GIDs through directory attributes (uidNumber, gidNumber).

Samba. A Linux file server with security = ads joins the domain as a member (through net ads join or together with sssd), gets the SPN cifs/, and Windows clients open its folders with a ticket, like the folders of srv1: to them there is no difference.


When the password prompt comes back

The main tool on the Linux side is the MIT Kerberos library trace:

$ KRB5_TRACE=/dev/stderr ssh -K lnx1.corp.example.com true

The KRB5_TRACE variable makes any program built with libkrb5 write every step to the given file: which cache was chosen and with which principal, whether it holds a TGT, which SPN was requested, which KDC (the name from the SRV record and the address) the TGS-REQ went to, what came back, which encryption type the session key has and whether the ticket was stored in the cache. The errors you see there and what they mean:

In the trace or the response Cause
Server not found in Kerberos database no account has an SPN with that name; often the library canonicalized the name through DNS and asked for host/lnx1-old.corp… — fixed with rdns = false and dns_canonicalize_hostname = fallback in [libdefaults] of krb5.conf
Wrong principal in request, KRB_AP_ERR_MODIFIED, Decrypt integrity check failed the service can't decrypt the ticket: the kvno in the keytab lags behind the directory (the machine password changed but the keytab or its copy didn't — adcli update), the SPN is on a different account, a duplicate SPN (setspn -X)
No credentials cache found, No Kerberos credentials available the user has no TGT: signed in with a key or with GSSAPI without delegation, KRB5CCNAME points elsewhere, or the ticket expired
Clock skew too great time (part 12)
KDC has no support for encryption type the keytab has an RC4 key while the account allows only AES, or the other way round: klist -k -e and msDS-SupportedEncryptionTypes
Cannot find KDC for realm DNS: no _kerberos._tcp SRV record, or dns_lookup_kdc = false without [realms]
Permission denied (gssapi-with-mic) with a valid ticket sshd refused: no GSSAPIAuthentication yes, the keytab is unreadable (permissions), or the principal didn't map to a local user; in the log of a successful sign-in this is the line Authorized to alice, krb5 principal alice@CORP.EXAMPLE.COM (krb5_kuserok), the mapping is done by the sssd localauth module, and a foreign realm needs .k5login or auth_to_local

Checking an SPN and a keytab without an application is kvno: it asks the KDC for a ticket for the given SPN and prints its key version number, one line host/lnx1.corp.example.com@CORP.EXAMPLE.COM: kvno = 2 per name. To compare with the service's keytab — sudo klist -k /etc/apache2/http.keytab: if the kvno in the ticket is higher than in the keytab, the keytab is out of date. Without a TGT kvno fails right away with the same error as klist:

alice@lnx1:~$ kvno host/lnx3.corp.example.com
kvno: No credentials cache found (filename: /tmp/krb5cc_1838401104) while getting client principal name

Looking at it by hand

Signing in from the workstation ws1 to lnx1 with the built-in Windows client, with GSSAPIAuthentication yes for *.corp.example.com in ~\.ssh\config:

PS C:\> ssh -v -l alice lnx1.corp.example.com true 2>&1 | Select-String 'Authenticat|gssapi|delegat'

debug1: Authenticating to lnx1.corp.example.com:22 as 'alice'
debug1: get_agent_identities: ssh_get_authentication_socket: No such file or directory
debug1: Authentications that can continue: publickey,gssapi-keyex,gssapi-with-mic,password
debug1: Next authentication method: gssapi-with-mic
Authenticated to lnx1.corp.example.com ([192.168.1.60]:22) using "gssapi-with-mic".

The server offered four methods, the client took gssapi-with-mic straight away and got in on the first attempt; the agent line only means that ssh-agent isn't running on the workstation — no keys were needed. gssapi-keyex is GSSAPI key exchange from a patch Ubuntu adds to OpenSSH; it is turned off on the server. In the Windows cache after the sign-in there is a ticket for host/lnx1, next to it, for comparison, a ticket for the controller's LDAP:

PS C:\> klist | Select-String -Context 0,3 'host/lnx1|ldap/dc1\.corp\.example\.com @'

> 	Server: host/lnx1.corp.example.com @ CORP.EXAMPLE.COM
  	KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96
  	Ticket Flags 0x40a10000 -> forwardable renewable pre_authent name_canonicalize
  	Start Time: 10/30/2026 12:39:53 (local)
> 	Server: ldap/dc1.corp.example.com @ CORP.EXAMPLE.COM
  	KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96
  	Ticket Flags 0x40a50000 -> forwardable renewable pre_authent ok_as_delegate name_canonicalize
  	Start Time: 10/30/2026 10:25:26 (local)

The controller's ticket has ok_as_delegate, the one for lnx1 doesn't. So with -K the Windows client asks for delegation and doesn't get it:

PS C:\> ssh -v -K -l alice lnx1.corp.example.com true 2>&1 | Select-String 'Authenticat|gssapi|delegat'

debug1: Authenticating to lnx1.corp.example.com:22 as 'alice'
debug1: get_agent_identities: ssh_get_authentication_socket: No such file or directory
debug1: Authentications that can continue: publickey,gssapi-keyex,gssapi-with-mic,password
debug1: Next authentication method: gssapi-with-mic
debug1: Delegating credentials
debug1: sspi delegation was requested but not fulfilled
debug1: Delegating credentials
debug1: sspi delegation was requested but not fulfilled
Authenticated to lnx1.corp.example.com ([192.168.1.60]:22) using "gssapi-with-mic".

The sign-in succeeded, the TGT stayed on ws1. The session on lnx1 has no cache at all:

PS C:\> ssh -l alice lnx1.corp.example.com klist
klist: No credentials cache found (filename: /tmp/krb5cc_1838401104)

The same after a key sign-in; after a password sign-in there would be a /tmp/krb5cc_1838401104_<suffix> file from pam_sss.

On lnx1 — the effective sshd settings (rather than what the files say) and the log:

alice@lnx1:~$ sudo sshd -T | grep -i gssapi
gssapiauthentication yes
gssapicleanupcredentials yes
gssapistrictacceptorcheck yes
gssapikeyexchange no
gssapistorecredentialsonrekey no
gssapikexalgorithms gss-group14-sha256-,gss-group16-sha512-,gss-nistp256-sha256-,gss-curve25519-sha256-
alice@lnx1:~$ sudo journalctl -u ssh --since -15min -o short-iso --no-pager | grep 'sshd-session\[537477\]' | grep -vi disconnect
2026-10-30T09:40:31+00:00 lnx1.corp.example.com sshd-session[537477]: Authorized to alice, krb5 principal alice@CORP.EXAMPLE.COM (krb5_kuserok)
2026-10-30T09:40:31+00:00 lnx1.corp.example.com sshd-session[537477]: Accepted gssapi-with-mic for alice from 192.168.1.50 port 62513 ssh2: alice@CORP.EXAMPLE.COM
2026-10-30T09:40:31+00:00 lnx1.corp.example.com sshd-session[537477]: pam_unix(sshd:session): session opened for user alice(uid=1838401104) by alice(uid=0)
2026-10-30T09:40:31+00:00 lnx1.corp.example.com sshd-session[537477]: pam_unix(sshd:session): session closed for user alice

First sshd mapped the principal alice@CORP.EXAMPLE.COM to the local alice (krb5_kuserok), then accepted the sign-in and appended the principal to the end of the Accepted line. Over a month this server's log has 101 gssapi-with-mic sign-ins, 12 with a key and 4 with a password:

alice@lnx1:~$ sudo journalctl -u ssh --since -30days --no-pager | grep -oE 'Accepted [a-z-]+' | sort | uniq -c
    101 Accepted gssapi-with-mic
      4 Accepted password
     12 Accepted publickey

The keytab sshd used to decrypt the ticket (-e shows encryption types; klist doesn't print the keys themselves):

alice@lnx1:~$ sudo klist -k -e /etc/krb5.keytab
Keytab name: FILE:/etc/krb5.keytab
KVNO Principal
---- --------------------------------------------------------------------------
   2 LNX1$@CORP.EXAMPLE.COM (aes128-cts-hmac-sha1-96)
   2 LNX1$@CORP.EXAMPLE.COM (aes256-cts-hmac-sha1-96)
   2 host/LNX1@CORP.EXAMPLE.COM (aes128-cts-hmac-sha1-96)
   2 host/LNX1@CORP.EXAMPLE.COM (aes256-cts-hmac-sha1-96)
   2 host/lnx1.corp.example.com@CORP.EXAMPLE.COM (aes128-cts-hmac-sha1-96)
   2 host/lnx1.corp.example.com@CORP.EXAMPLE.COM (aes256-cts-hmac-sha1-96)
   2 RestrictedKrbHost/LNX1@CORP.EXAMPLE.COM (aes128-cts-hmac-sha1-96)
   2 RestrictedKrbHost/LNX1@CORP.EXAMPLE.COM (aes256-cts-hmac-sha1-96)
   2 RestrictedKrbHost/lnx1.corp.example.com@CORP.EXAMPLE.COM (aes128-cts-hmac-sha1-96)
   2 RestrictedKrbHost/lnx1.corp.example.com@CORP.EXAMPLE.COM (aes256-cts-hmac-sha1-96)

And the same account in the directory — the SPNs, the allowed encryption types (24 = AES128 + AES256), the key version number and trust for delegation:

PS C:\> Get-ADComputer lnx1 -Properties servicePrincipalName, msDS-SupportedEncryptionTypes, msDS-KeyVersionNumber, TrustedForDelegation |
    Format-List Name, servicePrincipalName, msDS-SupportedEncryptionTypes, msDS-KeyVersionNumber, TrustedForDelegation

Name                          : LNX1
servicePrincipalName          : {RestrictedKrbHost/lnx1.corp.example.com, RestrictedKrbHost/LNX1, host/lnx1.corp.example.com, host/LNX1}
msDS-SupportedEncryptionTypes : 24
msDS-KeyVersionNumber         : 2
TrustedForDelegation          : False

The KVNO in the directory and in the keytab match — 2, TrustedForDelegation is False, hence the delegation refusal above. The ticket issue shows up on the controller as event 4769; its ServiceName field is the service account, not the SPN:

PS C:\> Invoke-Command -ComputerName dc1 -ScriptBlock {
    Get-WinEvent -FilterHashtable @{LogName='Security'; Id=4769; StartTime=(Get-Date).AddHours(-2)} |
        Where-Object { $_.Properties[2].Value -eq 'LNX1$' } | Select-Object -First 1 | ForEach-Object {
            ([xml]$_.ToXml()).Event.EventData.Data |
                Where-Object Name -in 'ServiceName','TicketOptions','TicketEncryptionType','Status','TransmittedServices','ServiceSupportedEncryptionTypes','ServiceAvailableKeys' |
                ForEach-Object { '{0} = {1}' -f $_.Name, $_.'#text' }
        }
}
ServiceName = LNX1$
TicketOptions = 0x40810000
TicketEncryptionType = 0x12
Status = 0x0
TransmittedServices = -
ServiceSupportedEncryptionTypes = 0x18 (AES128-SHA96, AES256-SHA96)
ServiceAvailableKeys = RC4, AES128-SHA96, AES256-SHA96, AES128-SHA256, AES256-SHA384

The ticket is encrypted with AES256 (0x12), TransmittedServices is empty — this is a direct request, not S4U. If the user went on from lnx1 with a delegated ticket, the 4769 event for cifs/fs1 would have lnx1 as the client address rather than her workstation: that is how delegation shows up in the audit.

Last — HTTP with Negotiate. A web application web1 on IIS with Windows authentication; a request without credentials gets the list of methods:

PS C:\> curl.exe -s -v -o NUL https://web1.corp.example.com/ 2>&1 | Select-String '^[<>] (GET|HTTP|Authorization|WWW-Authenticate|Location)'

> GET / HTTP/1.1
< HTTP/1.1 401 Unauthorized
< WWW-Authenticate: Bearer
< WWW-Authenticate: Basic realm="https://web1.corp.example.com/"
< WWW-Authenticate: Negotiate
< WWW-Authenticate: NTLM

With --negotiate -u : (empty credentials are the signal to take the ticket from the session), curl, given a single method, sends the token right away without waiting for a 401; the tokens below are truncated:

PS C:\> curl.exe -s -v -o NUL --negotiate -u : https://web1.corp.example.com/ 2>&1 | Select-String '^[<>] (GET|HTTP|Authorization|WWW-Authenticate|Location)'

> GET / HTTP/1.1
> Authorization: Negotiate YIIHuwYGKwYBBQUCoIIHrzCC…
< HTTP/1.1 302 Found
< Location: https://web1.corp.example.com/home/
< WWW-Authenticate: Negotiate oYG2MIGzoAMKAQChCwYJKoZI…

YII… is the start of a SPNEGO NegTokenInit (followed by the SPNEGO OID 1.3.6.1.5.5.2 and the AP-REQ inside), about two kilobytes together with the PAC; the oYG… response is a NegTokenResp with the "accepted" state and an AP-REP. The application answered with a redirect rather than a 401: the user was recognized. A ticket for HTTP/web1 appeared in the ws1 cache:

PS C:\> klist | Select-String -Context 0,3 'HTTP/'

> 	Server: HTTP/web1.corp.example.com @ CORP.EXAMPLE.COM
  	KerbTicket Encryption Type: AES-256-CTS-HMAC-SHA1-96
  	Ticket Flags 0x40a10000 -> forwardable renewable pre_authent name_canonicalize
  	Start Time: 10/30/2026 12:43:59 (local)

although web1 has no HTTP/… SPN in the directory:

PS C:\> (Get-ADComputer web1 -Properties servicePrincipalName).servicePrincipalName
TERMSRV/web1.corp.example.com
WSMAN/web1.corp.example.com
RestrictedKrbHost/web1.corp.example.com
HOST/web1.corp.example.com
TERMSRV/WEB1
WSMAN/WEB1
RestrictedKrbHost/WEB1
HOST/WEB1

This is sPNMappings at work: http is an alias of host, and the KDC found the account by HOST/web1.corp.example.com.


Sources

GSSAPI, SPNEGO and the protocols:

  • RFC 2743 — GSSAPI: security contexts and tokens the application carries itself;
  • RFC 4121 — the Kerberos V5 mechanism for GSSAPI;
  • RFC 4178 — SPNEGO: mechanism negotiation, NegTokenInit and NegTokenResp;
  • RFC 4462 — SSH authentication through GSSAPI, the gssapi-with-mic method and gssapi-keyex key exchange;
  • RFC 4559 — HTTP Negotiate: WWW-Authenticate and Authorization with a SPNEGO token;
  • RFC 4120 — Kerberos V5: the forwardable, forwarded and ok-as-delegate ticket flags;
  • [MS-KILE]: Request Flags Details and Ticket Flag Details — the Windows client forwards the TGT only to a server with OK-AS-DELEGATE, the KDC sets the flag for accounts trusted for delegation.

MIT Kerberos:

  • krb5.conf — default_ccache_name, rdns, dns_canonicalize_hostname, enforce_ok_as_delegate, dns_lookup_kdc, localauth modules;
  • Credential cache — the FILE, DIR, KEYRING and KCM cache types and the KRB5CCNAME variable;
  • keytab — what a keytab holds and how ktutil works with it;
  • kinit, klist and kvno — kinit -k, ticket flags in klist -f, klist -k -e, the key version number;
  • .k5login — which principals may sign in as a local user;
  • Troubleshooting — KRB5_TRACE tracing;
  • krb5-1.8 — S4U2Self and S4U2Proxy in MIT Kerberos.

OpenSSH:

SSSD and the machine account:

  • sssd-krb5 — krb5_ccachedir, krb5_ccname_template, the default cache from krb5.conf;
  • sssd-ad — ad_maximum_machine_account_password_age: the machine password changes every 30 days;
  • sssd-kcm — the KCM daemon and default_ccache_name = KCM:;
  • adcli — adcli update, --add-service-principal, the machine keytab;
  • msktutil — service accounts and keytabs from the Linux side, --auto-update;
  • gssproxy — services use keys without reading the keytab;
  • kstart — k5start for long-running processes.

The directory:

The web:

Other services:


One directory, one authentication for Windows, Linux, Wi-Fi, VPN and web applications. One frontier is left, one that in most organizations has long existed next to the domain and lives by its own rules: the cloud.

Next — part 31: Entra Connect. What Entra ID is next to Active Directory and why connect them; what exactly is synchronized and what isn't; three authentication methods — password hash synchronization, pass-through authentication and federation; UPNs and verified domains; filtering by OU and attributes; installing Entra Connect and the synchronization account; and Seamless SSO — Kerberos for the cloud.

Only registered users can leave a comment.

Sign in or register to leave a comment.