Token Lifetimes and Refresh

Keep access tokens short, use rotating refresh tokens stored server-side, and handle logout and revocation for JWTs that cannot be recalled.

Syntaxaccess token: JWT, 5-15 min, Authorization: Bearer ... refresh token: 32 random bytes, stored as SHA-256, rotated on use POST /auth/refresh (Cookie: refresh_token=...) -> new access token + new refresh cookie

Once a JWT is signed, the resource server accepts it until exp. Logging out, changing a password or disabling an account does nothing to tokens already handed out. The lifetime of the access token is therefore the window in which a stolen token is useful, and it is the first number to choose.

Short access tokens, longer refresh tokens

A common split is an access token of 5 to 15 minutes and a refresh token of days or weeks. The client sends the access token on every call. When it expires, the API answers 401 and the client exchanges its refresh token for a new pair, without asking the user to log in again.

If you use an external authorization server, it implements refresh for you; configure the lifetimes there. If you issue your own tokens, you also own refresh.

Designing refresh tokens

  • Opaque and random, not JWTs: 32 bytes from SecureRandom. They only ever go back to your server, so there is nothing to put inside them.
  • Stored as a hash. Keep a SHA-256 of the token with the user id, expiry and a family id. A leaked database then holds nothing usable.
  • Rotated on every use. Each refresh marks the old token used and issues a new one in the same family.
  • Reuse detection. If a token that is already used comes back, someone copied it. Revoke the whole family so both the attacker and the user must log in again.

The refreshed access token must carry the same authorisation claims as the original: the same scope and roles, built by the same code as the login endpoint. A refresh that only sets the subject produces a token that passes signature checks and then fails every hasAuthority("SCOPE_...") rule. Look the user up again on each refresh rather than copying claims from the old token; then a removed role, or a disabled account, takes effect within one access token lifetime.

For browser apps, send the refresh token in an HttpOnly, Secure, SameSite=Strict cookie scoped to the refresh path, so page scripts cannot read it. Because the browser then sends a credential automatically, protect that one endpoint against cross-site requests: SameSite helps, and checking the Origin header adds a second line.

Logout and revocation

Logout deletes the refresh token family. The current access token keeps working until it expires, which is why it is short. When minutes matter, for example after an account is disabled, add a denylist of jti values or a per-user "tokens issued before" timestamp, checked by a custom validator. Both bring back a lookup per request, so keep the store fast (Redis is common) and only as large as the access token lifetime requires.

Clocks

The decoder allows 60 seconds of clock skew by default when checking exp and nbf. Keep servers on NTP rather than widening the skew; a wide skew quietly extends every token's life.

Example

Example · java
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

record IssuedRefresh(String token, String userId) {
}

@Service
public class RefreshTokenService {

    private static final Duration TTL = Duration.ofDays(14);
    private static final SecureRandom RANDOM = new SecureRandom();

    private final JdbcClient jdbc;

    RefreshTokenService(JdbcClient jdbc) {
        this.jdbc = jdbc;
    }

    @Transactional
    public String issue(String userId) {
        return store(userId, UUID.randomUUID());
    }

    // Swap a refresh token for a new one in the same family. Empty means unknown,
    // expired or replayed; the caller answers 401 in every case.
    @Transactional
    public Optional<IssuedRefresh> rotate(String presented) {
        record Row(String userId, UUID familyId, boolean used, Instant expiresAt) {
        }
        Optional<Row> row = jdbc.sql("""
                        SELECT user_id, family_id, used, expires_at
                        FROM refresh_token WHERE token_hash = :hash FOR UPDATE""")
                .param("hash", hash(presented))
                .query(Row.class)
                .optional();
        if (row.isEmpty() || row.get().expiresAt().isBefore(Instant.now())) {
            return Optional.empty();
        }
        Row current = row.get();
        if (current.used()) {
            // An old token came back: assume it was stolen and end the whole family.
            // Return instead of throwing: a runtime exception would roll the DELETE back.
            jdbc.sql("DELETE FROM refresh_token WHERE family_id = :family")
                    .param("family", current.familyId())
                    .update();
            return Optional.empty();
        }
        jdbc.sql("UPDATE refresh_token SET used = TRUE WHERE token_hash = :hash")
                .param("hash", hash(presented))
                .update();
        return Optional.of(new IssuedRefresh(store(current.userId(), current.familyId()), current.userId()));
    }

