Build CoreOrdered learning track

Token Lifecycle & Revocation

Learn Java Authentication Pattern - Part 018

Token Lifecycle & Revocation untuk Java authentication: access token, refresh token, expiry, rotation, token family, reuse detection, revocation endpoint, introspection, logout semantics, Redis/PostgreSQL design, dan incident runbook.

10 min read1955 words
PrevNext
Lesson 1840 lesson track09–22 Build Core
#java#authentication#token#refresh-token+7 more

Part 018 — Token Lifecycle & Revocation

Target part ini: memahami token sebagai objek lifecycle, bukan string yang “ada expiry”-nya. Kita akan membahas access token, refresh token, expiry, refresh rotation, token family, reuse detection, revocation, introspection, logout semantics, Redis/PostgreSQL design, Spring integration, dan runbook token compromise.

Token authentication sering gagal bukan saat token dibuat.

Ia gagal saat token harus mati.

Pertanyaan produksi yang sering terlambat ditanyakan:

Apa yang terjadi saat user logout?
Apa yang terjadi saat refresh token dicuri?
Apa yang terjadi saat role dicabut?
Apa yang terjadi saat signing key bocor?
Apa yang terjadi saat device hilang?
Apa yang terjadi saat tenant menonaktifkan user?
Apa yang terjadi saat authorization server down?

Part ini membahas sisi yang sering disederhanakan: lifecycle dan revocation.


1. Mental Model: Token Is a Leased Credential

Token bukan identity permanen. Token adalah credential dengan lease.

issued -> active -> refreshed / rotated -> expired / revoked / compromised

Access token biasanya pendek umur. Refresh token biasanya lebih panjang umur.

Production invariant:

Every token has an owner, issuer, audience, purpose, lifetime, and death path.

If a token cannot be killed, its lifetime must be short enough that the business accepts the risk.


2. Access Token vs Refresh Token

TokenPurposeLifetimeUsed BySent To
Access tokenCall protected APIShortClient/API callerResource server
Refresh tokenObtain new access tokenLongerClientAuthorization server

Do not send refresh tokens to resource servers. Do not store refresh tokens in JavaScript-accessible browser storage. Do not use refresh tokens as API credentials.

Access token compromise means attacker can call APIs until expiry/revocation. Refresh token compromise means attacker can mint new access tokens until refresh token is revoked or reuse is detected.

Refresh tokens are more sensitive than access tokens.


3. Token Lifetime Design

There is no universal perfect TTL.

You choose TTL based on:

risk level
client type
revocation requirement
authorization volatility
network availability
user experience
ability to detect compromise

Example policy matrix:

ContextAccess TokenRefresh TokenNotes
Internal service-to-service5-15 minUsually nonePrefer client credentials or workload identity
Browser BFF session5-15 minServer-side onlyBrowser gets session cookie, not raw refresh token
Mobile app5-15 minDays/weeks with rotationBind to device when possible
Admin console2-5 minShorter, step-up requiredHigh-risk operations need re-auth
Public SPAShortAvoid or rotate with strong controlsPrefer BFF where possible

Rule:

The more revocation matters, the shorter the local-verification window should be.

4. Absolute Expiry vs Idle Expiry

Token lifecycle can use:

absolute expiry: token dies after fixed time from issuance
idle expiry: credential dies after inactivity
max session age: entire grant/session dies after maximum lifetime

Access token usually has absolute expiry only. Refresh token/session often has both idle and absolute expiry.

Example:

access_token TTL       = 10 minutes
refresh_token idle TTL = 30 days
session max age        = 90 days

Each refresh extends idle expiry but not max session age.


5. Logout Semantics Are Not One Thing

“Logout” can mean several different operations.

Logout TypeMeaningRequired Action
Local app logoutRemove app sessionClear local session/cookie/token storage
OAuth client logoutStop client from refreshingRevoke refresh token/grant
Resource server cutoffStop access token useShort TTL or revocation/introspection
SSO logoutEnd IdP sessionOIDC logout protocol / IdP session termination
All-device logoutRevoke all sessions/grantsAccount/session version bump or revoke all token families

Bad requirement statement:

Implement logout for JWT.

Better:

On logout from this browser, invalidate the server-side app session and revoke the refresh token family. Existing access tokens may remain valid for at most 5 minutes unless high-risk revocation is triggered.

A senior engineer clarifies logout semantics before choosing mechanism.


6. Self-Contained Token Revocation Problem

A self-contained JWT can be validated locally.

That means resource server can accept it without calling issuer.

Good:

