Skip to main content

JWT Refresh Token Rotation in Spring Security 7 (With Reuse Detection)

Build refresh token rotation with token families and reuse detection on Spring Boot 4.1 and Spring Security 7.1. Opaque hashed tokens, a race-proof conditional UPDATE, a grace window, logout everywhere, and the access token that outlives logout, all run and captured.

You added JWT login, the access token expires in five minutes, and now the first user has been logged out in the middle of filling a form. The usual fix is a second token: a long-lived refresh token that quietly buys a new access token. That fix has its own trap. A refresh token that lives for weeks and can be used any number of times is a password with a nicer name, and if somebody copies it, nothing in your system will ever tell you. This post builds the version that does tell you. Every refresh spends the token and hands back a new one (rotation), all the tokens of one login form a chain (a family), and when a token that was already spent shows up again, the server revokes the whole chain (reuse detection). We build it on Spring Boot 4.1 and Spring Security 7.1 with an H2 table, run it, and read what it printed. You need to know what a JWT is and what a Spring Security filter chain does; if not, start with the Spring Security 7.1 JWT Authentication guide.
Versions this was built and run against. Spring Boot 4.1.2 (4.1.0 shipped on June 10, 2026; 4.2.0-RC1 and 4.2.0-RC2 are release candidates, not GA), Spring Security 7.1.2, Nimbus JOSE+JWT 10.9.1, Jackson 3.1.7 (tools.jackson), H2 2.4.240, Temurin JDK 25.0.5+8. Versions were read from the resolved Maven dependency tree and maven-metadata.xml, not from release blogs. Every console block below is copied from a file in the companion module, and the module’s tests assert the same numbers, so a number in this post stops being true only if a test starts failing. There is no docs/ folder: everything deeper than the main line sits in the expandable sections on this page.

A five-minute access token is only half of a login

An access token is a signed JWT that every API call carries. Nobody asks a database whether it is still good: the resource server checks the signature and the exp claim and moves on. That is what makes it cheap, and also why you cannot take it back. So you keep it short, five minutes here, and accept that a stolen one is useful for five minutes. A short access token needs something to renew it, or the user logs in every five minutes. That something is the refresh token. It travels to exactly one endpoint, and the server does look it up, because this is the one place where we are willing to pay a database read to keep control.
Two tokens, two jobsAccess token5 minutes5 minutes5 minutes5 minutes5 minutes5 minutessent on every API call, replaced by every refreshRefresh token7 days of idle time allowed, 30 days absolute cap on the whole loginsent to exactly one endpoint, POST /api/auth/refreshA thief who steals an access token has minutes. A thief who steals a refresh token has weeks, unless we notice.
The picture is the whole division of labour. The blue boxes are the token the world sees all day. The amber bar is the token that sits in storage for days and is shown to one URL. Protecting the amber bar is what the rest of this post is about, because it is the long-lived one.
Why not just make the access token live for a week? Because then “logout” and “this account was compromised” have no answer. A signed JWT keeps working until exp whatever you do on the server. Keeping the access token short and putting the long life in a revocable, server-side token moves the question “is this login still allowed?” to the one endpoint where asking is affordable.

Going deeper

The refresh token is not a JWT, it is 256 random bits the server looks up

It is tempting to make the refresh token a JWT too, since the signing code already exists. Resist it. We need two properties a JWT does not give us on its own: the token must be single use and it must be revocable. Both mean “ask the database”, and once you ask the database the signature adds nothing. So the refresh token is 32 bytes from SecureRandom, base64url encoded, and the table stores only its SHA-256.
    private Issued insertNew(String username, String familyId, long familyStartedAt, long now) {
        byte[] bytes = new byte[32];
        RANDOM.nextBytes(bytes);
        String raw = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
        long expiresAt = now + tokenTtl.toMillis();
        store.insert(new Row(UUID.randomUUID().toString(), hash(raw), familyId, username,
                RefreshTokenStore.ACTIVE, now, expiresAt, familyStartedAt, null));
        return new Issued(raw, familyId, username, Instant.ofEpochMilli(expiresAt));
    }