    @Transactional
    public void revokeFamilyOf(String presented) {
        jdbc.sql("""
                        DELETE FROM refresh_token WHERE family_id =
                          (SELECT family_id FROM refresh_token WHERE token_hash = :hash)""")
                .param("hash", hash(presented))
                .update();
    }

    private String store(String userId, UUID familyId) {
        byte[] bytes = new byte[32];
        RANDOM.nextBytes(bytes);
        String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        jdbc.sql("""
                        INSERT INTO refresh_token (token_hash, user_id, family_id, used, expires_at)
                        VALUES (:hash, :userId, :family, FALSE, :expiresAt)""")
                .param("hash", hash(token))
                .param("userId", userId)
                .param("family", familyId)
                .param("expiresAt", java.sql.Timestamp.from(Instant.now().plus(TTL)))
                .update();
        return token;
    }

    private static String hash(String token) {
        try {
            byte[] digest = MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest);
        } catch (NoSuchAlgorithmException e) {
            throw new IllegalStateException(e);
        }
    }
}

When to use it

  • A banking-style web app keeps access tokens at five minutes and refreshes silently while the user is active.
  • A stolen refresh token replayed from another device triggers reuse detection and logs out every session in that family.
  • Disabling an employee account adds their live token ids to a Redis denylist so access ends within seconds, not at expiry.

More examples

The refresh token table

Put this in a Flyway migration. The family index makes revoking a whole chain cheap, and the expiry index keeps the clean-up job fast. Used rows stay until they expire on purpose: they are what lets you recognise a replayed token.

Example · sql
CREATE TABLE refresh_token (
    token_hash  CHAR(64)     PRIMARY KEY,     -- hex SHA-256, never the token itself
    user_id     VARCHAR(64)  NOT NULL,
    family_id   UUID         NOT NULL,
    used        BOOLEAN      NOT NULL DEFAULT FALSE,
    expires_at  TIMESTAMPTZ  NOT NULL,
    created_at  TIMESTAMPTZ  NOT NULL DEFAULT now()
);

CREATE INDEX refresh_token_family_idx ON refresh_token (family_id);
CREATE INDEX refresh_token_expires_idx ON refresh_token (expires_at);

-- Run regularly, for example from a scheduled job:
-- DELETE FROM refresh_token WHERE expires_at < now();

A refresh endpoint with an HttpOnly cookie

The browser keeps the refresh token where scripts cannot read it and sends it only to <code>/auth</code> paths. The access token goes back in the body and lives in memory in the front end. A refreshed access token must carry the same <code>scope</code> and <code>roles</code> claims as the one issued at login, or it fails every <code>SCOPE_</code> rule; the controller loads the user again and builds them with the same role-to-scope table, which also means a role change or a disabled account takes effect at the next refresh. Logout clears the cookie with a zero max age and revokes the family on the server, so the old cookie is useless even if it was copied first.

Example · java
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeSet;
import java.util.UUID;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.oauth2.jwt.JwsHeader;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

interface RefreshTokens {
    // Returns the new refresh token and its user, or empty when the token is unknown, expired or replayed.
    // userId is the username the login token used as its subject.
    Optional<Rotated> rotate(String presented);

    void revokeFamilyOf(String presented);

    record Rotated(String token, String userId) {
    }
}

record AccessToken(String access_token, String token_type, long expires_in) {
}

@RestController
public class RefreshController {

    private static final Duration ACCESS_TTL = Duration.ofMinutes(10);
    private static final Duration REFRESH_TTL = Duration.ofDays(14);

    // The same role-to-scope table as the login endpoint; share one helper in real code.
    private static final Map<String, List<String>> SCOPES_BY_ROLE = Map.of(
            "ROLE_USER", List.of("orders:read"),
            "ROLE_SUPPORT", List.of("orders:read", "orders:write"),
            "ROLE_ADMIN", List.of("orders:read", "orders:write"));

    private final RefreshTokens refreshTokens;
    private final UserDetailsService users;
    private final JwtEncoder encoder;

    RefreshController(RefreshTokens refreshTokens, UserDetailsService users, JwtEncoder encoder) {
        this.refreshTokens = refreshTokens;
        this.users = users;
        this.encoder = encoder;
    }