low latency
resilience to issuer outage
simple scaling

Bad:

issuer cannot instantly tell every resource server the token is dead

Therefore:

local validation and immediate revocation are in tension

You can resolve tension with:

short access token TTL
revocation list
introspection
subject/session version check
event-driven revocation cache
sender-constrained tokens

But you cannot get immediate global revocation with purely stateless local JWT validation and long token lifetime.


7. RFC 7009 Token Revocation

OAuth 2.0 Token Revocation defines a standard way for a client to tell the authorization server a token is no longer needed.

Simplified request:

POST /oauth2/revoke
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <client-auth>

token=<refresh-token>&token_type_hint=refresh_token

Important nuance:

Revocation request invalidates the token at the authorization server.
It does not magically erase already-issued self-contained access tokens from all resource servers.

If access tokens are self-contained JWTs, existing access tokens may remain accepted until expiry unless resource servers check revocation state.


8. RFC 7662 Token Introspection

Token introspection lets a protected resource query authorization server to determine whether a token is active and retrieve metadata.

Simplified request:

POST /oauth2/introspect
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <resource-server-auth>

token=<access-token>

Simplified response:

{
  "active": true,
  "sub": "user:123",
  "client_id": "case-web",
  "scope": "case:read",
  "exp": 1783060900
}

Introspection is especially useful for opaque tokens.

Trade-off:

PropertyLocal JWTIntrospection
LatencyLowNetwork call/cache needed
Revocation freshnessWeak aloneStronger
Issuer outage toleranceBetterWorse unless cached
Operational simplicityMediumMedium/high
Central policy controlLowerHigher

9. Opaque Token Pattern

Opaque token is a reference.

tok_live_01J2G7QX...

Resource server cannot read claims locally. It must introspect or query a trusted token store.

Flow:

Opaque token is a good fit when:

revocation freshness matters
claims are sensitive
centralized control matters
resource servers are few or can tolerate introspection

JWT is a good fit when:

local validation matters
access token lifetime is short
claims are safe and compact
revocation delay is acceptable

10. Refresh Token Rotation

Refresh token rotation means every refresh returns a new refresh token and invalidates the old one.

Why it matters:

If RT1 is stolen, attacker and legitimate client will eventually race.
Reuse of old token reveals compromise.

RFC 9700 states that refresh tokens for public clients must be sender-constrained or use refresh token rotation.


11. Refresh Token Family

A refresh token family groups all tokens derived from the same original grant/session.

family_id = fam_01J2...
RT1 -> RT2 -> RT3 -> RT4

If RT2 is reused after RT3 exists, you may not know who is legitimate.

Conservative response:

revoke the entire family
force re-authentication
emit security event
notify user if risk policy requires

State model:

This is the core of reuse detection.


12. Token Store Schema

For production-grade refresh token lifecycle, you need state.

PostgreSQL model:

create table oauth_token_family (
    id uuid primary key,
    account_id uuid not null,
    client_id text not null,
    tenant_id uuid,
    created_at timestamptz not null,
    max_expires_at timestamptz not null,
    revoked_at timestamptz,
    revoked_reason text,
    compromise_detected_at timestamptz
);

create table oauth_refresh_token (
    id uuid primary key,
    family_id uuid not null references oauth_token_family(id),
    token_hash bytea not null unique,
    parent_token_id uuid references oauth_refresh_token(id),
    status text not null,
    issued_at timestamptz not null,
    used_at timestamptz,
    expires_at timestamptz not null,
    replaced_by_token_id uuid references oauth_refresh_token(id),
    client_fingerprint_hash bytea,
    ip_hash bytea,
    user_agent_hash bytea,
    constraint refresh_token_status_check check (
        status in ('ACTIVE', 'USED', 'REVOKED', 'EXPIRED', 'COMPROMISED')
    )
);

create index idx_refresh_token_family_status
    on oauth_refresh_token(family_id, status);

create index idx_refresh_token_expiry
    on oauth_refresh_token(expires_at);

Store token hash, not raw refresh token.

Raw token is shown once to client. Server stores hash.


13. Refresh Token Format

Refresh token should be high entropy.

Example format:

rt_live_<base64url-random-256-bit-secret>

Prefix is useful for operations:

rt_live_ = production live refresh token
rt_test_ = test refresh token
at_      = opaque access token

But prefix is not security. Entropy is security.

Storage:

String rawToken = "rt_live_" + randomBase64Url(32);
byte[] tokenHash = hmacSha256(serverPepper, rawToken);

Hashing refresh tokens prevents database read access from immediately becoming token replay capability.

