The Cognito access token expires after one hour. Refresh proactively when less than 5 minutes remain, not on the failed 401 — by the time you hit a 401, the user’s request has already failed.

The endpoint

POST /auth/refresh_token
{
  "token":            "<refresh_token>",
  "device_key":       null,
  "device_group_key": null
}
Response:
{ "AccessToken": "...", "IdToken": "...", "TokenType": "Bearer" }
The refresh token does not rotate. Keep using the original until logout or its natural expiry (~30 days).

fetch interceptor

type Session = {
  accessToken:  string;
  refreshToken: string;
  expiresAt:    number;   // ms epoch
  partnerId:    string;
};

const REFRESH_THRESHOLD_MS = 5 * 60 * 1000;
let session: Session | null = loadSession();
let refreshPromise: Promise<Session> | null = null;

async function ensureFresh(): Promise<Session> {
  if (!session) throw new Error("Not signed in");

  if (session.expiresAt - Date.now() > REFRESH_THRESHOLD_MS) {
    return session;
  }

  if (!refreshPromise) {
    refreshPromise = doRefresh(session.refreshToken)
      .then(next => { session = next; saveSession(next); return next; })
      .finally(() => { refreshPromise = null; });
  }
  return refreshPromise;
}

async function doRefresh(refreshToken: string): Promise<Session> {
  const resp = await fetch(`${BASE}/auth/refresh_token`, {
    method:  "POST",
    headers: { "Content-Type": "application/json" },
    body:    JSON.stringify({ token: refreshToken }),
  });
  if (!resp.ok) throw new Error("refresh failed");
  const data = await resp.json();
  return {
    accessToken: data.AccessToken,
    refreshToken,                                    // unchanged
    expiresAt:   Date.now() + (data.ExpiresIn ?? 3600) * 1000,
    partnerId:   session!.partnerId,
  };
}

export async function fd<T>(
  path: string,
  init: RequestInit = {},
): Promise<T> {
  const { accessToken, partnerId } = await ensureFresh();
  const resp = await fetch(`${BASE}${path}`, {
    ...init,
    headers: {
      ...init.headers,
      Authorization:           `Bearer ${accessToken}`,
      "X-Preferred-Partner-Id": partnerId,
    },
  });
  if (resp.status === 401) {
    // Anything else got us here — bail to login
    clearSession();
    throw new Error("session_expired");
  }
  if (!resp.ok) throw new Error(`${resp.status}`);
  return resp.json();
}
Key properties:
  • Single-flight refresh. If 20 in-flight requests all trigger ensureFresh() simultaneously, only one POST to /auth/refresh_token runs; the others await the same promise.
  • No 401 retry. A 401 after a fresh token means the session is dead — clear it and force re-auth. Retrying would mask real auth problems.
  • Proactive threshold. 5 minutes is the reference webapp’s threshold. Shorter (1 min) makes more 401s likely on slow networks; longer (>10 min) wastes refresh calls.

axios interceptor

import axios from "axios";

export const fd = axios.create({
  baseURL: BASE,
  headers: { "Content-Type": "application/json" },
});

fd.interceptors.request.use(async (config) => {
  const { accessToken, partnerId } = await ensureFresh();
  config.headers.set("Authorization", `Bearer ${accessToken}`);
  config.headers.set("X-Preferred-Partner-Id", partnerId);
  return config;
});

fd.interceptors.response.use(undefined, (error) => {
  if (error?.response?.status === 401) {
    clearSession();
    window.location.href = "/login";
  }
  return Promise.reject(error);
});

Server-side variant

Server-to-server integrations should:
  1. Sign in once at boot (or on first call) and cache the session in memory.
  2. Run a background refresh timer (e.g. setInterval(refresh, 55 * 60 * 1000)).
  3. Persist the refresh token in a secret store, not in the access-token cache.
let session: Session | null = null;

export async function startup() {
  session = await signIn(USERNAME, PASSWORD);
  setInterval(refreshIfNeeded, 60 * 1000);          // tick every minute
}

async function refreshIfNeeded() {
  if (!session) return;
  if (session.expiresAt - Date.now() < 10 * 60 * 1000) {
    session = await doRefresh(session.refreshToken);
  }
}
This avoids per-request refresh logic in your hot path.

MFA-enabled accounts

If your service account has MFA enabled, the /auth response will come back with Challenge instead of AccessToken. For service accounts, turn MFA off at provisioning time and rely on a strong password + network controls. For interactive (user-driven) flows, complete the MFA challenge at PUT /auth/challenge/mfa-token — see Authentication.

Common mistakes

The user’s request has already failed by then. Their UI shows the error. Refresh ahead of time.
Without a shared refreshPromise, multiple in-flight requests will each trigger their own refresh. Best case: wasted calls. Worst case: you race on saving the new token and lose one.
Easy to do in the refresh interceptor — the refresh call itself doesn’t need it, but every subsequent request does. Make sure ensureFresh returns both pieces, and you set them together.
The refresh token is long-lived. Put it behind a proxy on your backend, or use an HttpOnly cookie. The access token in localStorage is survivable; the refresh token isn’t.