Skip to content

Suggested implementation

Where to store refresh tokens, where to use ID tokens, and how to refresh safely — recommended patterns for web and mobile clients.

Updated View as Markdown

Tokens & refresh describes the mechanics: what the tokens are and which endpoints issue them. This page is a recommendation for how to wire them into a real client — specifically where each token should live.

This is guidance, not enforcement. Truememory Auth accepts a valid refresh token from anywhere; nothing here is checked server-side. The recommendations exist because the two tokens have very different blast radii.

The two tokens are not equivalent

Token Lifetime What it can do Where it should live
id_token 1 hour Authenticate API calls as the user In memory on the client. Never persisted.
refresh_token Until revoked — effectively indefinite Mint unlimited fresh id_tokens Server-side (web) or platform keystore (mobile). Never in localStorage if you have any alternative.

An ID token that leaks is bad for at most an hour. A refresh token that leaks is a persistent account takeover — the attacker can keep minting ID tokens indefinitely, and Truememory Auth currently exposes no way for your app to revoke it (see Sign-out and revocation).

That asymmetry drives every recommendation below: treat the ID token as a disposable credential and the refresh token as a long-lived secret.

For any web app that has a server — Next.js, Nuxt, Remix, or an SPA with its own API — proxy the auth calls through your backend and never let the refresh token reach the browser.

flowchart LR
    Browser["Browser<br/><b>id_token in memory</b>"]
    Backend["Your backend<br/>(BFF)"]
    Auth["Truememory Auth"]
    Gateway["Truememory<br/>AI Gateway"]

    Browser -->|"POST /api/auth/login<br/>credentials"| Backend
    Backend -->|"POST /auth/login"| Auth
    Auth -->|"id_token + refresh_token"| Backend
    Backend -->|"id_token in body<br/>refresh_token in httpOnly cookie"| Browser
    Browser -->|"Authorization: Bearer id_token"| Gateway

    style Browser fill:#f0f9ff,stroke:#3b82f6,color:#181818
    style Backend fill:#fff7ed,stroke:#F68220,color:#181818
    style Auth fill:#ffffff,stroke:#d1d5db,color:#181818
    style Gateway fill:#ffffff,stroke:#d1d5db,color:#181818

The browser holds a short-lived ID token in memory and an opaque httpOnly cookie it cannot read. XSS can still steal the in-memory ID token — but it expires in an hour and cannot be used to mint more.

Sign-in route

Keep the auth base URL in a server-only variable. A NEXT_PUBLIC_-prefixed value is inlined into the client bundle, which defeats the purpose of proxying.

// app/api/auth/login/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

// Server-only — no NEXT_PUBLIC_ prefix.
const AUTH_BASE_URL = process.env.TRUEMEMORY_AUTH_BASE_URL!;

const REFRESH_COOKIE = "tm_refresh";

const cookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: "lax",
  // Scope the cookie to the auth routes so it isn't attached to every request.
  path: "/api/auth",
  maxAge: 60 * 60 * 24 * 30,
} as const;

export async function POST(request: Request) {
  const { email, password } = await request.json();

  const res = await fetch(`${AUTH_BASE_URL}/auth/login`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) {
    // Don't forward Truememory's error body verbatim — it can distinguish
    // "no such account" from "wrong password", which enables user enumeration.
    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
  }

  const { id_token, refresh_token, expires_in, user } = await res.json();

  const jar = await cookies();
  jar.set(REFRESH_COOKIE, refresh_token, cookieOptions);

  // The ID token goes back in the body. The browser keeps it in memory only.
  return NextResponse.json({ id_token, expires_in, user });
}

Refresh route

The browser calls this with no body — the cookie is the credential.

// app/api/auth/refresh/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

const AUTH_BASE_URL = process.env.TRUEMEMORY_AUTH_BASE_URL!;

export async function POST() {
  const jar = await cookies();
  const refreshToken = jar.get("tm_refresh")?.value;

  if (!refreshToken) {
    return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
  }

  const res = await fetch(`${AUTH_BASE_URL}/auth/refresh`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refresh_token: refreshToken }),
  });

  if (!res.ok) {
    jar.delete({ name: "tm_refresh", path: "/api/auth" });
    return NextResponse.json({ error: "Session expired" }, { status: 401 });
  }

  const { id_token, refresh_token, expires_in } = await res.json();

  // Truememory returns the same refresh token value today rather than rotating
  // it. Re-setting the cookie is still correct: it extends the cookie's own
  // expiry and keeps working unchanged if rotation is introduced later.
  jar.set("tm_refresh", refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/api/auth",
    maxAge: 60 * 60 * 24 * 30,
  });

  return NextResponse.json({ id_token, expires_in });
}

