Skip to main content

Secure an endpoint with JWT and OAuth

Protect a REST endpoint so that only callers with a valid bearer token and the right scope get through. The same endpoint accepts signed JWTs, checked locally against the issuer's public keys, and opaque tokens, checked by an OAuth 2.0 introspection call.

What you'll build​

An Orders API with one endpoint, GET /orders. A bearer-token guard runs before the endpoint and rejects missing, forged, expired and wrong-audience tokens with 401. The endpoint then checks for the orders:read scope and answers 403 without it. To stay self-contained, the guide issues its own JWTs from a generated RSA key and runs a small local stand-in for the introspection endpoint, so you don't need a real identity provider.

When you run it, you'll see one line per kind of caller:

no token:        401 Bearer realm="orders"
valid JWT: 200 "orders for alice"
...

Prerequisites and time​

  • Java 17 or later and Maven 3.
  • These dependencies, all shown in the POM in step 1:
    • org.apache.juneau:juneau-rest-server-auth-jwt:10.0.0-SNAPSHOT
    • org.apache.juneau:juneau-rest-server-auth-oauth:10.0.0-SNAPSHOT
    • org.apache.juneau:juneau-rest-mock:10.0.0-SNAPSHOT, used here to call the endpoint without starting a server
    • com.nimbusds:nimbus-jose-jwt:10.9.1 and com.nimbusds:oauth2-oidc-sdk:11.38.2. The two Juneau auth modules mark these as provided, so you declare them yourself.
  • About 20 minutes.

10.0.0-SNAPSHOT is not on Maven Central, so the POM adds the Apache snapshots repository. See the note in the MCP Quickstart and Install Juneau.

Steps​

Put each class in src/main/java/com/example/ and start each file with package com.example;. Any package works if you use it everywhere.

1. Create the project​

Create an empty directory with this pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>secureanendpoint</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<juneau.version>10.0.0-SNAPSHOT</juneau.version>
</properties>

<repositories>
<repository>
<id>apache-snapshots</id>
<url>https://repository.apache.org/snapshots/</url>
<releases><enabled>false</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>

