Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Kerberos & SPNEGO

Seamless Windows domain authentication: how Kerberos tickets work, SPNEGO negotiation over HTTP, keytab and SPN setup, Spring configuration, and diagnosing the usual failures.

Advanced6 min readUpdated
On this page

On a Windows domain, Kerberos gives the best sign-in experience available: the user opens the application and is already authenticated, with no prompt. The cost is infrastructure setup that is unforgiving about details.

Key Takeaways

  • The browser obtains a service ticket and presents it; the user sees nothing.
  • The SPN must exactly match the hostname the browser used.
  • The keytab holds the service account's key — treat it as a credential.
  • Browsers need explicit configuration to send tickets to your host.
  • Always ship a form-login fallback for non-domain clients.

How it works

The browser does the work using the ticket-granting ticket obtained at Windows login. The user never sees a credential prompt.

The security property is that the application never handles the user's password. It decrypts a ticket issued by the domain controller, and possession of a valid ticket is the proof.

Clock skew is the other classic failure, and it produces a different symptom from an SPN mismatch. Kerberos tickets carry timestamps and the default tolerance is five minutes, so a host whose clock has drifted past that rejects every ticket with a time-related error rather than an authentication one. Keep NTP running and monitored on the application hosts as well as the domain controllers — this is a failure that arrives all at once, for everybody, without a deploy.

Domain setup

on a domain controller
# A dedicated service account. Never use a user account or a domain admin.
New-ADUser -Name "svc-acme-app" -AccountPassword (Read-Host -AsSecureString) `
           -PasswordNeverExpires $true -Enabled $true
 
# The SPN must match the hostname browsers will use, exactly.
setspn -S HTTP/app.corp.example.com svc-acme-app
 
# Export the keytab. AES256 only — RC4 is deprecated and weak.
ktpass -princ HTTP/app.corp.example.com@CORP.EXAMPLE.COM `
       -mapuser svc-acme-app@CORP.EXAMPLE.COM `
       -crypto AES256-SHA1 -ptype KRB5_NT_PRINCIPAL `
       -pass * -out acme-app.keytab

Three details cause most failures. The SPN must match the URL — if users reach https://app.corp.example.com then the SPN is HTTP/app.corp.example.com, not the short name and not a load balancer alias unless that is what they type. One SPN per account — a duplicate SPN registered to two accounts breaks authentication for both, and setspn -X finds duplicates. And the keytab is invalidated by any password change on the service account, so set the password to never expire or plan the rotation.

Spring configuration

KerberosConfig.java
@Configuration
public class KerberosConfig {
 
    @Value("${app.kerberos.service-principal}")
    private String servicePrincipal;      // HTTP/app.corp.example.com@CORP.EXAMPLE.COM
 
    @Value("${app.kerberos.keytab-location}")
    private String keytabLocation;
 
    @Bean
    SpnegoEntryPoint spnegoEntryPoint() {
        // Falls back to the login page when the browser does not negotiate,
        // so non-domain users are not left staring at a 401.
        return new SpnegoEntryPoint("/login");
    }
 
    @Bean
    SunJaasKerberosTicketValidator ticketValidator() {
        var validator = new SunJaasKerberosTicketValidator();
        validator.setServicePrincipal(servicePrincipal);
        validator.setKeyTabLocation(new FileSystemResource(keytabLocation));
        validator.setDebug(false);        // true only while diagnosing
        return validator;
    }
 
    @Bean
    KerberosServiceAuthenticationProvider kerberosProvider(
            SunJaasKerberosTicketValidator validator,
            UserDetailsService userDetailsService) {
        var provider = new KerberosServiceAuthenticationProvider();
        provider.setTicketValidator(validator);
        // Kerberos proves identity; your directory supplies authorities.
        provider.setUserDetailsService(userDetailsService);
        return provider;
    }
 