That method is in RefreshTokenService.java. The raw token goes back to the client and is never written down; the row keeps the hash, the owner, a status of ACTIVE, USED or REVOKED, and two timestamps used later for expiry. The schema is one table, in schema.sql.
raw token handed to the client: 43 characters (32 random bytes, base64url)
column token_hash holds:        64 hex characters (SHA-256)
rows matching the raw token:    0
rows matching its SHA-256:      1
The block above is output/02-storage.txt. Looking the token up by its own text finds nothing, looking it up by its SHA-256 finds the row. If someone dumps your database, they get hashes they cannot turn back into tokens.
Why plain SHA-256 and not bcrypt? Password hashes are slow on purpose, because a password has maybe 30 bits of guessable structure and an attacker needs the slowness to make guessing expensive. This token has 256 bits of randomness and no structure to guess, so there is nothing for slowness to protect, and a slow hash would only add latency to every refresh. Hash with SHA-256 and move on.

Going deeper

  • The whole service, including expiry and revocation: RefreshTokenService.java.
  • Persisting the family id alongside each token is what makes the next section possible; it is the family_id column in the schema file above.

Rotation: using a refresh token spends it and gives you the next one

Rotation is a single rule: a refresh token can be exchanged once. The exchange marks it USED and issues a child token in the same family, which is the shared id of everything that descends from one login. The access token the client receives carries that family id in a sid claim, so the two token kinds can be tied back together later.
One login, one family, one live tokenone family: its id is the sid claim in every access token of this loginToken Astatus USEDToken Bstatus USEDToken Cstatus ACTIVErefreshrefreshDashed boxes are spent. They stay in the table on purpose: a spent token that shows up again is the alarm.
After two refreshes the chain is A, B, C and only C can be exchanged again. The two spent tokens are not deleted, and that is deliberate: a spent token that comes back is exactly the signal we want to detect, and we can only recognise it if we still remember it.
    public Issued rotate(String rawToken) {
        Row row = store.findByHash(hash(rawToken))
                .orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN));
        long now = clock.millis();

        if (RefreshTokenStore.REVOKED.equals(row.status())) {
            throw new InvalidGrantException(Reason.REVOKED);
        }
        if (now >= row.expiresAt() || now >= row.familyStartedAt() + familyMaxAge.toMillis()) {
            throw new InvalidGrantException(Reason.EXPIRED);
        }
        if (RefreshTokenStore.USED.equals(row.status())) {
            throw reuse(row, now);
        }

        // ACTIVE. Spend it and mint the child in ONE transaction, so a crash between the two
        // cannot leave the user holding a spent token with no successor.
        Issued issued = tx.execute(status -> {
            if (!store.markUsed(row.id(), now)) {
                return null;                       // lost a race against another request
            }
            return insertNew(row.username(), row.familyId(), row.familyStartedAt(), now);
        });
        if (issued != null) {
            return issued;
        }
        // Somebody else spent it between our read and our UPDATE. Re-read to see how.
        Row fresh = store.findByHash(hash(rawToken)).orElseThrow(() -> new InvalidGrantException(Reason.UNKNOWN));
        if (RefreshTokenStore.REVOKED.equals(fresh.status())) {
            throw new InvalidGrantException(Reason.REVOKED);
        }
        throw reuse(fresh, now);
    }
The method is in RefreshTokenService.java. The order of the checks matters: unknown, revoked, expired, and only then the split between ACTIVE and USED where reuse detection lives. Here is what a login and two refreshes leave behind.
login as alice
  access token: JWT, sid=22badf84
  access lifetime: 300 seconds
  refresh token: 43 characters, dots in it: 0
after login, refresh(A) -> B, refresh(B) -> C, one family:
  token A   hash 7a05668b  status USED
  token B   hash 7bb7862a  status USED
  token C   hash a661d1ea  status ACTIVE
sid of the access token minted from C: 22badf84
family id in the table:                22badf84
That is output/01-rotation.txt. Note the last two lines: the sid inside the access token is the family id from the table, which is how a resource server can later ask whether a login is still alive. The hash prefixes change on every run because the tokens are random.
Spend and mint in one transaction. Inside rotate the status change and the insert of the child run in one TransactionTemplate. Without it, a crash between the two leaves the user holding a USED token and no successor, and the next request looks exactly like an attack. It is a four-line fix that is easy to leave out because the happy path works without it.
How long should each refresh token live, and does rotation extend a login forever? Each token has its own lifetime (demo.refresh.token-ttl, 7 days in the module), so rotation behaves as an idle timeout: use the app at least once a week and you stay logged in. Left alone, that means a login never ends. The module therefore also stores when the family started and enforces demo.refresh.family-max-age (30 days), covered in the lifetimes section below. RFC 9700 asks that refresh tokens expire after a period of inactivity; the absolute cap is our addition, not something the RFC requires.