Use keyed HMAC or strong hash with server-side pepper.


14. Atomic Refresh Rotation

Refresh rotation must be atomic.

Failure mode:

two concurrent refresh requests use same RT1
both succeed
client receives RT2 and RT3
server state becomes inconsistent

Use transaction + row lock:

@Transactional
public TokenResponse refresh(String rawRefreshToken, ClientContext context) {
    byte[] hash = tokenHasher.hash(rawRefreshToken);

    RefreshToken token = refreshTokenRepository
        .findByHashForUpdate(hash)
        .orElseThrow(() -> invalidGrant());

    TokenFamily family = familyRepository
        .findByIdForUpdate(token.familyId())
        .orElseThrow(() -> invalidGrant());

    validateFamilyActive(family);
    validateTokenUsable(token);

    if (token.status() == RefreshTokenStatus.USED) {
        markFamilyCompromised(family, token, context);
        throw invalidGrant();
    }

    RefreshToken next = issueNextRefreshToken(token, context);
    token.markUsed(next.id());

    AccessToken accessToken = issueAccessToken(family, context);

    return new TokenResponse(accessToken.raw(), next.raw());
}

The exact repository implementation depends on your stack, but invariant is universal:

Only one request may transition a refresh token from ACTIVE to USED.

15. SQL Locking Pattern

PostgreSQL style:

select *
from oauth_refresh_token
where token_hash = :hash
for update;

Then:

update oauth_refresh_token
set status = 'USED',
    used_at = now(),
    replaced_by_token_id = :next_id
where id = :current_id
  and status = 'ACTIVE';

Check affected rows.

If zero rows updated, treat as reuse/race and evaluate family state.

Never implement refresh rotation as:

read token
if active then insert new token
update old token later

That creates race windows.


16. Redis for Access Token Revocation

If access token is JWT but you need emergency cutoff, Redis can store revoked jti until token expiry.

Key:

revoked:jti:<sha256(jti)> -> reason
TTL = token exp - now

Validation path:

Trade-off:

Adds state and Redis dependency to JWT validation.

Use only when business needs it. Otherwise short access token TTL may be enough.


17. Account-Level Revocation With Versioning

For “logout all devices” or “role change invalidates tokens”, use version claims.

Account table:

alter table account
add column token_version bigint not null default 0;

JWT claim:

{
  "sub": "user:123",
  "token_version": 17,
  "exp": 1783060900
}

Resource server validates:

token.token_version == current_account.token_version

On revoke-all:

update account
set token_version = token_version + 1
where id = :account_id;

Trade-off:

Requires lookup/cache in resource server.
Turns pure local JWT into semi-stateful validation.

A common optimization is short TTL + cached version lookup.


18. Grant-Level Revocation

OAuth-like systems should distinguish:

account
client
session
grant
token family
token

Revoking a single refresh token may not be enough.

Examples:

User ActionBetter Revocation Scope
Logout current browserCurrent token family/session
Logout all devicesAll active families for account/client/tenant
Password changedAll refresh token families for account, maybe all sessions
Admin disables userAll tokens and sessions for account
Client secret compromisedAll grants for client
Tenant suspendedAll grants under tenant

Revocation scope must match threat.


19. Device-Aware Token Families

For user-facing systems, token families should be device/session aware.

Fields:

device_id
client_id
tenant_id
created_ip_hash
last_ip_hash
user_agent_hash
last_used_at
risk_level

User UI:

Chrome on Windows — Jakarta — last used 2 hours ago
Safari on iPhone — Singapore — last used yesterday

Security action:

revoke this device
revoke all other devices

Do not expose raw token identifiers. Expose session/device metadata.


20. Reuse Detection Response

When refresh token reuse is detected:

mark token family compromised
revoke family
revoke related access tokens if tracked
emit high-risk event
require re-authentication
optionally require password reset/MFA depending on policy

Example event:

{
  "event_type": "REFRESH_TOKEN_REUSE_DETECTED",
  "severity": "HIGH",
  "account_id": "user:123",
  "client_id": "case-web",
  "tenant_id": "tenant:acme",
  "family_id": "fam_01J...",
  "ip_hash": "...",
  "user_agent_hash": "...",
  "occurred_at": "2026-07-03T03:15:00Z"
}

Do not silently ignore reuse. Reuse is a breach signal.


21. Client Storage Strategy

Where tokens live matters.