    @Bean
    SecurityFilterChain chain(HttpSecurity http,
                              AuthenticationManager authenticationManager,
                              SpnegoEntryPoint entryPoint) throws Exception {
        var spnegoFilter = new SpnegoAuthenticationProcessingFilter();
        spnegoFilter.setAuthenticationManager(authenticationManager);
 
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/css/**").permitAll()
                .anyRequest().authenticated())
            .exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint))
            .addFilterBefore(spnegoFilter, BasicAuthenticationFilter.class)
            // The fallback matters: laptops off the domain, contractors,
            // and anyone on a browser that will not negotiate.
            .formLogin(form -> form.loginPage("/login").permitAll());
 
        return http.build();
    }
}

Resolving authorities

Kerberos tells you the principal name and nothing else. Roles come from the directory:

KerberosUserDetailsService.java
@Service
public class DomainUserDetailsService implements UserDetailsService {
 
    private final LdapTemplate ldap;
 
    @Override
    public UserDetails loadUserByUsername(String principal) {
        // The principal arrives as user@CORP.EXAMPLE.COM.
        String samAccountName = principal.substring(0, principal.indexOf('@'));
 
        var attributes = ldap.searchForObject(
                "", "(sAMAccountName=" + LdapEncoder.filterEncode(samAccountName) + ")",
                new AttributesMapper<>(attrs -> attrs));
 
        List<String> groups = memberOf(attributes);
 
        return User.withUsername(samAccountName)
                // No password is ever involved — the ticket was the proof.
                .password("{noop}N/A")
                .authorities(groupMapper.map(groups))
                .build();
    }
}

Browser configuration

Browsers will not send Kerberos tickets to arbitrary hosts. The site must be in a trusted zone:

Chrome and Edge on domain-joined Windows use the Internet Options zone settings, deployed by group policy: add the site to Local Intranet and enable automatic logon.

Firefox needs network.negotiate-auth.trusted-uris set to your domain, either in about:config or through an enterprise policy file.

macOS and Linux clients need a local ticket from kinit and the browser configured similarly.

Without this, the browser silently declines to negotiate and the user falls through to your form login. That is a working outcome, but if nobody gets the seamless experience the group policy is usually the reason.

Diagnosing

terminal
# Confirm the keytab contains the expected principal and encryption type
klist -kte /etc/security/acme-app.keytab
 
# Try to obtain a ticket with the keytab — proves the keytab is valid
kinit -kt /etc/security/acme-app.keytab HTTP/app.corp.example.com@CORP.EXAMPLE.COM
 
# On Windows, inspect the client's cached tickets
klist
JVM Kerberos debugging
-Dsun.security.krb5.debug=true -Djava.security.krb5.conf=/etc/krb5.conf
SymptomUsual cause
"Defective token detected"SPN mismatch or a stale keytab
"Clock skew too great"More than five minutes drift; run NTP
No Authorization: Negotiate headerBrowser zone not configured
"KDC has no support for encryption type"Keytab uses RC4 while the domain requires AES
Works by hostname, fails by IPKerberos requires the name the SPN was registered for

Clock skew deserves the same attention as with SAML: Kerberos tickets have tight validity windows, and a host five minutes out of sync fails every authentication with an error that does not mention time.

Load balancers

A load balancer complicates SPNs. If users reach app.corp.example.com which balances across three hosts, the SPN must be registered for the load-balanced name and every backend must share the same keytab — not one per host.

Terminating TLS at the balancer is fine; SPNEGO is an HTTP header and passes through. What does not work is a balancer that rewrites the Host header, since the ticket is bound to the name the client requested.

What to take away

Register one SPN matching exactly the hostname users type, on a dedicated service account with AES encryption. Guard the keytab like a password and regenerate it after any change. Configure browsers through group policy, keep clocks synchronised, and always ship a form-login fallback — a meaningful share of clients will never negotiate.

Frequently Asked Questions

Is Kerberos still worth using?
For intranet applications on a Windows domain, yes — it gives genuinely seamless sign-in with no prompt at all, which nothing else matches. For anything reachable from outside the corporate network, prefer OIDC or SAML; Kerberos does not work across the internet without a VPN.
Why do I get "Defective token detected"?
Almost always an SPN or keytab mismatch. The SPN must exactly match the hostname the browser used, including case and whether it is fully qualified, and it must be registered to the account whose keytab your application loads. Regenerate the keytab after any SPN change.
Does SPNEGO work in every browser?
Chrome and Edge on a domain-joined Windows machine work with group policy configuration. Firefox needs network.negotiate-auth.trusted-uris set explicitly. Browsers on macOS and Linux need a local Kerberos ticket. Always provide a form-login fallback.

Related tutorials