Going deeper

Reuse detection: when a spent token comes back, the whole family dies

Rotation alone only makes a stolen token short-lived. Reuse detection is what makes the theft visible. Think about what a replay means: a token was exchanged once, so exactly one party got its child, and now someone presents the original again. There are two holders of the same secret. The server has no way to know which one is the thief, so it assumes the worst and revokes every token of the family.
Who gets locked out when a spent token comes backalice’s appserverattacker1. token A leaks (log file, backup, XSS)2. refresh with AA is now USED, B issued3. replay Afamily revoked: A and B both dead401 invalid_grant4. refresh with B401: log in againThe server cannot tell which caller is the thief, so both lose the session. The user types a password once.
Follow the numbers in the diagram. At step 3 the attacker presents A, the server sees a USED token, and kills A and B. At step 4 the real app arrives with B and is refused. The attacker did not get in, and the real user is signed out and has to log in again. Both outcomes are in the transcript.
alice logs in and gets refresh token A.
An attacker copies A out of a log file.
alice's app refreshes: A -> B (A is now USED).
the attacker replays A: HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
the family after the replay:
  token A   hash 7131712c  status REVOKED
  token B   hash 1c626483  status REVOKED
alice's app now presents B: HTTP 401  (the innocent holder is signed out too)
B's access token on /api/me, no sid check: HTTP 200 (still inside its 5 minutes)
That is output/03-reuse-detection.txt. The last line is the one to read twice: the access token minted from B still works on /api/me. We come back to that gap in the logout section. The behaviour is the one section 4.14 of RFC 9700 describes: with rotation the server cannot tell whether the stale token came from the attacker or the legitimate client, so it revokes the active token, which stops the attack and forces the legitimate client to obtain a new grant. Note the RFC is descriptive here rather than prescribing the revocation step, so what you do on replay is your decision, and this module makes the strict choice. Revoking a family does not touch the user’s other logins:
alice logs in on a phone and on a laptop: two families.
phone family 1c2bb6d5, laptop family 8c9ca716
replay of the phone's spent token: HTTP 401
laptop refresh afterwards:         HTTP 200
From output/04-families-are-independent.txt. Each login is its own family, so a phone being compromised does not sign out the laptop.
What does the server log when this happens? The service logs a warning with the user and the family id the moment it revokes, for example refresh token REUSE detected for user alice: family … revoked (2 tokens) (the exact format is in reuse() in RefreshTokenService.java). That line is your alerting hook: one reuse event is a stolen token or a buggy client, a burst of them for one user is worth a look. The HTTP response says nothing about why it failed; see the section on identical refusals.

Going deeper

Eight browser tabs, one token: the race that looks exactly like theft

Strict reuse detection has a nasty failure mode that never shows up in a manual test. Open your app in several tabs, let the access token expire, and focus the window: every tab notices at the same moment and sends the same refresh token. Only one of those requests can be first. The others present a token that was just spent, and to the server that is indistinguishable from a replay.
The database decides who spent the tokenrequest 1request 2request 3request 4request 5request 6request 7request 8UPDATE refresh_tokenSET status = 'USED'WHERE id = ? ANDstatus = 'ACTIVE'1 row changedmint child, HTTP 2000 rows changedseven of them, HTTP 401The check and the write are one statement, so no interleaving lets two requests both see ACTIVE and both win.
The server’s defence is one SQL statement. The update changes the row only if it is still ACTIVE, and the number of rows it reports changing tells each request whether it won. There is no read-then-write gap for two requests to slip through.
    public boolean markUsed(String id, long nowMillis) {
        return jdbc.sql("update refresh_token set status = 'USED', used_at = ? where id = ? and status = 'ACTIVE'")
                .params(nowMillis, id).update() == 1;
    }

    public int revokeFamily(String familyId) {
        return jdbc.sql("update refresh_token set status = 'REVOKED' where family_id = ? and status <> 'REVOKED'")
                .param(familyId).update();
    }