ClientAccess Token StorageRefresh Token Storage
BFF web appServer-sideServer-side
Traditional server-rendered appServer sessionServer session/token store
SPAMemory if possiblePrefer BFF; otherwise high caution
MobileSecure enclave/keystore where possibleSecure storage + rotation
CLIOS credential storeOS credential store
Machine clientSecret manager/workload identityUsually no refresh token

Browser localStorage is risky for bearer tokens because XSS can read it.

HttpOnly cookies reduce token theft by JavaScript but reintroduce CSRF considerations.

There is no free lunch.


22. BFF Token Lifecycle

Backend-for-Frontend pattern:

Browser holds only session cookie.
BFF holds tokens server-side.
BFF calls APIs with access token.

Flow:

Advantages:

refresh token not exposed to browser JavaScript
centralized CSRF/session controls
server can revoke/rotate safely

Costs:

BFF becomes stateful
more server complexity
must protect session store

23. Resource Server Caching of Introspection

If using introspection, resource server may cache positive responses.

Cache key:

sha256(access_token)

Cache TTL:

min(token_exp - now, configured_max_cache_ttl)

Do not cache beyond token expiry.

Trade-off:

Longer cache TTL improves performance but delays revocation visibility.

Example:

high-risk API introspection cache = 0-5 seconds
normal API introspection cache = 30-60 seconds
low-risk API introspection cache = 2-5 minutes

Revocation event can evict cache entries if token id is known.


24. Spring Security Opaque Token Introspection

Spring Security Resource Server supports opaque token introspection.

Example:

spring:
  security:
    oauth2:
      resourceserver:
        opaquetoken:
          introspection-uri: https://idp.example.com/oauth2/introspect
          client-id: case-api
          client-secret: ${INTROSPECTION_CLIENT_SECRET}

Security config:

@Configuration
@EnableWebSecurity
class OpaqueTokenSecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .opaqueToken(Customizer.withDefaults())
            )
            .build();
    }
}

For production, wrap/customize introspection to add:

timeouts
circuit breaker
metrics
safe caching
claim normalization
audience validation
tenant validation

25. Token Revocation Event Propagation

For distributed systems, revocation often needs event propagation.

Event payload should not contain raw tokens.

Use:

family_id
jti
subject_id
tenant_id
client_id
revoked_at
reason

26. Logout Endpoint Design

For application logout:

POST /auth/logout
Cookie: SESSION=...

Server actions:

identify current session/token family
revoke refresh token family
clear server session
clear cookie
emit logout event
return generic success

Pseudo-code:

@PostMapping("/auth/logout")
public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response) {
    AuthSession session = sessionResolver.currentSession(request);

    if (session != null) {
        tokenLifecycleService.revokeFamily(
            session.tokenFamilyId(),
            RevocationReason.USER_LOGOUT
        );
        serverSessionStore.delete(session.id());
        audit.emitUserLogout(session.accountId(), session.id());
    }

    cookieService.clearSessionCookie(response);
    return ResponseEntity.noContent().build();
}

Make logout idempotent.

Calling logout twice should not error in a way that leaks session existence.

27. Admin Revocation API

Admin revocation is high-risk.

API:

POST /admin/accounts/{accountId}/sessions/revoke
Content-Type: application/json

{
  "scope": "ALL_DEVICES",
  "reason": "ACCOUNT_COMPROMISE"
}

Required controls:

strong admin authentication
step-up authentication for high-risk action
authorization check
reason required
audit event immutable
rate limit/bulk operation control
notification policy

Admin revocation itself is a privileged action. Treat it like money movement or evidence deletion.


28. Token Compromise Runbook

When token compromise is suspected:

1. Determine token type: access, refresh, ID, API key, signing key.
2. Determine scope: one user, one client, one tenant, global.
3. Revoke token/family/grant/client as appropriate.
4. If JWT access tokens exist, evaluate expiry window and revocation cache need.
5. Rotate signing keys if key compromise is possible.
6. Force re-authentication where needed.
7. Preserve logs and audit evidence.
8. Notify affected users/tenants according to policy.
9. Add detection rule for similar pattern.
10. Post-incident: reduce lifetime or improve sender constraint if needed.

Do not start by “rotating everything” unless blast radius demands it. Uncontrolled rotation can cause platform-wide outage.


29. Failure Modes

