Revoking a JWT
Token revocation and introspection, and a filter that makes the management APIs reject a revoked JWT immediately.
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.
| Strategy | Benefit | Cost |
|---|---|---|
| Local JWT verification | fast, no network dependency | revocation awareness usually bounded by token lifetime |
| Introspection | central live state | network latency/availability dependency |
| Local JWT + targeted state check | useful for highly sensitive local APIs | database/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:
9e4edaffeat: enforce OAuth token revocation
Check out the last one to see the project exactly as this part leaves it:
git checkout 9e4edaf
References
- OAuth 2.0 Token Revocation — RFC 7009: https://www.rfc-editor.org/rfc/rfc7009
- OAuth 2.0 Token Introspection — RFC 7662: https://www.rfc-editor.org/rfc/rfc7662
- Spring Authorization Server settings/endpoints: https://docs.spring.io/spring-authorization-server/reference/configuration-model.html
- OAuth Security BCP — RFC 9700: https://www.rfc-editor.org/rfc/rfc9700
Next: externalize runtime security configuration and add CORS, health probes, and a persistent security audit trail.