---
title: "Add Refresh Tokens"
url: "https://effect-auth.itsbroly.com/recipes/add-refresh-tokens/"
description: "Add rotating opaque refresh tokens to a JWT access-token flow."
---



Use a short-lived JWT for API authorization and a long-lived opaque refresh token only to obtain the next pair. Start with [JWT access tokens](/recipes/issue-jwt-access-tokens/); refresh depends on its configured `JwtIssuer`.

```text
login ──> access JWT + refresh A
                       │ present A
                       v
              access JWT + refresh B
                       │ replay A
                       v
               revoke family A, B
```

The plaintext refresh secret is returned once. `RefreshTokenManagement` hashes it as `sha256:...`; durable storage receives `secretHash`, identifiers, timestamps, and metadata, never the plaintext. Every replacement keeps the same `familyId`. Successful rotation marks the previous row with `rotatedAt` and `replacedById`. Presenting that rotated token again records `reuseDetectedAt` and revokes every row in the family. Missing, expired, and already-revoked tokens are merely `Invalid`; core does not revoke sessions, notify users, or revoke already-issued JWTs.

## Store and layers [#store-and-layers]

Apply all `authStorageMigrations` before serving traffic. Migration `0009_auth_refresh_token` creates the table and indexes. The SQLite adapters provide `RefreshTokenStore`; for another database implement the contract described in [Storage](/reference/storage/). Its `rotate` operation must atomically accept only an unrotated, unrevoked row and insert the replacement. `RefreshTokenStoreMemoryLive` is for tests, not production.

```ts
import { Layer } from "effect";
import { WebCryptoLive } from "@effect-auth/core/Crypto";
import {
  RefreshTokenManagementLive,
  RefreshTokensLive as RefreshTokenPrimitivesLive,
} from "@effect-auth/core/RefreshToken";
import { DrizzleNodeSqliteAuthStorageLive } from "@effect-auth/core/DrizzleNodeSqliteStorage";

const StorageLive = DrizzleNodeSqliteAuthStorageLive({
  filename: "./data/auth.sqlite",
});

export const RefreshTokensLive = RefreshTokenManagementLive.pipe(
  Layer.provideMerge(RefreshTokenPrimitivesLive),
  Layer.provideMerge(StorageLive),
  Layer.provideMerge(WebCryptoLive())
);
```

Issue the initial refresh token after primary authentication with `RefreshTokenManagement.issueForUser({ userId, expiresAt })`, alongside the first JWT. Your application chooses both expiries; core defines no refresh TTL, idle timeout, or maximum family lifetime.

## Add the refresh route [#add-the-refresh-route]

The focused preset exposes `POST /auth/token/refresh`. `RefreshTokenHttpConfigLive` supplies expiry and JWT claims; `RefreshTokenHttpApiLive` requires `RefreshTokenManagement` and `JwtIssuer`. Here `JwtHs256Live` means the complete issuer layer built in the JWT recipe.

```ts
import { Layer } from "effect";
import { UnixMillis } from "@effect-auth/core/Identifiers";
import {
  RefreshTokenHttpApiLive,
  RefreshTokenHttpConfigLive,
} from "@effect-auth/core/HttpApi";
import { JwtHs256Live } from "./jwt-live";

const RefreshHttpConfigLive = RefreshTokenHttpConfigLive({
  refreshTokenExpiresAt: ({ now }) =>
    UnixMillis(Number(now) + 30 * 24 * 60 * 60 * 1000),
  accessToken: ({ now, refreshToken }) => ({
    issuer: "https://auth.example",
    subject: refreshToken.userId,
    audience: "api",
    expiresAt: UnixMillis(Number(now) + 15 * 60 * 1000),
    claims: { rt_family: refreshToken.familyId },
  }),
});

export const RefreshHttpLive = RefreshTokenHttpApiLive.pipe(
  Layer.provide(RefreshHttpConfigLive),
  Layer.provide(RefreshTokensLive),
  Layer.provide(JwtHs256Live)
);
```

For a custom API, bind `RefreshTokenHttpOperations.refresh`; see [HTTP Operations](/reference/http-operations/). The standard route accepts the token in JSON and does not add origin/CSRF checks or cookie handling.

## Client [#client]

```ts
import { createRefreshTokenClient } from "@effect-auth/core/Client";

const auth = createRefreshTokenClient();
const next = await auth.token.refresh({ refreshToken: current });

accessToken = next.accessToken;
current = next.refreshToken; // Replace atomically; never keep the old token.
```

Body storage is exposed to XSS. Prefer memory where possible. If using an `HttpOnly`, `Secure` cookie, adapt cookie extraction and replacement at your HTTP boundary and enforce origin/CSRF policy. Avoid parallel refreshes: a second request with the old token is treated as replay and revokes the family. This flow is separate from cookie-backed [Sessions](/concepts/sessions/).

## Verify and ship [#verify-and-ship]

1. Test issue, hash-only persistence, successful rotation, expired/revoked rejection, old-token replay, and whole-family revocation; run the store contract against the production adapter.
2. Run migrations before rollout and test concurrent rotation against the real database.
3. Use short access TTLs, rate-limit refresh, never log tokens or hashes, and protect backups and signing keys.
4. On refresh failure clear client state and require login; on suspected reuse add app-owned audit, alerting, session/JWT revocation, and recovery policy.