Failure ModeCauseDefense
Refresh token replay succeedsNo rotation/atomicitySingle-use refresh tokens + lock
Old access token works too longLong JWT TTLShort TTL/revocation/introspection
Logout does not revoke refresh tokenOnly client storage clearedServer-side family revocation
All devices logout misses mobile appSession model not device-awareToken family per device/session
Role removed but token keeps roleAuthorization embedded in long JWTShort TTL/version check/introspection
DB leak allows refresh replayRaw tokens storedStore token hash/HMAC only
Concurrent refresh breaks clientRace conditionRow lock/idempotency window strategy
Revocation cache outage fails openBad fallback policyDefine fail-open/fail-closed by API risk
Token event exposes secretsRaw token in eventUse ids/hashes only
Introspection overloadNo caching/backpressureCache + timeout + circuit breaker

30. Testing Strategy

Test lifecycle transitions, not only happy path.

Required tests:

@Test void refreshActiveTokenRotatesToNewToken() {}
@Test void refreshUsedTokenRevokesFamily() {}
@Test void concurrentRefreshAllowsOnlyOneSuccess() {}
@Test void revokedFamilyCannotRefresh() {}
@Test void expiredRefreshTokenIsRejected() {}
@Test void logoutRevokesCurrentTokenFamily() {}
@Test void revokeAllDevicesInvalidatesAllFamilies() {}
@Test void accessTokenRevocationListRejectsRevokedJti() {}
@Test void tokenHashIsStoredInsteadOfRawToken() {}
@Test void logoutIsIdempotent() {}

Integration test concurrent refresh:

@Test
void concurrentRefreshOnlyOneSucceeds() throws Exception {
    String refreshToken = issueRefreshTokenForTestUser();

    ExecutorService executor = Executors.newFixedThreadPool(2);
    Callable<Result> task = () -> tokenClient.refresh(refreshToken);

    List<Future<Result>> futures = executor.invokeAll(List.of(task, task));

    long success = futures.stream().filter(f -> f.get().isSuccess()).count();
    long failure = futures.stream().filter(f -> f.get().isInvalidGrant()).count();

    assertEquals(1, success);
    assertEquals(1, failure);
    assertTokenFamilyCompromisedOrRaceHandled(refreshToken);
}

Define whether concurrent same-client refresh is treated as compromise or benign race. For public clients, conservative compromise detection is safer. For BFF with retry behavior, you may implement a short idempotency grace window, but only intentionally.


31. Observability

Metrics:

token_issued_total{type,client,tenant}
token_refresh_total{client,result}
token_revoked_total{reason,scope}
refresh_token_reuse_detected_total{client,tenant}
introspection_latency_ms
introspection_error_total
revocation_cache_hit_total
jwt_revoked_jti_hit_total

Logs:

TOKEN_REFRESH_SUCCESS
TOKEN_REFRESH_INVALID_GRANT
REFRESH_TOKEN_REUSE_DETECTED
TOKEN_FAMILY_REVOKED
LOGOUT_COMPLETED
ADMIN_REVOKE_ALL_SESSIONS
INTROSPECTION_FAILED

Never log raw tokens.

Audit events must answer:

who triggered revocation
what was revoked
why
when
from where
which client/tenant/account was affected

32. Production Checklist

[ ] Access token TTL defined by risk class
[ ] Refresh token TTL and max session age defined
[ ] Refresh token rotation implemented for public clients
[ ] Refresh token reuse detection implemented
[ ] Token family model exists
[ ] Refresh token stored as hash/HMAC, not raw token
[ ] Refresh rotation is atomic
[ ] Logout semantics documented
[ ] Logout revokes server-side grant/family where needed
[ ] Admin revoke-all operation exists and is audited
[ ] Access token revocation strategy documented
[ ] Introspection/revocation cache policy documented
[ ] Raw tokens are never logged/events/analytics
[ ] Token compromise runbook exists
[ ] Concurrent refresh tested
[ ] Revocation event propagation tested

33. Decision Matrix

RequirementRecommended Pattern
Fast API validation, revocation delay acceptableShort-lived JWT access token
Immediate revocation requiredOpaque token + introspection or JWT + revocation state
Browser security prioritizedBFF + HttpOnly session cookie + server-held tokens
Mobile long-lived loginRefresh token rotation + secure storage + device family
High-risk adminShort token TTL + step-up + revocation/version check
Service-to-serviceClient credentials, mTLS/workload identity, short access tokens
Tenant shutdownTenant-level grant/session revocation + event propagation

34. Senior Engineer Summary

Token lifecycle design is a state machine.

Do not ask only:

How do we create a token?

Ask:

How does this token die?
How quickly must it die?
Who is allowed to kill it?
How do resource servers learn it is dead?
What evidence do we keep?
What happens during race, outage, and compromise?

A token system without revocation semantics is not incomplete documentation. It is incomplete security architecture.


35. References

Lesson Recap

You just completed lesson 18 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.