ASVS 5.0 V10.4.5 (Level 1) requires the authorization server to defend public clients against refresh token replay. The preferred answer is sender-constrained refresh tokens using DPoP or mutual TLS. For L1 and L2 applications, refresh token rotation is acceptable, as long as a used token is invalidated and a reuse of it revokes the whole token family.
A public client is one that cannot hold a secret: a browser single page app, a mobile app, a desktop app. Anything stored on that device can be copied.
Why it matters
Refresh tokens are long lived. An access token might last five minutes, but the refresh token behind it can last weeks. If someone copies it out of local storage, a backup or a log, they can mint access tokens until it expires or is revoked.
With a plain bearer refresh token, the server cannot tell the thief from the real app. Rotation changes that: every refresh issues a new token and kills the old one. Now there are two parties holding what used to be one token, and the first one to use the old value reveals the theft.
How to implement it
You have two options.
Option one, sender constraint. With DPoP (RFC 9449) the client proves possession of a private key on every token request, and the refresh token is bound to the public key. A copied token is useless without the key. This is the stronger choice when your clients can generate and hold a key pair, for example with WebCrypto non-extractable keys in a browser.
Option two, rotation with reuse detection. Keep a token family identifier so you can revoke everything descended from one login.
from datetime import datetime, timezone
def refresh(conn, presented_hash):
with conn.transaction():
row = conn.execute(
"SELECT id, family_id, used_at, expires_at "
"FROM refresh_tokens WHERE token_hash = %s FOR UPDATE",
(presented_hash,),
).fetchone()
if row is None:
raise InvalidGrant()
tid, family, used_at, expires_at = row
if used_at is not None:
# Replay of an already rotated token: kill the whole family.
conn.execute(
"UPDATE refresh_tokens SET revoked_at = now() "
"WHERE family_id = %s AND revoked_at IS NULL", (family,))
raise InvalidGrant()
if expires_at <= datetime.now(timezone.utc):
raise InvalidGrant()
conn.execute("UPDATE refresh_tokens SET used_at = now() WHERE id = %s", (tid,))
return issue_new_refresh_token(conn, family)
The FOR UPDATE lock serializes two simultaneous refreshes with the same token, so only one counts as the first use.
One practical wrinkle: a mobile app on a flaky network may send the refresh request, lose the response, and retry with the old token. Some servers allow a short grace window of a few seconds for the immediate predecessor. If you add one, keep it tiny and log it.
Choosing between the two options
Pick sender constraint when you control the client and it can hold a key. Native apps can use the platform keystore, and browsers can use non-extractable WebCrypto keys. Pick rotation when you have many third party public clients that cannot all support DPoP yet. You can offer both, but plan to move toward constraint over time.
Details that trip people up
Keep the rotation family tied to the original login, not to the user. If one device is compromised, you want to revoke that device's family and leave the user's other devices alone.
Apply the absolute expiry from V10.4.8 across the whole family, so rotation does not stretch a login forever. And make sure the revocation of a family also invalidates any reference access tokens that came from it, so the thief loses access now rather than at the next expiry.
Finally, return the same error for unknown, expired, and reused tokens. Different messages tell an attacker which guesses were close.
How to test it
- Log in and capture the refresh token, call it R1.
- Exchange R1 for R2. Confirm you receive a new refresh token.
- Exchange R1 again. Expect
invalid_grant. - Now try R2. If you implemented reuse detection, it should also fail, because the family was revoked.
- For DPoP, replay the token request without a valid DPoP proof header, and with a proof signed by a different key. Both must fail.
Automating the check
A pytest sketch of the reuse test:
def test_refresh_reuse_revokes_family(api):
r1 = api.login_and_get_refresh_token()
r2 = api.refresh(r1) # rotation succeeds
assert api.refresh(r1).status_code == 400 # old token fails
assert api.refresh(r2).status_code == 400 # family revoked
Run a parallel version too: two threads refreshing with the same token. At most one may succeed.
Evidence for an assessor
If someone has to verify this requirement, give them four things: the configuration showing rotation or DPoP enabled for public clients, the family revocation code or setting, the automated reuse test, and a log line from a staging run where a reused token triggered a family revoke. Screenshots of settings go stale, so prefer the test and the log. Note the absolute lifetime too, since reviewers usually ask how long a family can live.
A last practical point: public clients in browsers are often better served by a backend for frontend pattern, where the refresh token never reaches the browser at all. If that fits your architecture, it removes most of the exposure this requirement is about.
Common mistakes
- Rotating tokens but never revoking the family on reuse, which gives rotation without detection.
- Storing refresh tokens in browser local storage with no sender constraint and no rotation.
- Treating confidential clients and public clients the same. Confidential clients authenticate, which is a different control.
- Missing the race, where two refreshes in parallel both succeed.
Quick check
What should happen when an already used refresh token is presented?
The request fails and every refresh token issued for that authorization is revoked.
Why is sender constraint preferred over rotation?
A stolen token is useless without the private key, so replay is prevented outright instead of detected after the fact.