In any modern application, you’ll encounter multiple services talking to each other and even communicating with third-party services to provide useful functionalities. Some of these services may expose endpoints (also called resources) to serve data and perform actions of varying risk, cost and criticality. Therefore, it becomes prudent to protect the endpoints to provide appropriate access to the clients to reduce the chances of misuse and security breaches. OAuth2 is the industry standard for providing authorization. Spring Security provides an OAuth2 Resource Server starter that we can use to implement an authorization layer.
If you’re looking for an introduction on OAuth2 and OpenID Connect, please review the following video.
In this post, we’ll discuss how to implement a service-to-service authorization flow using client-credentials grant type and audience claim.
We’ll discuss multiple ways of validating a token through Spring Security and the scenarios where one approach makes sense over the other.
The examples in this post use
- Java 15
- Spring Boot 2.4.0
- httpie 2.3.0
- Maven 3.6.3
httpie is a user-friendly HTTP client with first-class JSON support and many other features. We’ll use it to send requests to our APIs.
We’ll use Okta as the identity provider (IdP) but you can use any other provider that supports the aud
claim and introspection.
Okta setup
To get started with Okta, create a developer account and login to your dashboard. Open the Application tab and click on the Add Application button.

On the Create New Application screen, select Web and press Next.

On the next screen, provide a name for the app, scroll down till Grant type allowed section, and check Client Credentials and Implicit (Hybrid) options. Press Done.

Open the newly created application. You should find the Client ID and Client Secret under the General tab. Copy these values somewhere; you’d need them later.

Open Authorization Servers (available under the API tab). Under the Settings tab , you’ll find the audience configured for the server and the issuer URL. Copy these values somewhere; you’d need them later.
Switch to the Scopes tab, and click on the Add Scope button. Add a scope with the name read:messages
and check Include in public metadata option.