Both are in RefreshTokenStore.java. To see it, the module fires eight requests with the same token from eight threads, released at the same instant.
8 requests, same refresh token, released together, reuse-grace = 0
  HTTP 200: 1
  HTTP 401: 7
family afterwards:
  hash 4659f797  status REVOKED
  hash 180503f6  status REVOKED
That is output/05-concurrent-refresh-strict.txt: exactly one 200, seven 401, and then the family is revoked, including the winner’s brand new token. The test asserts exactly one winner and it held on every run while writing this. The cost is the point of the section: a perfectly honest client that fired its refresh twice has just logged itself out. The usual softening is a grace window: a token replayed within a few seconds of being spent is refused but does not revoke the family. The same stampede with demo.refresh.reuse-grace=5s:
8 requests, same refresh token, released together, reuse-grace = 5s
  HTTP 200: 1
  HTTP 401: 7
family afterwards:
  hash 07e179bf  status USED
  hash f2fd3302  status ACTIVE
6 seconds later, replaying the spent token again:
  HTTP 401, family now:
  hash 07e179bf  status REVOKED
  hash f2fd3302  status REVOKED
From output/06-concurrent-refresh-grace.txt. Still one winner and seven refusals, but now the winner’s token is ACTIVE and the family lives. And six seconds later, the same replay is treated as theft and kills the family. The code that makes that decision:
    private InvalidGrantException reuse(Row row, long now) {
        if (row.usedAt() != null && reuseGrace.toMillis() > 0 && now - row.usedAt() <= reuseGrace.toMillis()) {
            log.info("refresh token replayed {} ms after use, inside the {} ms grace window, family {} kept",
                    now - row.usedAt(), reuseGrace.toMillis(), row.familyId());
            return new InvalidGrantException(Reason.REUSE_WITHIN_GRACE);
        }
        int revoked = store.revokeFamily(row.familyId());
        log.warn("refresh token REUSE detected for user {}: family {} revoked ({} tokens)",
                row.username(), row.familyId(), revoked);
        return new InvalidGrantException(Reason.REUSE_DETECTED);
    }
This grace window protects the family, not the losing request. The seven refused requests get a 401 and no token, so a client that lost the response to its one successful refresh (a flaky mobile connection) is still stranded and has to log in. Auth0’s documented overlap period behaves differently: inside the window it issues a new token instead of refusing. That requires remembering what you handed out, which this module does not do. Pick the grace behaviour on purpose rather than by default.
The better fix is on the client: one refresh at a time The cleanest way to avoid the race is to never send two refreshes for the same token. A client keeps a single in-flight refresh promise; every caller that needs a new access token while one is pending awaits the same promise instead of sending its own. Across browser tabs this takes a cross-tab lock. This is advice from experience of the failure mode rather than something the companion repository demonstrates, so treat it as a design suggestion and test it in your client.

Going deeper

Logout works on the refresh token and not on the access token

Because every login is a family and every family is a set of rows, logging out is a revoke. One device revokes the family of the token it presents. Logging out everywhere revokes every row for the user, and that endpoint takes an access token, since “all my sessions” needs an identity.
    public Map<String, Object> logoutAll(@AuthenticationPrincipal Jwt jwt) {
        return ordered("revoked_tokens", refreshTokens.revokeAll(jwt.getSubject()));
    }
That is from AuthController.java. The single-device endpoint always answers 204, even for a token that never existed, so it cannot be used to test whether a token is real. Here is a whole session with three devices for alice and one for bob.
alice is logged in on phone, laptop and tablet (3 families); bob on one device.
POST /api/auth/logout with the phone's refresh token: HTTP 204
  phone refresh:  HTTP 401
  laptop refresh: HTTP 200
POST /api/auth/logout with a made-up token:           HTTP 204 (same answer, no oracle)
POST /api/auth/logout-all with the laptop's access token: HTTP 200 {"revoked_tokens":3}
  laptop refresh: HTTP 401
  tablet refresh: HTTP 401
  bob refresh:    HTTP 200 (untouched)
alice's laptop ACCESS token on /api/me after logout-all: HTTP 200
  (it is a signed JWT with 5 minutes left; nothing in the request path asked the store)