    @PostMapping("/auth/refresh")
    ResponseEntity<AccessToken> refresh(@CookieValue(name = "refresh_token", required = false) String presented) {
        if (presented == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        Optional<RefreshTokens.Rotated> rotated = refreshTokens.rotate(presented);
        if (rotated.isEmpty()) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        // Load the user again: current roles, and a disabled, locked, expired or deleted account gets no new token.
        Optional<UserDetails> user = currentUser(rotated.get().userId());
        if (user.isEmpty()) {
            refreshTokens.revokeFamilyOf(rotated.get().token());
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }
        return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, cookie(rotated.get().token(), REFRESH_TTL).toString())
                .body(new AccessToken(accessToken(user.get()), "Bearer", ACCESS_TTL.toSeconds()));
    }

    private Optional<UserDetails> currentUser(String userId) {
        try {
            UserDetails user = users.loadUserByUsername(userId);
            // The same account checks the login's DaoAuthenticationProvider makes.
            boolean usable = user.isEnabled() && user.isAccountNonLocked() && user.isAccountNonExpired();
            return usable ? Optional.of(user) : Optional.empty();
        } catch (UsernameNotFoundException e) {
            return Optional.empty();
        }
    }

    @PostMapping("/auth/logout")
    ResponseEntity<Void> logout(@CookieValue(name = "refresh_token", required = false) String presented) {
        if (presented != null) {
            refreshTokens.revokeFamilyOf(presented);
        }
        return ResponseEntity.noContent()
                .header(HttpHeaders.SET_COOKIE, cookie("", Duration.ZERO).toString())
                .build();
    }

    // Same claims as the login token: scope from the role table, roles for hasRole rules.
    private String accessToken(UserDetails user) {
        TreeSet<String> scopes = new TreeSet<>();
        List<String> roles = new ArrayList<>();
        Collection<? extends GrantedAuthority> authorities = user.getAuthorities();
        for (GrantedAuthority authority : authorities) {
            String name = authority.getAuthority();
            scopes.addAll(SCOPES_BY_ROLE.getOrDefault(name, List.of()));
            if (name.startsWith("ROLE_")) {
                roles.add(name.substring("ROLE_".length()));
            }
        }
        Instant now = Instant.now();
        JwtClaimsSet claims = JwtClaimsSet.builder()
                .issuer("https://api.example.com")
                .id(UUID.randomUUID().toString())
                .subject(user.getUsername())
                .audience(List.of("orders-api"))
                .issuedAt(now)
                .expiresAt(now.plus(ACCESS_TTL))
                .claim("scope", String.join(" ", scopes))
                .claim("roles", roles)
                .build();
        return encoder.encode(JwtEncoderParameters.from(JwsHeader.with(SignatureAlgorithm.RS256).build(), claims))
                .getTokenValue();
    }

    private static ResponseCookie cookie(String value, Duration maxAge) {
        return ResponseCookie.from("refresh_token", value)
                .httpOnly(true)
                .secure(true)
                .sameSite("Strict")
                .path("/auth") // sent only to /auth/refresh and /auth/logout
                .maxAge(maxAge)
                .build();
    }
}

Tightening the clock skew on the decoder

<code>setJwtValidator</code> replaces the default validators, so the timestamp check must be added back, here with 10 seconds of skew instead of 60. The <code>jti</code> denylist only needs to remember each revoked id until that token would have expired anyway, which keeps it small when access tokens are short. Issue every access token with a <code>jti</code> (<code>JwtClaimsSet.Builder.id</code>) so there is an id to revoke.

Example · java
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

@Configuration
public class StrictDecoderConfig {

    @Bean
    JwtDecoder jwtDecoder(@Value("${app.jwt.public-key}") RSAPublicKey publicKey,
                          RevokedTokens revoked) {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withPublicKey(publicKey).build();
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                new JwtTimestampValidator(Duration.ofSeconds(10)),
                new JwtIssuerValidator("https://api.example.com"),
                notRevoked(revoked)));
        return decoder;
    }

    private static OAuth2TokenValidator<Jwt> notRevoked(RevokedTokens revoked) {
        // Tokens must carry a jti for this to work; without one there is nothing to look up.
        return jwt -> jwt.getId() != null && revoked.contains(jwt.getId())
                ? OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Token revoked", null))
                : OAuth2TokenValidatorResult.success();
    }

    @Bean
    RevokedTokens revokedTokens() {
        return Set.<String>of()::contains; // replace with a Redis-backed lookup in production
    }
}

interface RevokedTokens {
    boolean contains(String jti);
}

Discussion

  • Be the first to comment on this lesson.