Get started
API scopes
Integration guides
Features
Troubleshooting
Java / Spring Boot
Spring Security's OAuth 2.0 Client support handles Kenni without any custom code — point its provider config at your discovery URL and it does the rest. A few Kenni-specific defaults need wiring (PKCE on confidential clients, RFC 9068 access tokens, audience validation), all covered below.
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
application.yml
spring:
security:
oauth2:
client:
provider:
kenni:
issuer-uri: ${KENNI_ISSUER:}
registration:
kenni:
provider: kenni
client-id: ${KENNI_CLIENT_ID:}
client-secret: ${KENNI_CLIENT_SECRET:}
authorization-grant-type: authorization_code
redirect-uri: '{baseUrl}/login/oauth2/code/{registrationId}'
scope:
- openid
- profile
- national_id
- offline_access
Setting issuer-uri lets Spring discover endpoints, JWKS, and supported features automatically.
{baseUrl} is a Spring URL template — it expands to http://<request-host>:<port> at sign-in time. Register https://<your-host>/login/oauth2/code/kenni as a redirect URI on your application in the developer portal. Changing the kenni registration ID requires re-registering a different redirect URI.
Default ${KENNI_*} placeholders to empty (${KENNI_ISSUER:}) and validate at startup. Unresolved ${KENNI_ISSUER} strings get treated as URI templates by Spring's UriComponentsBuilder and surface as confusing IllegalArgumentException: Not enough variable values available errors elsewhere.
When the API scope is conditional
Apps that conditionally request a custom API scope can't express that in YAML — scope: is a fixed list. Drop spring.security.oauth2.client.* from YAML and build a ClientRegistrationRepository @Bean programmatically:
@Bean
ClientRegistrationRepository clientRegistrations(KenniProperties kenni) {
var scopes = new LinkedHashSet<>(List.of("openid", "profile", "national_id", "offline_access"));
if (kenni.apiEnabled()) scopes.add(kenni.apiScope());
var registration = ClientRegistrations.fromIssuerLocation(kenni.issuer())
.registrationId("kenni")
.clientId(kenni.clientId())
.clientSecret(kenni.clientSecret())
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
.scope(scopes)
.build();
return new InMemoryClientRegistrationRepository(registration);
}
The Spring Boot auto-config bean is @ConditionalOnMissingBean, so this suppresses the YAML-driven setup. Without it, an empty ${KENNI_API_SCOPE} either gets appended literally or is silently dropped — and /api/protected-resource returns 401 forever even after a successful sign-in.
Wire PKCE for confidential clients
Spring Security 6 auto-enables PKCE only for public clients (client-authentication-method: none). Confidential clients (client_secret_basic / client_secret_post) must wire it in explicitly. Kenni requires PKCE — without this, the auth flow fails on token exchange and Spring redirects to its default /login?error page with the misleading message "Invalid credentials".
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers;
private static OAuth2AuthorizationRequestResolver pkceAwareResolver(
ClientRegistrationRepository repo) {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(
OAuth2AuthorizationRequestCustomizers.withPkce());
return resolver;
}
…then plug it into oauth2Login (see the security configuration below).
Security configuration
Mix UI (cookie-session OIDC) and API (bearer JWT) endpoints with two SecurityFilterChain beans, each scoped by securityMatcher and ordered:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain apiChain(HttpSecurity http, JwtDecoder jwtDecoder) throws Exception {
return http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.anyRequest().hasAuthority("SCOPE_" + System.getenv("KENNI_API_SCOPE")))
.oauth2ResourceServer(rs -> rs.jwt(jwt -> jwt.decoder(jwtDecoder)))
.csrf(csrf -> csrf.disable())
.build();
}
@Bean
@Order(2)
SecurityFilterChain uiChain(HttpSecurity http,
ClientRegistrationRepository clients) throws Exception {
var logoutSuccess = new OidcClientInitiatedLogoutSuccessHandler(clients);
logoutSuccess.setPostLogoutRedirectUri("{baseUrl}/");
return http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2Login(o -> o.authorizationEndpoint(a ->
a.authorizationRequestResolver(pkceAwareResolver(clients))))
.logout(logout -> logout.logoutSuccessHandler(logoutSuccess))
.build();
}
}
Spring's default JwtAuthenticationConverter maps the scope claim to authorities prefixed with SCOPE_. Use .hasAuthority("SCOPE_<your-scope>") — not .hasRole(...) and not .hasAuthority("<your-scope>").
Verifying access tokens (JwtDecoder)
Two Kenni-specific defaults need overriding on the resource-server JwtDecoder:
- RFC 9068 token type. Kenni issues access tokens with
"typ": "at+jwt"per RFC 9068. Spring's defaultNimbusJwtDecoderaccepts only"typ": "JWT"(or absent) and rejects everything else withWWW-Authenticate: Bearer error="invalid_token", error_description="JOSE header typ (type) at+jwt not allowed". - Audience validation.
issuer-urialone validates issuer + signature + expiry but not audience. Without audience validation, any Kenni token from the same tenant — including id_tokens or access tokens minted for other APIs — will pass.
@Bean
JwtDecoder jwtDecoder(@Value("${KENNI_ISSUER}") String issuer,
@Value("${KENNI_API_AUDIENCE}") String audience) {
var decoder = NimbusJwtDecoder.withIssuerLocation(issuer)
.jwtProcessorCustomizer(processor -> processor.setJWSTypeVerifier(
new DefaultJOSEObjectTypeVerifier<>(
JOSEObjectType.JWT,
new JOSEObjectType("at+jwt"))))
.build();
var withIssuer = JwtValidators.createDefaultWithIssuer(issuer);
var withAudience = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD, aud -> aud != null && aud.contains(audience));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, withAudience));
return decoder;
}
On Spring Boot 3.4+ you can replace the JwtClaimValidator block with the audiences: property:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${KENNI_ISSUER}
audiences:
- ${KENNI_API_AUDIENCE}
…but you still need the jwtProcessorCustomizer for at+jwt, so a custom @Bean is usually simpler.
Reading user claims
Inject @AuthenticationPrincipal OidcUser principal into your controller methods. principal.getClaims() returns everything from the ID token; principal.getIdToken().getTokenValue() returns the raw JWT.
Calling APIs with the access token
@RegisteredOAuth2AuthorizedClient("kenni") exposes the user's authorized client, including the access token:
@GetMapping("/api/me")
public Map<String, Object> me(
@RegisteredOAuth2AuthorizedClient("kenni") OAuth2AuthorizedClient client) {
String accessToken = client.getAccessToken().getTokenValue();
// Forward the token to a downstream service…
return Map.of("token", accessToken);
}
Client credentials (M2M)
For machine-to-machine calls, hit the token endpoint directly with HTTP Basic auth:
@Bean
RestClient kenniTokenClient(@Value("${KENNI_ISSUER}") String issuer) {
return RestClient.builder().baseUrl(issuer).build();
}
public String fetchClientCredentialsToken(RestClient client,
String clientId, String clientSecret,
String scope) {
var basic = Base64.getEncoder()
.encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
Map<String, Object> body = client.post()
.uri("/oidc/token")
.header(HttpHeaders.AUTHORIZATION, "Basic " + basic)
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("grant_type=client_credentials&scope=" + URLEncoder.encode(scope, UTF_8))
.retrieve()
.body(new ParameterizedTypeReference<>() {});
return (String) body.get("access_token");
}
For credentials with special characters, the form-urlencoded Basic auth from RFC 6749 §2.3.1 requires base64(form_urlencode(id) + ":" + form_urlencode(secret)).
Sign out
OidcClientInitiatedLogoutSuccessHandler (wired in the UI chain above) redirects logout through Kenni's end_session_endpoint. It requires the servlet stack — not WebFlux. Spring's default logout responds to POST /logout; a GET /logout link returns 404.
Register the resolved post-logout URL (e.g. https://<your-host>/) on the application's Post logout redirect URIs list in the developer portal. See the No framework (curl) guide for the full reasoning.
Troubleshooting
- "Invalid credentials" on
/login?error— Spring's default error page hides the cause. Setlogging.level.org.springframework.security: DEBUG(orTRACE) to see the actual reason. The most common culprit on a fresh Kenni integration is missing PKCE. HTTP 400 — Request header is too large— Tomcat's default 8KB header limit overflows after a couple of failed sign-in attempts (staleOAUTH2_AUTHORIZATION_REQUESTcookies pile up). Bump it:server.max-http-request-header-size: 16KB.- Empty 401 / 403 bodies — Spring's resource-server returns the actual reason in the
WWW-Authenticateheader (error="insufficient_scope",error="invalid_token"). JS proxies should fall back to that header when the upstream body is empty.