effect-auth

Add Refresh Tokens

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; refresh depends on its configured JwtIssuer.

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

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. Its rotate operation must atomically accept only an unrotated, unrevoked row and insert the replacement. RefreshTokenStoreMemoryLive is for tests, not production.

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

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.

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. The standard route accepts the token in JSON and does not add origin/CSRF checks or cookie handling.

Client

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.

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.

On this page