Similarly, add another scope with the name write:messages
. This finishes Okta setup.
Generate Maven project
Generate a Maven project with the following pom.xml
.
xml version="1.0" encoding="UTF-8"?>
<?project xmlns="http://maven.apache.org/POM/4.0.0"
<xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
modelVersion>4.0.0</modelVersion>
<
parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.0</version>
<relativePath/> <!-- lookup parent from repository -->
<parent>
</
groupId>dev.mflash.guides</groupId>
<artifactId>spring-security-token-validation-hybrid</artifactId>
<version>1.0-SNAPSHOT</version>
<
properties>
<java.version>15</java.version>
<properties>
</
dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<dependency>
</dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<dependency>
</dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
<dependency>
</dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>8.26</version>
<scope>runtime</scope>
<dependency>
</
dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
<dependency>
</dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<dependency>
</dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
<dependency>
</dependencies>
</
build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<plugin>
</plugins>
</build>
</
project>
</
Create a configuration file application.yml
in the src/main/resources
directory.
Local token validation
To validate a token locally, we need to decode it and write the validation logic based on the properties of the received token. Spring Security can initialize a decoder using the URL of the issuer
of the token. You can obtain the issuer URL from the Authorization Servers > default > Settings tab from the Okta dashboard. Open the application.yml
file and configure this URL as follows.
# src/main/resources/application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://dev-4273429.okta.com/oauth2/default
Create a SecurityConfiguration
class and inject a JwtDecoder
bean.
// src/main/java/dev/mflash/guides/tokenval/local/security/SecurityConfiguration.java
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final String issuer;
public SecurityConfiguration(OAuth2ResourceServerProperties resourceServerProps) {
this.issuer = resourceServerProps.getJwt().getIssuerUri();
}
@Bean JwtDecoder jwtDecoder() {
var jwtDecoder = (NimbusJwtDecoder) JwtDecoders.fromOidcIssuerLocation(issuer);
OAuth2TokenValidator<Jwt> validatorWithIssuer = JwtValidators.createDefaultWithIssuer(issuer);
var validator = new DelegatingOAuth2TokenValidator<>(validatorWithIssuer);
.setJwtValidator(validator);
jwtDecoderreturn jwtDecoder;
}
}
Here, we initialized a decoder using the issuer URL and then we added some default token validators in the decoder using the createDefaultWithIssuer
method. This method adds
JwtTimestampValidator
that verifies if the token has expired, andJwtIssuerValidator
that checks theiss
claim to ascertain if the issuer configured is the same as the one that issued the incoming token.
In addition to the above validations, we can implement custom validations based on the content of the token using the OAuth2TokenValidator
interface, e.g., checking if an aud
claim exists.
// src/main/java/dev/mflash/guides/tokenval/local/security/CustomTokenValidator.java
public class CustomTokenValidator implements OAuth2TokenValidator<Jwt> {
private static final OAuth2Error MISSING_AUDIENCE_ERROR = new OAuth2Error("invalid_token",
"The required audience is missing", null);
private final String audience;
public CustomTokenValidator(String resource) {
this.audience = resource;
}
public @Override OAuth2TokenValidatorResult validate(Jwt jwt) {
boolean hasAudience = jwt.getAudience().contains(audience);
if (hasAudience) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(MISSING_AUDIENCE_ERROR);
}
}
In the CustomTokenValidator
, we’re checking the existence of a valid audience; the logic can be as intricate as your specific needs. If the validation fails, we throw an OAuth2Error
with a relevant message.
To integrate the CustomTokenValidator
with the JwtDecoder
bean, we’ll need a reference value of the audience
against which we can run our validation. You can obtain the value of audience from the Authorization Servers > default > Settings tab from the Okta dashboard. Lets configure it in the application.yml
file.
# src/main/resources/application.yml
auth:
audience: api://default
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://dev-4273429.okta.com/oauth2/default
Read this configuration through Spring Configuration Processor using the following class.
// src/main/java/dev/mflash/guides/tokenval/local/security/OidcProperties.java
@ConfigurationProperties("auth")
@ConstructorBinding
public class OidcProperties {
private final String audience;
public OidcProperties(String audience) {
this.audience = audience;
}
public String getAudience() {
return audience;
}
}
Enable the configuration processor in the application launcher as follows.
// src/main/java/dev/mflash/guides/tokenval/local/Launcher.java
@EnableConfigurationProperties(OidcProperties.class)
public @SpringBootApplication class Launcher {
public static void main(String[] args) {
SpringApplication.run(Launcher.class, args);
}
}
Now, modify the JwtDecoder
as follows.
// src/main/java/dev/mflash/guides/tokenval/local/security/SecurityConfiguration.java
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final String audience;
private final String issuer;
public SecurityConfiguration(OidcProperties oidcProps, OAuth2ResourceServerProperties resourceServerProps) {
this.audience = oidcProps.getAudience();
this.issuer = resourceServerProps.getJwt().getIssuerUri();
}
@Bean JwtDecoder jwtDecoder() {
var jwtDecoder = (NimbusJwtDecoder) JwtDecoders.fromOidcIssuerLocation(issuer);
var audienceValidator = new CustomTokenValidator(audience);
OAuth2TokenValidator<Jwt> validatorWithIssuer = JwtValidators.createDefaultWithIssuer(issuer);
var validatorWithAudience = new DelegatingOAuth2TokenValidator<>(validatorWithIssuer, audienceValidator);
.setJwtValidator(validatorWithAudience);
jwtDecoderreturn jwtDecoder;
}
}
Note that we can pass multiple validators using the DelegatingOAuth2TokenValidator
constructor.
Testing the local token validation
To test this implementation, let’s create some sample endpoints in a controller, say GenericController
.
// src/main/java/dev/mflash/guides/tokenval/local/GenericController.java
@RestController
@RequestMapping(GenericController.CONTEXT)
public class GenericController {
public static final String CONTEXT = "/spring-security-oidc";
public static final String PUBLIC_ENDPOINT = "/public";
public static final String PRIVATE_ENDPOINT = "/private";
public static final String PRIVATE_SCOPED_ENDPOINT = "/private-scoped";
private static final String MSG_TEMPLATE = "Hello, world! This is a %s endpoint";
@GetMapping(PUBLIC_ENDPOINT)
public Map<String, String> publicEndpoint() {
return response("public");
}
@GetMapping(PRIVATE_ENDPOINT)
public Map<String, String> privateEndpoint() {
return response("private");
}
@GetMapping(PRIVATE_SCOPED_ENDPOINT)
public Map<String, String> privateScopedEndpoint() {
return response("private scoped");
}
private Map<String, String> response(String type) {
return Map.of("message", String.format(MSG_TEMPLATE, type));
}
}
Here, we’ve exposed three endpoints, one is public /spring-security-oidc/public
, one is private /spring-security-oidc/private
, and the other is private /spring-security-oidc/private-scoped
but available only for a specific scope (say, read:messages
).
We need to configure these protection rules in the SecurityConfiguration
as follows.
// src/main/java/dev/mflash/guides/tokenval/local/security/SecurityConfiguration.java
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final String audience;
private final String issuer;
public SecurityConfiguration(OidcProperties oidcProps, OAuth2ResourceServerProperties resourceServerProps) {
this.audience = oidcProps.getAudience();
this.issuer = resourceServerProps.getJwt().getIssuerUri();
}
protected @Override void configure(HttpSecurity http) throws Exception {
.authorizeRequests()
httpCONTEXT + PUBLIC_ENDPOINT).permitAll()
.mvcMatchers(CONTEXT + PRIVATE_ENDPOINT).authenticated()
.mvcMatchers(CONTEXT + PRIVATE_SCOPED_ENDPOINT).hasAuthority("SCOPE_read:messages")
.mvcMatchers(.oauth2ResourceServer().jwt();
.and()
}
@Bean JwtDecoder jwtDecoder() {
var jwtDecoder = (NimbusJwtDecoder) JwtDecoders.fromOidcIssuerLocation(issuer);
var audienceValidator = new CustomTokenValidator(audience);
OAuth2TokenValidator<Jwt> validatorWithIssuer = JwtValidators.createDefaultWithIssuer(issuer);
var validatorWithAudience = new DelegatingOAuth2TokenValidator<>(validatorWithIssuer, audienceValidator);
.setJwtValidator(validatorWithAudience);
jwtDecoderreturn jwtDecoder;
}
}

