Revoking a JWT

Token revocation and introspection, and a filter that makes the management APIs reject a revoked JWT immediately.

  • Part 11
  • advanced
  • about 45 minutes

You will build

revocation and introspection checks, and an active-authorization filter on the management chain

You will understand

why a revoked JWT still verifies, and the trade-off between local validation, introspection and a targeted state check

  • one filter

Self-contained JWT access tokens are convenient because an API can verify them locally.

That convenience creates a revocation question:

Authorization Server marks token revoked
        ↓
JWT signature remains valid
        ↓
Offline resource server only checks signature/exp
        ↓
Token can still appear valid until expiry

In this part we use standardized OAuth endpoints and then add an extra state check for our sensitive management APIs.

Token introspection — RFC 7662

Introspection lets a protected resource ask the authorization server about the current state of a token.

Spring exposes:

POST /oauth2/introspect

Example:

curl -s \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/introspect \
  --data-urlencode "token=$ACCESS_TOKEN" \
  --data-urlencode "token_type_hint=access_token" \
  | jq

An active token can return:

{
  "active": true,
  "client_id": "demo-client",
  "username": "user"
}

The key contract is active.

Token revocation — RFC 7009

Spring exposes:

POST /oauth2/revoke

Revoke an access token:

curl -i \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/revoke \
  --data-urlencode "token=$ACCESS_TOKEN" \
  --data-urlencode "token_type_hint=access_token"

Then introspect it again:

curl -s \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/introspect \
  --data-urlencode "token=$ACCESS_TOKEN" \
  | jq

Expected:

{
  "active": false
}

But will a JWT-protected endpoint reject it?

Not necessarily.

A normal JWT resource server often checks:

signature
issuer
exp/nbf
audience (when configured)
claims/authorities

The authorization server’s database is not consulted automatically on every API request.

For high-volume external APIs, that may be an intentional tradeoff: short access-token TTL plus local validation.

For our authorization server’s own administrative APIs, we want stronger immediate enforcement.

Active authorization filter

After BearerTokenAuthenticationFilter has cryptographically authenticated the JWT, add a OncePerRequestFilter that looks up the exact access token through OAuth2AuthorizationService:

OAuth2Authorization authorization =
        authorizationService.findByToken(
                tokenValue,
                OAuth2TokenType.ACCESS_TOKEN
        );

Then:

boolean active =
        authorization != null
        && authorization.getAccessToken() != null
        && authorization.getAccessToken().isActive();

If inactive:

clear SecurityContext
return Bearer authentication error / 401

Add it after bearer JWT authentication:

http.addFilterAfter(
        new ActiveAuthorizationFilter(authorizationService),
        BearerTokenAuthenticationFilter.class
);

Request path after the change

flowchart TD
    Req[Bearer request]
    JWT[JWT signature / expiry validation]
    Auth[JwtAuthenticationToken]
    DB[OAuth2AuthorizationService.findByToken]
    Active{Token active?}
    RBAC{ROLE_ADMIN?}
    API[Management API]
    Deny[401 / 403]

    Req --> JWT --> Auth --> DB --> Active
    Active -->|No| Deny
    Active -->|Yes| RBAC
    RBAC -->|No| Deny
    RBAC -->|Yes| API

This deliberately adds a database lookup to sensitive management requests.

Test immediate rejection

Before revocation:

curl -i \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  http://localhost:9000/api/v1/clients

Expected:

200 OK

Revoke the token and run the same request again.

Expected:

401 Unauthorized

even though the JWT itself has not yet reached exp.

Refresh-token revocation

Obtain a fresh flow and revoke:

curl -i \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/revoke \
  --data-urlencode "token=$REFRESH_TOKEN" \
  --data-urlencode "token_type_hint=refresh_token"

Then try:

curl -i \
  -u demo-client:demo-secret \
  -X POST http://localhost:9000/oauth2/token \
  --data-urlencode "grant_type=refresh_token" \
  --data-urlencode "refresh_token=$REFRESH_TOKEN"

Expect:

400 Bad Request

with invalid_grant.

Local validation versus introspection

There is no single universal answer for every resource server.

StrategyBenefitCost
Local JWT verificationfast, no network dependencyrevocation awareness usually bounded by token lifetime
Introspectioncentral live statenetwork latency/availability dependency
Local JWT + targeted state checkuseful for highly sensitive local APIsdatabase/service lookup

Our template intentionally uses the third approach for its own management endpoints.

External resource servers can choose differently based on threat model and scale.

Automated tests

Protocol tests should verify:

new access token introspects active=true
revoked access token introspects active=false
revoked refresh token cannot refresh
management endpoint rejects revoked bearer token

Do not test only the HTTP status of /oauth2/revoke; verify the resulting state.

The code

This part corresponds to these commits in the repository:

  • 9e4edaf feat: enforce OAuth token revocation

Check out the last one to see the project exactly as this part leaves it:

git checkout 9e4edaf

References

Next: externalize runtime security configuration and add CORS, health probes, and a persistent security audit trail.