<dependencies>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-auth-jwt</artifactId>
<version>${juneau.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>10.9.1</version>
</dependency>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-auth-oauth</artifactId>
<version>${juneau.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>11.38.2</version>
</dependency>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-mock</artifactId>
<version>${juneau.version}</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.4</version>
</plugin>
</plugins>
</build>
</project>

2. Write the protected resource​

The resource registers a BearerTokenGuard as a bean. The guard reads the Authorization: Bearer ... header, hands the token to a TokenValidator, and either rejects the request with 401 and a WWW-Authenticate challenge or stores the validated claims for the endpoint. The endpoint receives them as an @Auth ClaimsPrincipal and does its own scope check.

import java.util.*;

import org.apache.juneau.commons.inject.*;
import org.apache.juneau.http.response.*;
import org.apache.juneau.rest.server.*;
import org.apache.juneau.rest.server.auth.*;
import org.apache.juneau.rest.server.config.*;
import org.apache.juneau.rest.server.guard.*;
import org.apache.juneau.rest.server.servlet.*;

/**
* An endpoint that only answers requests carrying a valid bearer token.
*/
@Rest(title="Orders API")
public class SecureResource extends RestServlet implements BasicJsonConfig {

private static final long serialVersionUID = 1L;

private final transient TokenValidator validator;

public SecureResource(TokenValidator validator) {
this.validator = validator;
}

// Every request must pass this guard before any @RestOp method runs.
@Bean
public RestGuardList guards(BeanStore beanStore) {
return RestGuardList.create(beanStore)
.append(BearerTokenGuard.create().realm("orders").validator(validator).build())
.build();
}

@RestGet(path="/orders")
public String orders(@Auth ClaimsPrincipal caller) {
if (! hasScope(caller, "orders:read"))
throw new Forbidden("Missing scope orders:read");
return "orders for " + caller.getName();
}

private static boolean hasScope(ClaimsPrincipal caller, String wanted) {
// A JWT carries "scope" as a space-separated string; an introspection result carries it as a list.
var scope = caller.getClaims().get("scope");
if (scope instanceof Collection<?> c)
return c.contains(wanted);
return scope != null && Arrays.asList(scope.toString().split(" ")).contains(wanted);
}
}

3. Choose how tokens are validated​

JwtTokenValidator checks a JWT's signature against the issuer's JSON Web Key Set, and checks its issuer, audience and expiry. In production, point it at the issuer's JWKS URL. OAuthIntrospectionValidator posts an opaque token to the authorization server's introspection endpoint and requires the listed scopes. either sends anything shaped like a JWT (three dot-separated parts) to the first and everything else to the second.

import java.net.*;

import org.apache.juneau.rest.server.auth.*;
import org.apache.juneau.rest.server.auth.jwt.*;
import org.apache.juneau.rest.server.auth.oauth.*;

import com.nimbusds.jose.jwk.source.*;
import com.nimbusds.jose.proc.*;

/**
* The token validators the endpoint can use.
*/
public final class SecureValidators {

public static final String ISSUER = "https://issuer.example.com/";
public static final String AUDIENCE = "https://api.example.com";

private SecureValidators() {}

/** Production: verify JWTs against the identity provider's published keys. */
public static TokenValidator jwt(URI jwksUrl) {
return JwtTokenValidator.create().jwksUrl(jwksUrl).issuer(ISSUER).audience(AUDIENCE).build();
}

/** Same checks, with the keys supplied directly (used by the test). */
public static TokenValidator jwt(JWKSource<SecurityContext> keys) {
return JwtTokenValidator.create().jwkSource(keys).issuer(ISSUER).audience(AUDIENCE).build();
}

/** Opaque OAuth 2.0 tokens: ask the authorization server whether the token is active (RFC 7662). */
public static TokenValidator introspection(URI endpoint, String clientId, String clientSecret) {
return OAuthIntrospectionValidator.create()
.introspectionEndpoint(endpoint)
.clientId(clientId)
.clientSecret(clientSecret)
.requiredScopes("orders:read")
.build();
}

/** Accept either kind: a JWT has three dot-separated parts, an opaque token doesn't. */
public static TokenValidator either(TokenValidator jwt, TokenValidator opaque) {
return token -> token.split("\\.", -1).length == 3 ? jwt.validate(token) : opaque.validate(token);
}
}

4. Add a test token issuer​

To run without an identity provider, generate an RSA key pair, sign tokens with the private key, and publish the public key as the key set the validator trusts. A real issuer does exactly this on its side.

import java.time.*;
import java.util.*;

import com.nimbusds.jose.*;
import com.nimbusds.jose.crypto.*;
import com.nimbusds.jose.jwk.*;
import com.nimbusds.jose.jwk.gen.*;
import com.nimbusds.jwt.*;

/**
* Stands in for your identity provider: owns an RSA key pair and signs short-lived JWTs with it.
*/
public class TokenIssuer {

private final RSAKey key;

public TokenIssuer() throws JOSEException {
key = new RSAKeyGenerator(2048).keyID("demo-key").algorithm(JWSAlgorithm.RS256).generate();
}

/** The public half, as a JWKS document would publish it. */
public JWKSet publicKeys() {
return new JWKSet(key.toPublicJWK());
}

public String issue(String subject, String scope, String audience) throws JOSEException {
var now = Instant.now();
var claims = new JWTClaimsSet.Builder()
.issuer(SecureValidators.ISSUER)
.audience(audience)
.subject(subject)
.claim("scope", scope)
.issueTime(Date.from(now))
.notBeforeTime(Date.from(now))
.expirationTime(Date.from(now.plus(Duration.ofMinutes(5))))
.build();
var jwt = new SignedJWT(new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.getKeyID()).build(), claims);
jwt.sign(new RSASSASigner(key));
return jwt.serialize();
}
}

5. Add a local introspection endpoint​

A tiny JDK HTTP server stands in for the authorization server's introspection endpoint. It treats opaque-good as an active token with the orders:read scope, and every other token as revoked.

import java.io.*;
import java.net.*;
import java.nio.charset.*;

import com.sun.net.httpserver.*;

/**
* Stands in for an OAuth 2.0 authorization server's introspection endpoint.
* The token "opaque-good" is active with scope orders:read; every other token is inactive.
*/
public class TokenIntrospectionStub implements Closeable {

private final HttpServer server;

public TokenIntrospectionStub() throws IOException {
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/introspect", exchange -> {
var form = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
var body = form.contains("token=opaque-good")
? "{\"active\":true,\"sub\":\"reporting-job\",\"scope\":\"orders:read\"}"
: "{\"active\":false}";
var bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, bytes.length);
try (var out = exchange.getResponseBody()) {
out.write(bytes);
}
});
server.start();
}

