---
title: Refresh tokens & rotation
---

# Refresh tokens & rotation

Refresh tokens let your backend mint new `access_token`s without redirecting the user. Little X Little issues refresh tokens only when you request `offline_access` in the scope list.

## Requesting one

```php
$url = $client->createAuthUrl([
    'scope' => 'openid profile email offline_access',
]);
```

The token response will then include:

```json
{
  "access_token":  "...",
  "refresh_token": "rt_2x9...",
  "id_token":      "eyJ...",
  "expires_in":    3600
}
```

## Refreshing

```bash
curl -X POST https://id.littlexlittle.org/oidc/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=rt_2x9..." \
  -d "client_id=YOUR_CLIENT_ID"
```

The response contains a **new** refresh token. Store it. Discard the old one.

## Rotation & replay detection

Every successful refresh:

1. Marks the used refresh token as `rotated` in the database.
2. Issues a new refresh token in the same chain (linked via `parent_id`).
3. Returns the new tokens.

If you ever submit a refresh token that has already been rotated:

1. We return `400 invalid_grant`.
2. We **revoke the entire chain** (current + all descendants).
3. The user must sign in again.

This is the OAuth 2.0 Security Best Current Practice [§4.13.2](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics) defense against stolen refresh tokens.

## Lifetimes

| Token | TTL | Behavior |
|---|---|---|
| Access token | 1 hour | Hard expiry. |
| Refresh token | 30 days sliding | Reset to 30d on each successful use. |
| Refresh chain | 90 days absolute | Total chain age cap, even with constant use. |

After 90 days the user must re-authenticate via `/oidc/authorize`.

## Storage recommendations

- **Server-side only.** Never expose the refresh token to JavaScript.
- **HttpOnly cookie scoped to `/oauth/refresh`** path of *your* server, with `Secure`, `SameSite=Strict`, and a separate cookie from the access token cookie.
- **Encrypt at rest** if you persist them in a database.

## Detecting compromise

If you receive a `400 invalid_grant` with `error_description=Refresh token was already used`, you have evidence of a compromised refresh token. Force-re-auth all sessions for that user and emit a security alert.