Launch the application. Open a terminal and send the following request to the public endpoint using httpie.
$ http :8080/spring-security-oidc/public
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a public endpoint"
}
Try accessing the private endpoint.
$ http :8080/spring-security-oidc/private
HTTP/1.1 401# other headers
As expected, you received a 401 Unauthorized
status. Generate a token (using the Basic Authentication scheme by passing the client_id and client_secret),
$ http --form -a 0oarle1cZ7n7esoqO5d5:i-HUDoMIm5SO7s22ejZzMb2qKHGb7HnMESx4NV2S POST https://dev-4273429.okta.com/oauth2/default/v1/token grant_type=client_credentials scope=write:messages
HTTP/1.1 200 OK# other headers
{"access_token": "eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULldpcDNYZzNQSFRSYjgwX1M0dUZPbWNSOVhVaHQxbF95TGl1QVdzOVE5SnMiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM3OTMzOCwiZXhwIjoxNjA1MzgyOTM4LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.DbVQW0lDqPWpZ8RM6FBPI4N6ey9UKb9v3oMTNMifyF9rx7hfQb8YVFGeNVHMPCkYDUfCHFQPplAo0tubVjN-Fh5xzs4y0Wai58Ju-viMGSn-lo5G5Vz8_EjH47R0OQHWz-CqFr6NPNdarKs-KK_GuFYOxoOdcCJ1rwACtKdAHz8ihG69VKncYtkfWvvIRA270Wpo7_PAtnkdAxz-LVvLIkdT9OTQOg7oFfnI7k0EJhmg9BAEzWWmxprzVgfCTSLsCBz5nfHtQdv8aD3AauvY61s0M59rMRCO37P7EN7Fd1HRN0klYm-QycVYxYpXIAVbw5KDPWtKEs0rz-mpS_y9KQ",
"expires_in": 3600,
"scope": "write:messages",
"token_type": "Bearer"
}
and use this token in the request to the private endpoint. You’ll be able to access the endpoint this time.
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULldpcDNYZzNQSFRSYjgwX1M0dUZPbWNSOVhVaHQxbF95TGl1QVdzOVE5SnMiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM3OTMzOCwiZXhwIjoxNjA1MzgyOTM4LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.DbVQW0lDqPWpZ8RM6FBPI4N6ey9UKb9v3oMTNMifyF9rx7hfQb8YVFGeNVHMPCkYDUfCHFQPplAo0tubVjN-Fh5xzs4y0Wai58Ju-viMGSn-lo5G5Vz8_EjH47R0OQHWz-CqFr6NPNdarKs-KK_GuFYOxoOdcCJ1rwACtKdAHz8ihG69VKncYtkfWvvIRA270Wpo7_PAtnkdAxz-LVvLIkdT9OTQOg7oFfnI7k0EJhmg9BAEzWWmxprzVgfCTSLsCBz5nfHtQdv8aD3AauvY61s0M59rMRCO37P7EN7Fd1HRN0klYm-QycVYxYpXIAVbw5KDPWtKEs0rz-mpS_y9KQ'
$ http :8080/spring-security-oidc/private
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a private endpoint"
}
Let’s try to access the private scoped endpoint. Note that the previous token was generated for the write:messages
scope; it should not work for the private scoped endpoint (which requires the read:messages
scope).
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULldpcDNYZzNQSFRSYjgwX1M0dUZPbWNSOVhVaHQxbF95TGl1QVdzOVE5SnMiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM3OTMzOCwiZXhwIjoxNjA1MzgyOTM4LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.DbVQW0lDqPWpZ8RM6FBPI4N6ey9UKb9v3oMTNMifyF9rx7hfQb8YVFGeNVHMPCkYDUfCHFQPplAo0tubVjN-Fh5xzs4y0Wai58Ju-viMGSn-lo5G5Vz8_EjH47R0OQHWz-CqFr6NPNdarKs-KK_GuFYOxoOdcCJ1rwACtKdAHz8ihG69VKncYtkfWvvIRA270Wpo7_PAtnkdAxz-LVvLIkdT9OTQOg7oFfnI7k0EJhmg9BAEzWWmxprzVgfCTSLsCBz5nfHtQdv8aD3AauvY61s0M59rMRCO37P7EN7Fd1HRN0klYm-QycVYxYpXIAVbw5KDPWtKEs0rz-mpS_y9KQ'
$ http :8080/spring-security-oidc/private-scoped
HTTP/1.1 403"insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
WWW-Authenticate: Bearer error=# other headers
Generate a new token for the read:messages
scope,
$ http --form -a 0oarle1cZ7n7esoqO5d5:i-HUDoMIm5SO7s22ejZzMb2qKHGb7HnMESx4NV2S POST https://dev-4273429.okta.com/oauth2/default/v1/token grant_type=client_credentials scope=read:messages
HTTP/1.1 200 OK# other headers
{"access_token": "eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULlctdzdUVHJsM1VRQWk0bk5wOS01YzB0ZjB1eWJmem9FZmNfU0NRSU5iN2MiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM3OTA5NSwiZXhwIjoxNjA1MzgyNjk1LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJyZWFkOm1lc3NhZ2VzIl0sInN1YiI6IjBvYXJsZTFjWjduN2Vzb3FPNWQ1In0.RIgotpHIn2UCMTXx3rl7Kin756YsXXmcCj_DmfAtoLFCVZagB-SAp6jPhL8XSWJrCcBmZXOXClmoTiljMdvH4DWo4bVAYnESEFsMLgYujD4FkkZdeXHnNWQGfpp4u9nzHUsZSo2J777rBAWn0h9dRSUkFw9Wy-z_2wVTa3etreqQlHA9VkdqeW8Re7e1EB-jRezRW2pTe8ibfcJ23oHmv4AcB2eVu0SFgSHVYM_7i5xRsr-M0ta1ajwEkD7iAA1Ye0_1qRURrWZpM0qlx2LZw33hOgkoAAPUA4UJOq0yPbvSM8d6BKxIRCObqxHcDL63VZoLPc7_Jt8ez3eXO-TLoQ",
"expires_in": 3600,
"scope": "read:messages",
"token_type": "Bearer"
}
and try accessing the endpoint with this token.
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULlctdzdUVHJsM1VRQWk0bk5wOS01YzB0ZjB1eWJmem9FZmNfU0NRSU5iN2MiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM3OTA5NSwiZXhwIjoxNjA1MzgyNjk1LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJyZWFkOm1lc3NhZ2VzIl0sInN1YiI6IjBvYXJsZTFjWjduN2Vzb3FPNWQ1In0.RIgotpHIn2UCMTXx3rl7Kin756YsXXmcCj_DmfAtoLFCVZagB-SAp6jPhL8XSWJrCcBmZXOXClmoTiljMdvH4DWo4bVAYnESEFsMLgYujD4FkkZdeXHnNWQGfpp4u9nzHUsZSo2J777rBAWn0h9dRSUkFw9Wy-z_2wVTa3etreqQlHA9VkdqeW8Re7e1EB-jRezRW2pTe8ibfcJ23oHmv4AcB2eVu0SFgSHVYM_7i5xRsr-M0ta1ajwEkD7iAA1Ye0_1qRURrWZpM0qlx2LZw33hOgkoAAPUA4UJOq0yPbvSM8d6BKxIRCObqxHcDL63VZoLPc7_Jt8ez3eXO-TLoQ'
$ http :8080/spring-security-oidc/private-scoped
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a private scoped endpoint"
}
Since the scope
matched this time, you were able to access the endpoint with this token.
Using the above scenarios, you can write some tests using Spring’s MockMvc
and AssertJ assertions.
// src/test/java/dev/mflash/guides/tokenval/local/GenericControllerTest.java
@WebMvcTest(GenericController.class)
@ExtendWith(SpringExtension.class)
class GenericControllerTest {
private @Autowired MockMvc mvc;
@Test
@DisplayName("Should be able to access public endpoint without auth")
void shouldBeAbleToAccessPublicEndpointWithoutAuth() throws Exception {
MockHttpServletResponse response = mvc.perform(
CONTEXT + PUBLIC_ENDPOINT))
get(.isOk())
.andExpect(status().getResponse();
.andReturn()
.getContentAsString()).isNotEmpty();
assertThat(response
}
@Test
@DisplayName("Should get unauthorized on private endpoint without auth")
void shouldGetUnauthorizedOnPrivateEndpointWithoutAuth() throws Exception {
.perform(get(CONTEXT + PRIVATE_ENDPOINT))
mvc.isUnauthorized())
.andExpect(status()
.andReturn();
}
@Test
@DisplayName("Should be able to access private endpoint with auth")
@WithMockUser(username = "oidcUser")
void shouldBeAbleToAccessPrivateEndpointWithAuth() throws Exception {
MockHttpServletResponse response = mvc.perform(
CONTEXT + PRIVATE_ENDPOINT))
get(.isOk())
.andExpect(status().getResponse();
.andReturn()
.getContentAsString()).isNotEmpty();
assertThat(response
}
@Test
@DisplayName("Should get unauthorized on private scoped endpoint without auth")
void shouldGetUnauthorizedOnPrivateScopedEndpointWithoutAuth() throws Exception {
.perform(get(CONTEXT + PRIVATE_SCOPED_ENDPOINT))
mvc.isUnauthorized())
.andExpect(status()
.andReturn();
}
@Test
@DisplayName("Should get forbidden on private scoped endpoint without scope")
@WithMockUser(username = "oidcUser")
void shouldGetForbiddenOnPrivateScopedEndpointWithoutScope() throws Exception {
.perform(get(CONTEXT + PRIVATE_SCOPED_ENDPOINT))
mvc.isForbidden())
.andExpect(status()
.andReturn();
}
@Test
@DisplayName("Should be able to access private scoped endpoint with proper scope")
@WithMockUser(username = "oidcUser", authorities = { "SCOPE_read:messages" })
void shouldBeAbleToAccessPrivateScopedEndpointWithProperScope() throws Exception {
MockHttpServletResponse response = mvc.perform(
CONTEXT + PRIVATE_SCOPED_ENDPOINT))
get(.isOk())
.andExpect(status().getResponse();
.andReturn()
.getContentAsString()).isNotEmpty();
assertThat(response
}
@Test
@DisplayName("Should get forbidden on private scoped endpoint without proper scope")
@WithMockUser(username = "oidcUser", authorities = { "SCOPE_write:messages" })
void shouldGetForbiddenOnPrivateScopedEndpointWithoutProperScope() throws Exception {
.perform(get(CONTEXT + PRIVATE_SCOPED_ENDPOINT))
mvc.isForbidden())
.andExpect(status()
.andReturn();
}
}
Here,
- the first test checks if the public endpoint is accessible without any token,
- the second and fourth tests verify that the private and private scoped endpoints return unauthorized when accessed without token,
- the third test verifies that the private endpoint is accessible with proper authentication,
- the fifth and seventh tests verify that the private scoped endpoint returns forbidden when accessed without a scope or with an incorrect scope, and
- the sixth test verifies that the private scoped endpoint is accessible with proper authentication and correct scope.
Token Introspection
Any tokens that you validate locally are, by definition, stale. This is because there will always be some time difference between the instance an Authorization Server generates a token and the instance a Resource Server receives a snapshot of that token. The longer a client uses that token, the higher is the chance that it is not valid. In low-risk scenarios, you may not care much about it. However, when a client requests a sensitive resource, you may want to make absolutely sure that the accompanying token is valid. In such scenarios, you may defer the token validation to your identity provider through a process called introspection.
Since we don’t care about the content or structure of the token being introspected, it is called an opaque token. Spring Security provides an optional support for the introspection of opaque tokens through the resource server. The dependency oauth2-oidc-sdk
in pom.xml
is required to support this feature.
To start with, open the application.yml
file, and add the following configuration.
# src/main/resources/application.yml
auth:
audience: api://default
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://dev-4273429.okta.com/oauth2/default
opaque-token:
introspection-uri: https://dev-4273429.okta.com/oauth2/default/v1/introspect
client-id: 0oarle1cZ7n7esoqO5d5
client-secret: i-HUDoMIm5SO7s22ejZzMb2qKHGb7HnMESx4NV2S
Implement the OpaqueTokenIntrospector
interface to use the above configuration.
// src/main/java/dev/mflash/guides/tokenval/introspection/security/CustomOpaqueTokenIntrospector.java
public class CustomOpaqueTokenIntrospector implements OpaqueTokenIntrospector {
private final OpaqueTokenIntrospector introspector;
public CustomOpaqueTokenIntrospector(OAuth2ResourceServerProperties resourceServerProps) {
var opaqueTokenProps = resourceServerProps.getOpaquetoken();
this.introspector = new NimbusOpaqueTokenIntrospector(
.getIntrospectionUri(),
opaqueTokenProps.getClientId(),
opaqueTokenProps.getClientSecret()
opaqueTokenProps
);
}
public @Override OAuth2AuthenticatedPrincipal introspect(String token) {
return introspector.introspect(token);
}
}
Modify the SecurityConfiguration
to use the CustomOpaqueTokenIntrospector
as follows.
// src/main/java/dev/mflash/guides/tokenval/introspection/security/SecurityConfiguration.java
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
protected @Override void configure(HttpSecurity http) throws Exception {
.authorizeRequests()
httpCONTEXT + PUBLIC_ENDPOINT).permitAll()
.mvcMatchers(CONTEXT + PRIVATE_ENDPOINT_LOCAL).authenticated()
.mvcMatchers(CONTEXT + PRIVATE_SCOPED_ENDPOINT_REMOTE).hasAuthority("SCOPE_read:messages")
.mvcMatchers(.oauth2ResourceServer().opaqueToken();
.and()
}
@Bean OpaqueTokenIntrospector tokenIntrospector(OAuth2ResourceServerProperties resourceServerProps) {
return new CustomOpaqueTokenIntrospector(resourceServerProps);
}
}
Note that we are now calling the opaqueToken
method on the oauth2ResourceServer
instead of the jwt
method earlier.
Testing the token introspection

