Skip to main content
the auth layer

Token Lifecycle: Issuance, Refresh, Rotation, and the Race Condition Nobody Talks About

6 min read Chapter 13 of 45

Token Lifecycle

A token is not a thing. It is a state machine. It transitions from issued to active to refreshed to expired or revoked. Each transition is a security boundary. A token in the wrong state, accepted by a resource server that does not track state, is a vulnerability.

Most tutorials cover issuance and validation. They skip the transitions in between: the refresh that replaces one token with another, the rotation that invalidates the old token, the revocation that should propagate but does not. The gaps between these states are where attacks live.

The States

A token in the multi-tenant SaaS platform moves through these states:

Issued. The authorization server creates the token after a successful grant. The token exists but has not yet been presented to a resource server. For access tokens, this state lasts milliseconds. For refresh tokens stored server-side, this state persists until the client needs a new access token.

Active. The token has been issued and its exp claim is in the future. Resource servers accept it. In our system, an access token lives for 5 minutes. A refresh token lives for 24 hours. These durations are not arbitrary: 5 minutes bounds the exposure window if a token is stolen, 24 hours balances user experience against the risk of a long-lived refresh token.

Refreshed. The client presented the refresh token to the token endpoint and received a new access token (and possibly a new refresh token). The old access token remains technically valid until its exp passes, but the client discards it. The old refresh token’s state depends on whether rotation is enabled.

Rotated. With refresh token rotation enabled, the old refresh token is invalidated when the new one is issued. The grant chain (the sequence of refresh tokens descended from the original authorization) is maintained. Any attempt to use the old refresh token triggers replay detection.

Revoked. An explicit revocation request (RFC 7009) marks the token as no longer valid. For opaque tokens, this is definitive: the introspection endpoint returns "active": false. For JWTs, revocation is a suggestion: the authorization server records the revocation, but resource servers validating locally do not know about it until the token expires.

Expired. The exp claim is in the past. Resource servers reject the token. This is the only state transition that requires no coordination between services. It happens automatically, everywhere, at the same moment (assuming clock synchronization).

The Dangerous Transitions

The transition from Active to Rotated is where the race condition lives. Two concurrent refresh requests create a fork in the state machine: two new refresh tokens exist, but only one should. Chapter 5, Section 1 dissects this in detail.

The transition from Active to Revoked is incomplete for JWTs. The authorization server records the revocation, but resource servers continue accepting the token because they validate locally. Chapter 5, Section 2 covers the strategies for propagating revocation across a distributed system.

Token Settings in Spring Authorization Server

@Bean
public RegisteredClientRepository registeredClientRepository(
        PasswordEncoder passwordEncoder) {

    RegisteredClient frontendShell = RegisteredClient
        .withId(UUID.randomUUID().toString())
        .clientId("frontend-shell")
        .clientSecret(passwordEncoder.encode("frontend-secret"))
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("https://app.saas.example/callback")
        .scope(OidcScopes.OPENID)
        .scope("tenant:read")
        .scope("tenant:write")
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofMinutes(5))
            .refreshTokenTimeToLive(Duration.ofHours(24))
            .reuseRefreshTokens(false)  // Enable rotation
            .build())
        .build();

    RegisteredClient mobileClient = RegisteredClient
        .withId(UUID.randomUUID().toString())
        .clientId("mobile-client")
        .clientSecret(passwordEncoder.encode("mobile-secret"))
        .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
        .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
        .redirectUri("com.saas.mobile://callback")
        .scope(OidcScopes.OPENID)
        .scope("tenant:read")
        .tokenSettings(TokenSettings.builder()
            .accessTokenTimeToLive(Duration.ofMinutes(5))
            .refreshTokenTimeToLive(Duration.ofDays(30)) // Longer for mobile
            .reuseRefreshTokens(false)
            .build())
        .build();

    return new InMemoryRegisteredClientRepository(frontendShell, mobileClient);
}

The .reuseRefreshTokens(false) setting is the rotation switch. When false, every refresh request generates a new refresh token. The old refresh token should be invalidated. Whether it actually is, and what happens when it is reused, depends on the OAuth2AuthorizationService implementation.

The default InMemoryOAuth2AuthorizationService handles rotation correctly for a single instance. In a clustered deployment, two instances may both accept the same refresh token before either records the rotation. Redis-backed implementations solve this with atomic operations, covered in CH5-S1.

Access Token TTL: The Security-UX Tradeoff

Short access token TTL reduces the exposure window but increases refresh frequency. Every refresh request is a round trip to the authorization server. If the authorization server is down, the client cannot refresh, and users see errors after 5 minutes.

The decision rule:

TTLUse caseTradeoff
1 minHigh-security internal APIsAggressive refresh, high auth server load
5 minStandard SaaS applicationBalanced: stolen token useful for 5 minutes max
15 minLow-risk read-only APIsFewer refreshes, but 15-minute exposure window
60 minPartner APIs with introspection fallbackLong window, but introspection catches revocation

For the multi-tenant platform, 5 minutes for access tokens is the starting point. Adjust based on observed traffic patterns and the authorization server’s capacity to handle refresh requests at scale. If the auth server handles 10,000 refresh requests per minute during peak load and the 95th percentile latency stays under 50ms, the TTL is acceptable.

Refresh Token TTL: Sliding Window vs Fixed Expiration

Two strategies for refresh token lifetime:

Fixed expiration (absolute TTL). The refresh token expires 24 hours after issuance, regardless of activity. The user must re-authenticate daily. Used when compliance requires periodic re-authentication (PCI DSS, SOC 2 controls).

Sliding window (relative TTL). Each refresh extends the token’s lifetime. A 24-hour sliding window means the user stays authenticated as long as they are active within any 24-hour period. The maximum absolute lifetime is capped (e.g., 30 days) to prevent indefinite sessions.

Spring Authorization Server does not natively support sliding window refresh tokens. Implementing it requires a custom OAuth2TokenGenerator that resets the exp claim on each rotation:

// Fixed expiration (default): refresh token expires at original issue time + TTL
// Sliding window (custom): refresh token expires at rotation time + TTL

@Component
public class SlidingWindowRefreshTokenGenerator implements OAuth2TokenGenerator<OAuth2RefreshToken> {

    private final Duration slidingWindow = Duration.ofHours(24);
    private final Duration absoluteMax = Duration.ofDays(30);

    @Override
    public OAuth2RefreshToken generate(OAuth2TokenContext context) {
        if (!OAuth2TokenType.REFRESH_TOKEN.equals(context.getTokenType())) {
            return null;
        }

        Instant now = Instant.now();
        Instant expiresAt = now.plus(slidingWindow);

        // Cap at absolute maximum from original grant
        OAuth2Authorization authorization = context.getAuthorization();
        if (authorization != null) {
            Instant originalIssue = authorization.getAuthorizationGrantType()
                .equals(AuthorizationGrantType.REFRESH_TOKEN)
                ? authorization.getToken(OAuth2RefreshToken.class)
                    .getToken().getIssuedAt()
                : now;

            Instant absoluteLimit = originalIssue.plus(absoluteMax);
            if (expiresAt.isAfter(absoluteLimit)) {
                expiresAt = absoluteLimit;
            }
        }

        return new OAuth2RefreshToken(
            UUID.randomUUID().toString(), now, expiresAt);
    }
}

This implementation enforces both the sliding window and the absolute maximum. A user who refreshes every 23 hours stays authenticated for up to 30 days. After 30 days, re-authentication is required regardless of activity.