The block is output/07-logout.txt. Refreshing after logout-all fails for every alice device and bob is untouched. The last two lines are the honest part. Logging out everywhere did not stop alice’s access token, because it is a signed JWT with minutes to run and nothing on the request path asks the database.
What logout-all actually ends, and when02 min5 minRefresh tokenaliverevoked: refresh answers 401Access tokenvalidstill accepted: signature and exp are fineThe 5 minutes after logout are the price of an access token nobody has to ask the database about.
The red part of the top lane is what logout controls. The amber part of the bottom lane is what it does not. That window is at most the access-token lifetime, five minutes here, and it is the reason to keep that lifetime short. If five minutes is too long for your risk, the module has an option that closes it. With demo.check-sid=true a validator rejects any access token whose sid family has no live tokens left:
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        String sid = jwt.getClaimAsString("sid");
        if (sid != null && store.familyAlive(sid)) {
            return OAuth2TokenValidatorResult.success();
        }
        return OAuth2TokenValidatorResult.failure(
                new OAuth2Error("invalid_token", "The session behind this token has ended", null));
    }
Same flow, with that switched on:
alice logs in. /api/me with the access token: HTTP 200
after logout-all, same access token, demo.check-sid=true: HTTP 401
That is the first two lines of output/08-check-sid.txt (the third, the full WWW-Authenticate header, is cut for width). The token dies with its family.
The sid check turns your stateless API into a stateful one. The validator runs a database query on every authenticated request, which is exactly the lookup the access JWT existed to avoid. Turn it on when immediate revocation matters more than the extra read (an admin console, say), and leave it off for the high-volume endpoints. Caching the result for a few seconds is a middle path; this module does not implement it.

Going deeper

Two lifetimes, one hard cap, and a minute nobody asked for

Three clocks are running. The access token has five minutes. Each refresh token has seven days, which acts as an idle timeout because every rotation starts a fresh seven days. And the family has an absolute age cap of 30 days, so rotation cannot stretch one login forever. All three are properties:
demo:
  jwt:
    issuer: https://refresh-demo.ankurm.com
    access-ttl: 5m
    # 32+ bytes. Demo value only - a real deployment reads this from a secret manager.
    hmac-secret: ankurm-refresh-demo-hmac-secret-32-bytes-min
  refresh:
    # Lifetime of ONE refresh token. Each rotation hands out a fresh one, so this is an idle timeout.
    token-ttl: 7d
    # Absolute cap on a whole token family. Rotation cannot extend a login past this.
    family-max-age: 30d
    # 0 = strict: any replay of a spent token revokes the family. >0 = tolerate a replay this soon after use.
    reuse-grace: 0s
  # true adds a validator that rejects an access token whose family (sid claim) was revoked.
  check-sid: false
From application.yaml. To test time without waiting, the tests move an injected Clock. Here is the idle timeout and then the cap, which is the surprising one: a token six days old is refused because its family is thirty days old.
token-ttl = 7d, family-max-age = 30d, access-ttl = 5m
5m01s after login, access token on /api/me: HTTP 401
8 days idle, refresh: HTTP 401 (token-ttl passed)
day  6: refresh OK (every token is only 6 days old)
day 12: refresh OK (every token is only 6 days old)
day 18: refresh OK (every token is only 6 days old)
day 24: refresh OK (every token is only 6 days old)
day 30: refresh HTTP 401, the token is 6 days old but the family is 30 days old
family rows: 5, all in the same family 721853e3
That is output/09-expiry.txt. Five tokens in one family, every one of them young, and the next refresh is still refused. The “minute nobody asked for” is in the access token side. Spring Security’s default timestamp validator allows 60 seconds of clock skew, so a five-minute token is accepted for six. The module’s decoder sets the skew to zero:
    public JwtDecoder jwtDecoder(Clock clock, ObjectProvider<SidValidator> sidValidator) {
        NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key).macAlgorithm(MacAlgorithm.HS256).build();
        JwtTimestampValidator timestamps = new JwtTimestampValidator(Duration.ZERO);
        timestamps.setClock(clock);
        List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
        validators.add(timestamps);
        validators.add(new JwtIssuerValidator(issuer));
        sidValidator.ifAvailable(validators::add);
        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
        return decoder;
    }
And the comparison, with a token that expired 30 seconds ago:
an access token that expired 30 seconds ago:
  decoder with Spring Security's default validators accepts it: true
  this module's decoder (clock skew set to zero) accepts it:    false