OAuth route

OAuth sign-in follows the same shape — the browser gets a token from Google or Apple, your route exchanges it. This is the endpoint the examples in Sign in with Google & Apple post to.

// app/api/auth/oauth/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

const AUTH_BASE_URL = process.env.TRUEMEMORY_AUTH_BASE_URL!;

export async function POST(request: Request) {
  const { provider, id_token: providerToken } = await request.json();

  if (provider !== "google" && provider !== "apple") {
    return NextResponse.json({ error: "Unsupported provider" }, { status: 400 });
  }

  const res = await fetch(`${AUTH_BASE_URL}/auth/oauth/token`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ provider, id_token: providerToken }),
  });

  if (!res.ok) {
    return NextResponse.json({ error: "Sign-in failed" }, { status: 401 });
  }

  const { id_token, refresh_token, expires_in, user } = await res.json();

  const jar = await cookies();
  jar.set("tm_refresh", refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/api/auth",
    maxAge: 60 * 60 * 24 * 30,
  });

  return NextResponse.json({ id_token, expires_in, user });
}

Sign-out route

// app/api/auth/logout/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

export async function POST() {
  const jar = await cookies();
  jar.delete({ name: "tm_refresh", path: "/api/auth" });
  return NextResponse.json({ ok: true });
}

Client-side token store

Hold the ID token in a module-level variable — not localStorage, not sessionStorage, not a non-httpOnly cookie. It is lost on reload, which is correct: the refresh route restores the session from the cookie.

Two details matter here. Refresh early, so a request never leaves with a token that expires mid-flight. And de-duplicate concurrent refreshes — without the inFlight guard, ten parallel requests hitting an expired token fire ten refresh calls.

let idToken: string | null = null;
let expiresAt = 0;
let inFlight: Promise<string> | null = null;

const REFRESH_MARGIN_MS = 5 * 60 * 1000;

async function refreshIdToken(): Promise<string> {
  const res = await fetch("/api/auth/refresh", { method: "POST" });

  if (!res.ok) {
    idToken = null;
    expiresAt = 0;
    throw new Error("Session expired");
  }

  const data = await res.json();
  idToken = data.id_token;
  expiresAt = Date.now() + Number(data.expires_in) * 1000;
  return data.id_token;
}

/** Called after sign-in, with the ID token your auth routes returned. */
export function setIdToken(token: string, expiresInSeconds: number) {
  idToken = token;
  expiresAt = Date.now() + expiresInSeconds * 1000;
}

export function clearIdToken() {
  idToken = null;
  expiresAt = 0;
}

export async function getIdToken(): Promise<string> {
  if (idToken && Date.now() < expiresAt - REFRESH_MARGIN_MS) {
    return idToken;
  }

  // Single-flight: concurrent callers await the same refresh.
  inFlight ??= refreshIdToken().finally(() => {
    inFlight = null;
  });

  return inFlight;
}

export async function fetchWithAuth(url: string, options: RequestInit = {}) {
  let token = await getIdToken();

  let response = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: `Bearer ${token}` },
  });

  if (response.status === 401) {
    // Token rejected despite looking fresh (e.g. revoked). Refresh once, retry
    // once — never loop, or a genuinely dead session becomes an infinite retry.
    idToken = null;
    token = await getIdToken();

    response = await fetch(url, {
      ...options,
      headers: { ...options.headers, Authorization: `Bearer ${token}` },
    });
  }

  return response;
}

Should you proxy the AI Gateway too?

No — call the gateway directly from the browser with the in-memory ID token. Proxying Server-Sent Events through your backend adds latency and a second streaming hop to debug for no security gain: the ID token is the only credential involved either way.

This does mean your browser origin must be CORS-whitelisted on the gateway host (see Before you start). With the proxy pattern, the browser never calls the auth host directly, so only the gateway origin matters.

Native mobile: use the platform keystore

Mobile apps don’t need a backend proxy. iOS and Android both ship a hardware-backed secret store, and that’s the right home for the refresh token.

