---
title: "Suggested implementation"
description: "Where to store refresh tokens, where to use ID tokens, and how to refresh safely — recommended patterns for web and mobile clients."
---

> Documentation Index
> Fetch the complete documentation index at: https://truememory-docs.mvstg.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Suggested implementation

[Tokens & refresh](/authentication/tokens/) 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_token`s | **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](#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.

:::caution[Truememory Auth returns the refresh token in the response body]
`/auth/register`, `/auth/login`, `/auth/oauth/token`, and `/auth/refresh` all return `refresh_token` as JSON. Truememory Auth sets no cookies and has no httpOnly-cookie mode. If you want the refresh token out of JavaScript's reach, **your backend has to put it there** — that's what the recommended pattern below does.
:::

## Recommended: keep the refresh token in your backend

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.

```mermaid
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.

```typescript
// 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.

```typescript
// 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](/authentication/oauth/) post to.

```typescript
// 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

```typescript
// 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.

```typescript
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](/authentication/overview/#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

```swift
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

```kotlin
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()
```

:::note[Check your Jetpack Security version]
`EncryptedSharedPreferences` ships in `androidx.security:security-crypto`, whose maintenance status has shifted across releases. If your toolchain flags it as deprecated, the replacement is the same idea assembled by hand: a key from Android Keystore (`AndroidKeyStore` provider, AES/GCM) used to encrypt values before writing them to DataStore. Don't respond to the deprecation warning by falling back to plain `SharedPreferences`.
:::

## 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.

:::danger[Treat a leaked refresh token as an incident]
Because your app can't revoke one, the practical remediation is to force the user through [password reset](/authentication/email-password/), which invalidates existing refresh tokens at the identity-provider level. Plan for this before you need it.
:::

## 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](/authentication/service-accounts/).

Source: https://truememory-docs.mvstg.com/authentication/token-handling/index.mdx