From output/10-clock-skew.txt. The 60 seconds is not folklore: javap on spring-security-oauth2-jose 7.1.2 shows DEFAULT_MAX_CLOCK_SKEW initialised to 60 seconds in its static initialiser. Zero skew is a choice, safe only while the issuer and the verifier share a reliable clock, so if your resource servers are separate machines, keep a few seconds.

Going deeper

Refuse every bad refresh the same way, and watch it over real HTTP

There are four ways a refresh can fail: the token never existed, it was already used, it was revoked, or it expired. If those produce four different responses, an attacker holding a guessed or half-stolen token learns which kind it was. The controller maps them all to one status and one body, the invalid_grant error that RFC 6749 section 5.2, the invalid_grant error defines for a bad grant.
    /** Map.of() iterates in a different order on every JVM start; a stable body keeps reruns diffable. */
    private static Map<String, String> error(String description) {
        Map<String, String> body = new LinkedHashMap<>();
        body.put("error", "invalid_grant");
        body.put("error_description", description);
        return body;
    }
The reason for each failure goes to the log and to the tests, never to the caller.
never issued           HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
already used (reuse)   HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
revoked by logout      HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
expired                HTTP 401 {"error":"invalid_grant","error_description":"The refresh token is not valid"}
From output/11-identical-refusals.txt: four reasons, four identical lines. Last, the same story over a real HTTP server rather than MockMvc, with curl against the packaged jar.
$ curl -X POST /api/auth/login  (alice)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"FeHO52BY0danWV...","token_type":"Bearer","expires_in":300}

$ curl /api/me  (Bearer access token)
{"sub":"alice","sid":"c9ddb06a..."}
HTTP 200

$ curl -X POST /api/auth/refresh  (token A)
{"access_token":"eyJraWQiOiJMeX...","refresh_token":"zgB_rx1Kaqu_AZ...","token_type":"Bearer","expires_in":300}

$ curl -X POST /api/auth/refresh  (token A again: the replay)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401

$ curl -X POST /api/auth/refresh  (token B, the innocent holder)
{"error":"invalid_grant","error_description":"The refresh token is not valid"}
HTTP 401
That is output/12-http-walkthrough.txt, produced by http-walkthrough.sh, with long tokens shortened for the page only. Login, an authenticated call, a rotation, then the replay and the innocent holder both refused.
Three small things that cost a build or a diff while writing this
  • On Boot 4, @Valid and @NotBlank need spring-boot-starter-validation as an explicit dependency. The first compile failed with cannot find symbol: class Valid.
  • The first version of the error body used Map.of(...), whose iteration order changes from one JVM start to the next. The committed transcripts reordered the JSON keys on every rerun, so the controller now builds a LinkedHashMap.
  • The tests parse responses with Jackson 3 (tools.jackson), the version Boot 4.1 manages. The annotation @JsonProperty is still imported from com.fasterxml.jackson.annotation, and the module compiled with that import.

Going deeper

  • Everything above runs with ./scripts/run-all.sh in the module; the tests are in src/test/java/com/ankurm/refresh/.

Should you build this yourself at all?

Probably not, if you already run an authorization server. Spring Authorization Server, Keycloak and the commercial providers issue refresh tokens for you and, depending on the product and its settings, rotate them; they are maintained by people whose full-time job is this. In this repository’s own captured output (as-settings-defaults.txt) TokenSettings.isReuseRefreshTokens() is true by default in both Spring Authorization Server 1.5.8 and 7.1.1, which means refresh tokens are not rotated unless you switch it off. I did not test whether Spring Authorization Server revokes a whole family on replay, so I am not claiming it does. Read your provider’s behaviour for replayed tokens and for concurrent refreshes before you rely on it. Build your own when you issue tokens from the same app that serves the API and an OAuth server would be more moving parts than the problem deserves.

Two limits of this module. The data lives in H2 in memory, so everything is lost on restart; a real deployment uses your production database and cleans up old rows. And a browser app still has to store the refresh token somewhere. An HTTP-only cookie limited to the refresh path keeps it away from page scripts, and that matters more than any rotation scheme, but it is a deployment decision this repository does not exercise.
The short version of the design is five rules. The refresh token is random and stored as a hash. It is spent on use. Tokens of one login share a family. A spent token coming back revokes the family. And you decide, deliberately, what happens when two honest requests collide.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.