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.
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.
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
localStorageand 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
sessionStorageoverlocalStoragewhen 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, andRefererheaders.
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:
- Delete the refresh token — clear the httpOnly cookie, or the Keychain /
EncryptedSharedPreferencesentry. - Drop the in-memory ID token.
- 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, narrowpath. - Refresh is single-flight and happens before expiry, not only on
401. -
401triggers 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_SECRETappears in no client bundle — it is backend-only.