Rerun the previous scenarios with httpie to see the introspection in action.
$ http :8080/spring-security-oidc/public
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a public endpoint"
}
$ http :8080/spring-security-oidc/private
HTTP/1.1 401# other headers
$ http --form -a 0oarle1cZ7n7esoqO5d5:i-HUDoMIm5SO7s22ejZzMb2qKHGb7HnMESx4NV2S POST https://dev-4273429.okta.com/oauth2/default/v1/token grant_type=client_credentials scope=write:messages
HTTP/1.1 200 OK# other headers
{"access_token": "eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULkwzb0dCZ3BWQkxDNXowUzdrV1QyRlNXUWtfUk9fbm16Y0NLOWl1Z2drVG8iLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM4MjgyOCwiZXhwIjoxNjA1Mzg2NDI4LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.ENCqOdVf6NWt1zq5JaPqLi7zc-LvE3ffFrCJXZXl3ORONfkU2w_aGMWGTafn4miTMZsrZJZEe4fwckNCr5rg3hOtib-ohlOy0nzAHJWTDvRYMnDx0LxUFaCM_wP5Fgh1VP4cONBOO69106vKiaKNTao6NorIHeLBeqpcTCZbTiteZ5rTIBVR5qXabLT0ALpw3JWb9U7c_hnixVj1ecP9CW4Fv1BIFysN9srESrCofMrlP4Hf2YlC19KflYdFnTvpADTn-1owTNpbPOGnipltEQgd0DIQREi5Sf53Vx_6TYGi3sIGfOI7QEM8yf7dNJhaWgCxnUh0sAlPIxzw6jmJuA",
"expires_in": 3600,
"scope": "write:messages",
"token_type": "Bearer"
}
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULkwzb0dCZ3BWQkxDNXowUzdrV1QyRlNXUWtfUk9fbm16Y0NLOWl1Z2drVG8iLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM4MjgyOCwiZXhwIjoxNjA1Mzg2NDI4LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.ENCqOdVf6NWt1zq5JaPqLi7zc-LvE3ffFrCJXZXl3ORONfkU2w_aGMWGTafn4miTMZsrZJZEe4fwckNCr5rg3hOtib-ohlOy0nzAHJWTDvRYMnDx0LxUFaCM_wP5Fgh1VP4cONBOO69106vKiaKNTao6NorIHeLBeqpcTCZbTiteZ5rTIBVR5qXabLT0ALpw3JWb9U7c_hnixVj1ecP9CW4Fv1BIFysN9srESrCofMrlP4Hf2YlC19KflYdFnTvpADTn-1owTNpbPOGnipltEQgd0DIQREi5Sf53Vx_6TYGi3sIGfOI7QEM8yf7dNJhaWgCxnUh0sAlPIxzw6jmJuA'
$ http :8080/spring-security-oidc/private
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a private endpoint"
}
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULkwzb0dCZ3BWQkxDNXowUzdrV1QyRlNXUWtfUk9fbm16Y0NLOWl1Z2drVG8iLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM4MjgyOCwiZXhwIjoxNjA1Mzg2NDI4LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.ENCqOdVf6NWt1zq5JaPqLi7zc-LvE3ffFrCJXZXl3ORONfkU2w_aGMWGTafn4miTMZsrZJZEe4fwckNCr5rg3hOtib-ohlOy0nzAHJWTDvRYMnDx0LxUFaCM_wP5Fgh1VP4cONBOO69106vKiaKNTao6NorIHeLBeqpcTCZbTiteZ5rTIBVR5qXabLT0ALpw3JWb9U7c_hnixVj1ecP9CW4Fv1BIFysN9srESrCofMrlP4Hf2YlC19KflYdFnTvpADTn-1owTNpbPOGnipltEQgd0DIQREi5Sf53Vx_6TYGi3sIGfOI7QEM8yf7dNJhaWgCxnUh0sAlPIxzw6jmJuA'
$ http :8080/spring-security-oidc/private-scoped
HTTP/1.1 403"insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
WWW-Authenticate: Bearer error=# other headers
$ http --form -a 0oarle1cZ7n7esoqO5d5:i-HUDoMIm5SO7s22ejZzMb2qKHGb7HnMESx4NV2S POST https://dev-4273429.okta.com/oauth2/default/v1/token grant_type=client_credentials scope=read:messages
HTTP/1.1 200 OK# other headers
{"access_token": "eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULmdhdVZEZmd5Y1A3Nm5QLVVKOUxOXzhmX2s3U2tNOUNiUXFidm01eWtNOWsiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM4MzE2OSwiZXhwIjoxNjA1Mzg2NzY5LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJyZWFkOm1lc3NhZ2VzIl0sInN1YiI6IjBvYXJsZTFjWjduN2Vzb3FPNWQ1In0.A9jOuJbLYlSFr7mtoQwgIv2-53vSpolTW_ZWfGNH9NyRYuchGY4iCIOTUsfGzN3B7fOZnHjg0F2Iywk_Yo0XHvwGLnfFIyeAjtVB-FUd21HbbfUf-kUeyh0zSz0ixPq-K6kQBha08vPTqKPXuAcHUwhAUFHoA2cREvl2cx-WioDIZgjd_oodbEBzm2jQiREvi3mFHtlb_EVtSEWpD92tNuZdf_E31M0jUeISTdo7UnJarps5WHNIK6ZbLumllhwI7yrpCpb58AFKyeXBwzI2TPYdvXyVIvA9svuoIUnYu_MiLnW5rh4BbH_hh21OOI_2XIO6NJsLSQy19pTZGh4NzA",
"expires_in": 3600,
"scope": "read:messages",
"token_type": "Bearer"
}
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULmdhdVZEZmd5Y1A3Nm5QLVVKOUxOXzhmX2s3U2tNOUNiUXFidm01eWtNOWsiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTM4MzE2OSwiZXhwIjoxNjA1Mzg2NzY5LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJyZWFkOm1lc3NhZ2VzIl0sInN1YiI6IjBvYXJsZTFjWjduN2Vzb3FPNWQ1In0.A9jOuJbLYlSFr7mtoQwgIv2-53vSpolTW_ZWfGNH9NyRYuchGY4iCIOTUsfGzN3B7fOZnHjg0F2Iywk_Yo0XHvwGLnfFIyeAjtVB-FUd21HbbfUf-kUeyh0zSz0ixPq-K6kQBha08vPTqKPXuAcHUwhAUFHoA2cREvl2cx-WioDIZgjd_oodbEBzm2jQiREvi3mFHtlb_EVtSEWpD92tNuZdf_E31M0jUeISTdo7UnJarps5WHNIK6ZbLumllhwI7yrpCpb58AFKyeXBwzI2TPYdvXyVIvA9svuoIUnYu_MiLnW5rh4BbH_hh21OOI_2XIO6NJsLSQy19pTZGh4NzA'
$ http :8080/spring-security-oidc/private-scoped
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a private scoped endpoint"
}
Mixing the local token validation and introspection
You may have already realized that introspecting every request is unnecessary, unless the resource is very sensitive. Also, keep in mind that your identity provider may charge you for a certain number of introspection requests; so cost may be another concern due to which you may want to avoid introspection until absolutely needed. You can mix the local token validation and introspection approaches to suit your needs: verify the tokens locally for the low-risk resources and introspect them for the high-risk resources.
To demonstrate, assume that the private scoped endpoint is a high-risk resource and the other endpoints are low-risk resources. We need to inform Spring Security to introspect in the former case and locally validate the token in the rest of the cases. Spring Security uses specific implementations of the AuthenticationManager
to manage different types of authentication. An AuthenticationManagerResolver
specifies which implementation of the AuthenticationManager
needs to be applied to a request. At the time of writing this post, Spring Security does not provide an inbuilt AuthenticationManagerResolver
to handle our usecase. Hence, we need to implement a custom AuthenticationManagerResolver
as follows.
// src/main/java/dev/mflash/guides/tokenval/hybrid/security/RequestMatchingAuthenticationManagerResolver.java
public class RequestMatchingAuthenticationManagerResolver implements AuthenticationManagerResolver<HttpServletRequest> {
private final LinkedHashMap<RequestMatcher, AuthenticationManager> authenticationManagers;
private AuthenticationManager defaultAuthenticationManager = authentication -> {
throw new AuthenticationServiceException("Cannot authenticate " + authentication);
};
public RequestMatchingAuthenticationManagerResolver(
LinkedHashMap<RequestMatcher, AuthenticationManager> authenticationManagers) {
this.authenticationManagers = authenticationManagers;
}
public @Override AuthenticationManager resolve(HttpServletRequest context) {
for (Map.Entry<RequestMatcher, AuthenticationManager> entry : this.authenticationManagers.entrySet()) {
if (entry.getKey().matches(context)) {
return entry.getValue();
}
}
return this.defaultAuthenticationManager;
}
public void setDefaultAuthenticationManager(AuthenticationManager defaultAuthenticationManager) {
this.defaultAuthenticationManager = defaultAuthenticationManager;
}
}
Here, once the RequestMatchingAuthenticationManagerResolver
receives a map of RequestMatcher
and AuthenticationManager
, it can return the corresponding AuthenticationManager
for a given route, which can then be invoked to do either local token validation or introspection. You can also set a fallback AuthenticationManager
through the setDefaultAuthenticationManager
method in case no matching AuthenticationManager
is available for a given request.
Note that there’s an open pull request on the Spring Security repository which may add the
RequestMatchingAuthenticationManagerResolver
to the framework once accepted.
Using the RequestMatchingAuthenticationManagerResolver
, modify the SecurityConfiguration
as follows.
// src/main/java/dev/mflash/guides/tokenval/hybrid/security/SecurityConfiguration.java
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final OidcProperties oidcProps;
private final OAuth2ResourceServerProperties resourceServerProps;
public SecurityConfiguration(OidcProperties oidcProps, OAuth2ResourceServerProperties resourceServerProps) {
this.oidcProps = oidcProps;
this.resourceServerProps = resourceServerProps;
}
protected @Override void configure(HttpSecurity http) throws Exception {
.authorizeRequests()
httpCONTEXT + PUBLIC_ENDPOINT).permitAll()
.mvcMatchers(CONTEXT + PRIVATE_ENDPOINT_LOCAL).authenticated()
.mvcMatchers(CONTEXT + PRIVATE_SCOPED_ENDPOINT_REMOTE).hasAuthority("SCOPE_read:messages")
.mvcMatchers(.oauth2ResourceServer().authenticationManagerResolver(customAuthenticationManager());
.and()
}
AuthenticationManagerResolver<HttpServletRequest> customAuthenticationManager() {
var authenticationManagers = new LinkedHashMap<RequestMatcher, AuthenticationManager>();
RequestMatcher requestMatcherLocal = request -> request.getRequestURI().contains(PRIVATE_SCOPED_ENDPOINT_REMOTE);
.put(requestMatcherLocal, opaque());
authenticationManagers
var authenticationManagerResolver = new RequestMatchingAuthenticationManagerResolver(authenticationManagers);
.setDefaultAuthenticationManager(jwt());
authenticationManagerResolver
return authenticationManagerResolver;
}
AuthenticationManager jwt() {
var jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtDecoder());
.setJwtAuthenticationConverter(new JwtAuthenticationConverter());
jwtAuthenticationProviderreturn jwtAuthenticationProvider::authenticate;
}
AuthenticationManager opaque() {
return new OpaqueTokenAuthenticationProvider(tokenIntrospector())::authenticate;
}
@Bean JwtDecoder jwtDecoder() {
String issuer = resourceServerProps.getJwt().getIssuerUri();
String audience = oidcProps.getAudience();
var jwtDecoder = (NimbusJwtDecoder) JwtDecoders.fromOidcIssuerLocation(issuer);
var audienceValidator = new CustomTokenValidator(audience);
OAuth2TokenValidator<Jwt> validatorWithIssuer = JwtValidators.createDefaultWithIssuer(issuer);
var validatorWithAudience = new DelegatingOAuth2TokenValidator<>(validatorWithIssuer, audienceValidator);
.setJwtValidator(validatorWithAudience);
jwtDecoderreturn jwtDecoder;
}
@Bean OpaqueTokenIntrospector tokenIntrospector() {
return new CustomOpaqueTokenIntrospector(resourceServerProps);
}
}
Note that we’re now calling the authenticationManagerResolver
method over the oauth2ResourceServer
. In the customAuthenticationManager
method, we’re registering the private scoped endpoint to be introspected, and the other endpoints to be locally validated. Finally, we define dedicated AuthenticationManager
implementations that use
- the
JwtDecoder
to perform local token validation in thejwt
method, and - the
OpaqueTokenIntrospector
to perform introspection in theopaque
method.
Testing the hybrid approach

