API & Automation·8 min read·

Implementing Refresh Tokens Safely in Production

Field-proven guidance on issuing, rotating, storing, and revoking refresh tokens without introducing session-hijack security holes for your API.

NB

Netbay Cloud Team

Netbay Engineering

On this page

Short-lived access tokens are great for lowering the blast radius of a leak, but they break long-lived client sessions unless you add another layer: the refresh token. Done carelessly, refresh flows create replay and rotation hazards that are worse than the problem they solve. This post covers the safe shape of a refresh token implementation.

Why refresh instead of long tokens

An access token that lives for hours is a pass that works until it expires, even after you detect a leak. Refresh tokens let you keep access short (minutes) while allowing a client to silently get fresh tokens without re-authenticating. The safety knob is that refresh tokens are higher value — they must be stored secret and bound to a single client.

The whole design hinges on three rules: rotate the refresh token on every use, store a fingerprint of the client, and make revocation immediate.

Client holds access + refresh Auth server store hash, rotation refresh + client id new access + rotated refresh replay detect on reused token => revoke family

Core safety rules

  • **Rotate on use.** Each refresh issuance produces the new refresh token and invalidates the old one. This turns a stolen token into a single-use object.
  • **Store only a hash.** Persist a random hash of the refresh token, never the raw value, so a database leak does not leak live sessions.
  • **Bind to the client.** Record a stable device or client identifier against the token and reject mismatches.
  • **Support immediate revocation.** Keep an allow-list, or short-lived family IDs with a denylist for active breaches.

Two more structural choices are worth making explicit. First, group refresh tokens into a *family*: a single login issues a family id, and every rotation stays inside that family. That is what lets you revoke the whole session at once when one token is reused by an attacker — you do not chase individual records, you kill the lineage. Second, decide where the server keeps these tokens. A durable database is right, but an encrypted cache can be faster if you accept that a cache eviction silently logs the user out; understand that trade-off rather than inheriting it by default.

Rotation shutdowns replay attacks

The classic replay scenario: an attacker steals a refresh token, then both the attacker and the legitimate client try to use it. Without rotation, both succeed — an indefinite hijack. With rotation and reuse detection, the first to use it wins, and the server revokes the whole refresh token family on the second use. This is the single most important hardening step.

The trade-off is that offline, long-lived mobile clients must be able to persist the refreshed token safely (keychain or encrypted storage), and you accept that a genuine client retry could look like a replay if a response is lost. Handle idempotency and client retries explicitly.

A rotation check in Python

python
import hashlib, secrets

token_hash = lambda t: hashlib.sha256(t.encode()).hexdigest()

def rotate_refresh(old_refresh, client_id, store):
    h = token_hash(old_refresh)
    record = store.get(h)
    if record is None:
        raise PermissionError("unknown refresh token")
    if record["client_id"] != client_id:
        raise PermissionError("client mismatch")
    new_refresh = secrets.token_urlsafe(48)
    store.delete(h)
    store.set(
        token_hash(new_refresh),
        {"client_id": client_id, "family": record["family"]},
    )
    return new_refresh, mint_access(record["user"], client_id)

Revoking a session on logout

javascript
async function revokeRefresh(db, rawToken) {
  const hash = crypto.createHash("sha256")
    .update(rawToken).digest("hex");
  const r = await db.get("refresh", hash);
  if (!r) return { ok: false };
  // optionally revoke the whole family
  await db.put("revoked_family", r.family, { value: Date.now() });
  await db.del("refresh", hash);
  return { ok: true };
}

Handling client retries and loss

Refresh responses that fail mid-flight can desync client and server. Have the client treat a 401 on the refresh call as cause to hard-delete the session, and give the server an idempotent hook (a request id) so a replayed refresh due to a lost response is not mistaken for theft. Document the exact behavior in your client SDK.

Takeaway

Refresh tokens buy you short access lifetimes, but only if they are rotated, stored as hashes, bound to a client, and revocable. Ship those four rules together or not at all. You can build and stress-test this flow against an API-backed VPS on Netbay, in production-ready SSD storage from DC01 in Lucknow — netbayhosts.in.

Keep reading

Follow along on a real VPS

Deploy Linux in under 60 seconds

These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.

Deploy an instance