public URI endpoint() {
return URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/introspect");
}

@Override
public void close() {
server.stop(0);
}
}

6. Call the endpoint as different callers​

MockRestClient calls the resource in-process, with no servlet container. Each call sends a different token.

import java.util.*;

import org.apache.juneau.rest.mock.classic.*;

import com.nimbusds.jose.jwk.source.*;

/**
* Calls the secured endpoint in-process with no token, good tokens and bad tokens.
*/
public class SecureDemo {

public static void main(String[] args) throws Exception {
System.out.println(run());
}

public static String run() throws Exception {
var issuer = new TokenIssuer();
try (var authServer = new TokenIntrospectionStub()) {
var validator = SecureValidators.either(
SecureValidators.jwt(new ImmutableJWKSet<>(issuer.publicKeys())),
SecureValidators.introspection(authServer.endpoint(), "orders-api", "change-me"));

try (var client = MockRestClient.buildLax(new SecureResource(validator))) {
var lines = new ArrayList<String>();

var res = client.get("/orders").run();
lines.add("no token: " + res.getStatusCode() + " " + res.getHeader("WWW-Authenticate").asString().orElse(""));

var good = issuer.issue("alice", "orders:read", SecureValidators.AUDIENCE);
lines.add("valid JWT: " + call(client, good));

var otherApi = issuer.issue("alice", "orders:read", "https://other.example.com");
lines.add("wrong audience: " + call(client, otherApi));

var noScope = issuer.issue("bob", "profile", SecureValidators.AUDIENCE);
lines.add("missing scope: " + call(client, noScope));

lines.add("opaque token: " + call(client, "opaque-good"));
lines.add("revoked token: " + call(client, "opaque-revoked"));
return String.join("\n", lines);
}
}
}

private static String call(MockRestClient client, String token) throws Exception {
var res = client.get("/orders").header("Authorization", "Bearer " + token).run();
return res.getStatusCode() == 200 ? "200 " + res.getContent().asString() : String.valueOf(res.getStatusCode());
}
}

Run it​

From the project directory:

mvn -q compile exec:java -Dexec.mainClass=com.example.SecureDemo

Expected output:

no token:        401 Bearer realm="orders"
valid JWT: 200 "orders for alice"
wrong audience: 401
missing scope: 403
opaque token: 200 "orders for reporting-job"
revoked token: 401

How it works​

  • The guard runs first. A RestGuardList bean applies to every operation in the resource. BearerTokenGuard answers 401 with WWW-Authenticate: Bearer realm="orders" when the header is missing or the validator rejects the token, so your @RestOp methods never see an unauthenticated call. See AuthN Guards.
  • Validation is pluggable. TokenValidator is a single-method interface, which is why either can be a lambda. With a JWKS URL, JwtTokenValidator caches the key set for 5 minutes and refetches it early when a token names a key id it hasn't seen, so key rotation needs no restart. Invalid signatures, expired tokens and a wrong aud or iss all fail validation, so the wrong-audience call gets 401, not 403. See OAuth 2.0 / OIDC AuthN Support.
  • Authorization stays in your code. The guard only proves who the caller is. The scope check in orders decides what they may do, and answers 403. A JWT carries scope as a space-separated string and an introspection response carries it as a list, so hasScope accepts both.
  • Other ways in. For authentication that runs at the servlet-filter layer, or that covers several resources at once, see the AuthN Filter Framework and the REST Authenticator.

Next steps​