"On-Behalf-Of" (OBO) implementation in Java okta-spring-boot-starter

My understanding is that for a machine to machine application, an OBO ‘on-behalf-of’ flow is good practice.

Thus what is the correct way to implement the OBO, ‘on-behalf-of’ flow for a Java implementation using the okta-spring-boot-starter library?
Whereupon, a JWT will already be available in the security context, then that JWT can be used to call the Auth0 token endpoint to get another JWT to then call a downstream service, with all claims intact.

Is there an example that I have missed?

Hi @dmiller,

Welcome to the Auth0 Community!

The “On-Behalf-Of” (OBO) pattern is indeed a best practice for preserving user identity across service boundaries. While okta-spring-boot-starter simplifies resource server configuration, it doesn’t provide a “one-click” OBO implementation because Auth0 handles this via the OAuth 2.0 Token Exchange (RFC 8693) or Client Credentials flow, depending on whether you need to strictly maintain the original user’s identity or just the service’s authority.

Since you are using Auth0, the modern way to achieve this is through Token Exchange.

1. Enable Token Exchange in Auth0

You must first ensure your “API A” (the Service) is allowed to perform the exchange.

  • Go to Auth0 Dashboard > Applications.
  • Select your Machine-to-Machine application representing “API A”.
  • In Advanced Settings > Grant Types, ensure Token Exchange is enabled.

2. Implementation in Java (Spring Boot)

You can use the WebClient or RestTemplate to perform the exchange. The okta-spring-boot-starter manages the incoming token, but you’ll need a small helper to get the outgoing token.

@Service
public class DownstreamService {

    @Value("${okta.oauth2.issuer}")
    private String issuer;

    @Value("${okta.oauth2.client-id}")
    private String clientId;

    @Value("${okta.oauth2.client-secret}")
    private String clientSecret;

    public String getOnBehalfOfToken() {
        // 1. Get the current user's JWT from the Security Context
        JwtAuthenticationToken authentication = (JwtAuthenticationToken) 
            SecurityContextHolder.getContext().getAuthentication();
        String subjectToken = authentication.getToken().getTokenValue();

        // 2. Exchange it for a token scoped for the downstream API
        WebClient webClient = WebClient.builder().baseUrl(issuer).build();

        return webClient.post()
            .uri("/oauth/token")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(BodyInserters.fromFormData("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
                .with("subject_token", subjectToken)
                .with("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")
                .with("client_id", clientId)
                .with("client_secret", clientSecret)
                .with("audience", "https://your-downstream-api-identifier")
                .with("scope", "read:data")) // Requested scopes for API B
            .retrieve()
            .bodyToMono(TokenResponse.class)
            .block()
            .getAccessToken();
    }
}

If you have any further questions, please don’t hesitate to reach out.

Have a good one,
Vlad

Thanks @vlad.murarasu ,

Much appreciated.

One thing though, I don’t have and option for the grant type ‘Token Exchange’

Is there another setting I need to use to enable it?

Thanks

Hi @dmiller,

It should be Client Credentials, but I see it’s greyed out. See the documentation provided in that warning on how to enable it.

Have a good one,
Vlad

Thanks @vlad.murarasu ,

So using an application with client credentials,

I get the following:

{“error”: “invalid_request”,“error_description”: “Invalid subject_token_type.”}

Hi again @dmiller,

I think I made an error in the code snippet. Instead of

it should be

("grant_type", "urn:ietf:params:oauth:grant-type:client-credentials")

I hope this fixes the issue

Have a good one,
Vlad

Hi @vlad.murarasu ,

I have it working, but only with grant_type: "client_credentials"
And it does not return the sub and claims that were specified in the “subject-token" for the token exchange.
Is there another setting I’m missing?

Hi @dmiller,

Can you also try

.with("subject_token_type", "access_token")

instead of

.with("subject_token_type", "urn:ietf:params:oauth:token-type:access_token")

Have a good one,
Vlad

Thanks @vlad.murarasu ,

So realised I really needed to start from scratch and implement it properly. Key thing I was missing was adding the action and linking that to the user defined ‘subject-token-type’.
Found it all here:
Custom Token Exchange - Auth0 Docs

Hi everyone!

I will be optimizing the solution provided for future reference on this topic in case anybody else runs into the same issue.

I can see that you are having issues in implementingthe On-Behalf-Of (OBO) flow for a machine-to-machine application using Java and the okta-spring-boot-starter library.

[ROOT CAUSE]

The okta-spring-boot-starter library simplifies incoming token validation (resource server configuration) but does not provide built-in support for outgoing token exchange. This is because:

  1. Token Exchange is not automatic — You must explicitly call Auth0’s token endpoint
  2. Custom subject token type required — Auth0 needs to know what type of token you’re exchanging (access token, ID token, etc.)
  3. Action configuration needed — You must create an Auth0 Action to define how the subject token should be processed

SOLUTION

Step 1: Enable Token Exchange in Auth0

Navigate to Auth0 Dashboard → Applications → Your Machine-to-Machine App → Advanced Settings → Grant Types.

Verify that Token Exchange is enabled. If not, enable it.

Step 2: Create a custom subject token type in Auth0 (via Action)

Auth0 requires you to define a custom subject token type so it knows how to process your incoming token.

Create an Auth0 Action:

  1. Go to Dashboard → Actions → Library → Create Action
  2. Name it something like handle-token-exchange
  3. Add this code:
exports.onExecuteTokenExchange = async (event, api) => {
  // Define your custom subject token type
  if (event.request.body.subject_token_type === 'urn:custom:subject-token-type') {
    // Validate and process the incoming token
    api.access.addClaimToToken('custom_claim', 'value');
  }
};
  1. Deploy the Action
  2. Link it to your API in Dashboard → APIs → Your API → Actions → Token Exchange

Step 3: Implement Token Exchange in Java (Spring Boot)

Create a service that extracts the current JWT and exchanges it for a downstream API token:

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient;

@Service
public class TokenExchangeService {

    @Value("${auth0.domain}")
    private String auth0Domain;

    @Value("${auth0.client-id}")
    private String clientId;

    @Value("${auth0.client-secret}")
    private String clientSecret;

    /**
     * Exchange the current user's JWT for a token scoped to a downstream API
     */
    public String getOnBehalfOfToken(String downstreamApiAudience) {
        // Step 1: Extract the current user's JWT from the security context
        JwtAuthenticationToken authentication = 
            (JwtAuthenticationToken) SecurityContextHolder.getContext().getAuthentication();
        
        if (authentication == null || authentication.getToken() == null) {
            throw new IllegalStateException("No JWT found in security context");
        }

        String subjectToken = authentication.getToken().getTokenValue();

        // Step 2: Exchange the subject token for a downstream API token
        WebClient webClient = WebClient.builder()
            .baseUrl("https://" + auth0Domain)
            .build();

        TokenExchangeResponse response = webClient.post()
            .uri("/oauth/token")
            .contentType(MediaType.APPLICATION_FORM_URLENCODED)
            .body(BodyInserters.fromFormData("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
                .with("subject_token", subjectToken)
                .with("subject_token_type", "urn:custom:subject-token-type") // Custom type defined in Action
                .with("client_id", clientId)
                .with("client_secret", clientSecret)
                .with("audience", downstreamApiAudience)
                .with("scope", "read:data write:data")) // Scopes for downstream API
            .retrieve()
            .bodyToMono(TokenExchangeResponse.class)
            .block();

        if (response == null || response.getAccessToken() == null) {
            throw new RuntimeException("Failed to exchange token");
        }

        return response.getAccessToken();
    }
}

Create a response DTO:

import com.fasterxml.jackson.annotation.JsonProperty;

public class TokenExchangeResponse {
    
    @JsonProperty("access_token")
    private String accessToken;
    
    @JsonProperty("token_type")
    private String tokenType;
    
    @JsonProperty("expires_in")
    private Long expiresIn;

    // Getters and setters
    public String getAccessToken() {
        return accessToken;
    }

    public void setAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }

    public String getTokenType() {
        return tokenType;
    }

    public void setTokenType(String tokenType) {
        this.tokenType = tokenType;
    }

    public Long getExpiresIn() {
        return expiresIn;
    }

    public void setExpiresIn(Long expiresIn) {
        this.expiresIn = expiresIn;
    }
}

Step 4: Use the exchanged token to call the downstream API

@Service
public class DownstreamApiClient {

    private final TokenExchangeService tokenExchangeService;
    private final WebClient webClient;

    public DownstreamApiClient(TokenExchangeService tokenExchangeService, WebClient.Builder webClientBuilder) {
        this.tokenExchangeService = tokenExchangeService;
        this.webClient = webClientBuilder.build();
    }

    public String callDownstreamApi(String downstreamApiUrl, String downstreamApiAudience) {
        // Step 1: Get the OBO token
        String oboToken = tokenExchangeService.getOnBehalfOfToken(downstreamApiAudience);

        // Step 2: Call the downstream API with the OBO token
        return webClient.get()
            .uri(downstreamApiUrl)
            .header("Authorization", "Bearer " + oboToken)
            .retrieve()
            .bodyToMono(String.class)
            .block();
    }
}

Step 5: Configure application properties

Add to application.yml or application.properties:

auth0:
  domain: your-auth0-domain.auth0.com
  client-id: your-machine-to-machine-client-id
  client-secret: your-machine-to-machine-client-secret

okta:
  oauth2:
    issuer: https://your-auth0-domain.auth0.com/
    client-id: your-resource-server-client-id

Kind Regards,
Nik