As earlier, rerun the previous scenarios with httpie to see the hybrid approach in action.
$ http :8080/spring-security-oidc/public
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a public endpoint"
}
$ http :8080/spring-security-oidc/private-local
HTTP/1.1 401# other headers
$ http --form -a 0oarle1cZ7n7esoqO5d5:i-HUDoMIm5SO7s22ejZzMb2qKHGb7HnMESx4NV2S POST https://dev-4273429.okta.com/oauth2/default/v1/token grant_type=client_credentials scope=write:messages
HTTP/1.1 200 OK# other headers
{"access_token": "eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULk5Da3dQeXBfOGJLQ3RTeVhnYTExTzVYTFdpSllmMHczOVRNWDJ3S2dUVGciLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTQxNjQ4NywiZXhwIjoxNjA1NDIwMDg3LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.J8NGRTdMGYOmkr7jnm-d2yhHwHmZlCga3j5WsNpifaRqMRutYy9PXACZfcPS7R4vrX-iDYJyNCoKMcjUT1RL-FTiBAbZ50tIz5lfFWEVbz2M80B75I0cVPOmvk7yv1w2SOyDo8ykRRt4O0tKDkrD7rKJCi6YTQu9QWzwiNlTNCzy-KdcL75plzuOUyg1P9dd-ScMN3pNmm3R1sgyIGbVXMlkbjIqZ_vX1-76kqb-3diBoYpt6_aiWAGxVejtYl689q9SeBty79TrWhGDTZpPJp-21QlxvWn0ybrJvlJuUFLe5aKXqW6evR5owrwt0YMT3qvKZIQmQlVC2JWRFOHzAQ",
"expires_in": 3600,
"scope": "write:messages",
"token_type": "Bearer"
}
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULk5Da3dQeXBfOGJLQ3RTeVhnYTExTzVYTFdpSllmMHczOVRNWDJ3S2dUVGciLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTQxNjQ4NywiZXhwIjoxNjA1NDIwMDg3LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.J8NGRTdMGYOmkr7jnm-d2yhHwHmZlCga3j5WsNpifaRqMRutYy9PXACZfcPS7R4vrX-iDYJyNCoKMcjUT1RL-FTiBAbZ50tIz5lfFWEVbz2M80B75I0cVPOmvk7yv1w2SOyDo8ykRRt4O0tKDkrD7rKJCi6YTQu9QWzwiNlTNCzy-KdcL75plzuOUyg1P9dd-ScMN3pNmm3R1sgyIGbVXMlkbjIqZ_vX1-76kqb-3diBoYpt6_aiWAGxVejtYl689q9SeBty79TrWhGDTZpPJp-21QlxvWn0ybrJvlJuUFLe5aKXqW6evR5owrwt0YMT3qvKZIQmQlVC2JWRFOHzAQ'
$ http :8080/spring-security-oidc/private-local
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a private locally-validated endpoint"
}
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULk5Da3dQeXBfOGJLQ3RTeVhnYTExTzVYTFdpSllmMHczOVRNWDJ3S2dUVGciLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTQxNjQ4NywiZXhwIjoxNjA1NDIwMDg3LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJ3cml0ZTptZXNzYWdlcyJdLCJzdWIiOiIwb2FybGUxY1o3bjdlc29xTzVkNSJ9.J8NGRTdMGYOmkr7jnm-d2yhHwHmZlCga3j5WsNpifaRqMRutYy9PXACZfcPS7R4vrX-iDYJyNCoKMcjUT1RL-FTiBAbZ50tIz5lfFWEVbz2M80B75I0cVPOmvk7yv1w2SOyDo8ykRRt4O0tKDkrD7rKJCi6YTQu9QWzwiNlTNCzy-KdcL75plzuOUyg1P9dd-ScMN3pNmm3R1sgyIGbVXMlkbjIqZ_vX1-76kqb-3diBoYpt6_aiWAGxVejtYl689q9SeBty79TrWhGDTZpPJp-21QlxvWn0ybrJvlJuUFLe5aKXqW6evR5owrwt0YMT3qvKZIQmQlVC2JWRFOHzAQ'
$ http :8080/spring-security-oidc/private-scoped-remote
HTTP/1.1 403"insufficient_scope", error_description="The request requires higher privileges than provided by the access token.", error_uri="https://tools.ietf.org/html/rfc6750#section-3.1"
WWW-Authenticate: Bearer error=# other headers
$ http --form -a 0oarle1cZ7n7esoqO5d5:i-HUDoMIm5SO7s22ejZzMb2qKHGb7HnMESx4NV2S POST https://dev-4273429.okta.com/oauth2/default/v1/token grant_type=client_credentials scope=read:messages
HTTP/1.1 200 OK# other headers
{"access_token": "eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULjVOX1F3ekV4aFJRemQ1WTBVR0tfRVdrS3ZKOHVLcndleVh4MG82R2EtWUkiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTQxNjY3NCwiZXhwIjoxNjA1NDIwMjc0LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJyZWFkOm1lc3NhZ2VzIl0sInN1YiI6IjBvYXJsZTFjWjduN2Vzb3FPNWQ1In0.F-zfuMAHPnwvhfzu6-GLFER_AMDLUaPjGn9EYFNMjQLgGOkRV9DY4dxlMGLExytqwe2lDmMd9EYptyPBNRWk9kPz-yzcbaxQAAzBoq1N700F0eaMPnxI-S_zwpOxlFD58NPTJf_4CGHY9T_K4A51N_OUIs-fI0STeTv4bmgNxL-eVZhiZSu2CVq1pJO6fDu6HBhc4g8pualauSHNZrkx0P8_VyuhM7jM5tv-3hJD8Ppay04JDd5vwKlLoc1nFIzbWI9nYv-ymuelG1SDQ24k1gRaCBFh7cHnKcRdw3Eu_eqRkTLAdfh2JAk-QNNjzpr_8ffvhYvoY39VFmQ7J-0uBQ",
"expires_in": 3600,
"scope": "read:messages",
"token_type": "Bearer"
}
'Authorization:Bearer eyJraWQiOiJxczFVSzFqWnN0OGFFYlRxOElaZ1NTaDlHd3pha3Jqa0hFcG1MeGRQblNJIiwiYWxnIjoiUlMyNTYifQ.eyJ2ZXIiOjEsImp0aSI6IkFULjVOX1F3ekV4aFJRemQ1WTBVR0tfRVdrS3ZKOHVLcndleVh4MG82R2EtWUkiLCJpc3MiOiJodHRwczovL2Rldi00MjczNDI5Lm9rdGEuY29tL29hdXRoMi9kZWZhdWx0IiwiYXVkIjoiYXBpOi8vZGVmYXVsdCIsImlhdCI6MTYwNTQxNjY3NCwiZXhwIjoxNjA1NDIwMjc0LCJjaWQiOiIwb2FybGUxY1o3bjdlc29xTzVkNSIsInNjcCI6WyJyZWFkOm1lc3NhZ2VzIl0sInN1YiI6IjBvYXJsZTFjWjduN2Vzb3FPNWQ1In0.F-zfuMAHPnwvhfzu6-GLFER_AMDLUaPjGn9EYFNMjQLgGOkRV9DY4dxlMGLExytqwe2lDmMd9EYptyPBNRWk9kPz-yzcbaxQAAzBoq1N700F0eaMPnxI-S_zwpOxlFD58NPTJf_4CGHY9T_K4A51N_OUIs-fI0STeTv4bmgNxL-eVZhiZSu2CVq1pJO6fDu6HBhc4g8pualauSHNZrkx0P8_VyuhM7jM5tv-3hJD8Ppay04JDd5vwKlLoc1nFIzbWI9nYv-ymuelG1SDQ24k1gRaCBFh7cHnKcRdw3Eu_eqRkTLAdfh2JAk-QNNjzpr_8ffvhYvoY39VFmQ7J-0uBQ'
$ http :8080/spring-security-oidc/private-scoped-remote
HTTP/1.1 200# other headers
{"message": "Hello, world! This is a private scoped remotely-validated endpoint"
}
Source code
- spring-security-token-validation-local
- spring-security-token-introspection
- spring-security-token-validation-hybrid
Related
- Spring Security Resource Server
- OAuth 2.0 Client Credentials Grant
- OAuth Scopes
- OAuth 2.0: Audience Information
- OAuth 2.0 Token Introspection