Platform Store the refresh token in Never in
iOS / macOS Keychain (kSecClassGenericPassword) UserDefaults, plist files, @AppStorage
Android EncryptedSharedPreferences, or DataStore encrypted with an Android Keystore key SharedPreferences, app files, strings.xml

The ID token stays in memory on mobile too — an in-process property, not a persisted preference.

iOS

import Security

enum RefreshTokenStore {
    private static let account = "truememory.refresh_token"

    private static var baseQuery: [String: Any] {
        [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrAccount as String: account,
        ]
    }

    static func save(_ token: String) {
        SecItemDelete(baseQuery as CFDictionary)

        var query = baseQuery
        query[kSecValueData as String] = Data(token.utf8)
        // Readable after first unlock, never synced to iCloud or backups.
        query[kSecAttrAccessible as String] =
            kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly

        SecItemAdd(query as CFDictionary, nil)
    }

    static func load() -> String? {
        var query = baseQuery
        query[kSecReturnData as String] = true
        query[kSecMatchLimit as String] = kSecMatchLimitOne

        var item: CFTypeRef?
        guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
              let data = item as? Data
        else { return nil }

        return String(decoding: data, as: UTF8.self)
    }

    static func clear() {
        SecItemDelete(baseQuery as CFDictionary)
    }
}

kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly is the important part: the ThisDeviceOnly suffix keeps the token out of iCloud Keychain and encrypted backups, so a restore onto a different device doesn’t carry the session with it. For high-value apps, add kSecAttrAccessControl with .biometryCurrentSet to gate reads behind Face ID.

Android

import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey

private val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

private val prefs = EncryptedSharedPreferences.create(
    context,
    "truememory_auth",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)

fun saveRefreshToken(token: String) =
    prefs.edit().putString("refresh_token", token).apply()

fun loadRefreshToken(): String? = prefs.getString("refresh_token", null)

fun clearRefreshToken() = prefs.edit().remove("refresh_token").apply()

Accepted fallback: browser-only, no backend

Sometimes there’s no server — a static SPA, a prototype, an internal tool. Then the refresh token has to live in the browser, and localStorage is the usual choice. This is a real trade-off, not a non-issue:

  • Any XSS is a full account takeover. One injected script — yours or a compromised dependency’s — reads localStorage and exfiltrates a credential that works indefinitely.
  • You cannot revoke it. There’s no logout endpoint, so a stolen refresh token stays valid until the user’s password is reset.

If you accept that, reduce the exposure:

  • Prefer sessionStorage over localStorage when a per-tab session is acceptable — it dies with the tab and isn’t shared across them.
  • Keep the ID token in memory even here. There’s no reason to persist a token you can always re-mint.
  • Ship a strict Content-Security-Policy. It’s the control that actually addresses the threat; storage choice barely moves the needle by comparison.
  • Audit your dependency tree — every script on the page can read the token.
  • Never interpolate the token into a URL, query string, or <img> src. It lands in server logs, browser history, and Referer headers.

If the app will ever hold anything worth stealing, add the backend proxy. It is roughly the three route handlers shown above.

Sign-out and revocation

Truememory Auth has no logout or token-revocation endpoint today. Signing out is entirely a client-side operation:

  1. Delete the refresh token — clear the httpOnly cookie, or the Keychain / EncryptedSharedPreferences entry.
  2. Drop the in-memory ID token.
  3. Clear any cached user state in your app.

Already-issued ID tokens remain valid until they expire (up to an hour). Google Identity Platform invalidates refresh tokens on password change or reset, account disable, and account deletion.

Quick checklist

  • Refresh token never reaches browser JavaScript (or the trade-off is a documented decision).
  • ID token is in memory only — not localStorage, sessionStorage, or a readable cookie.
  • Auth base URL is a server-only env var in the proxy pattern (no NEXT_PUBLIC_).
  • Cookie flags: httpOnly, secure, sameSite, narrow path.
  • Refresh is single-flight and happens before expiry, not only on 401.
  • 401 triggers at most one retry — no unbounded loop.
  • Tokens never appear in URLs, logs, analytics events, or error reports.
  • Sign-out clears the refresh token from its store, not just the ID token.
  • AUTH_SECRET appears in no client bundle — it is backend-only.
Navigation

Type to search…

↑↓ navigate↵ selectEsc close