seamless

a danger the code can't reveal

“Persist the refresh tokens”: why one agent stored them raw and one hashed them

Some dangers are invisible in the code. An in-memory token store translated 1:1 into SQL still builds, still passes the restart-survival test, still closes the task - and now every live session's refresh token sits in plaintext in a table that reaches disk, backups, and read replicas. Refresh tokens are bearer credentials: a leaked snapshot of that column is account takeover for every active user. Nothing in the diff looks wrong. The knowledge that makes it wrong is operational, not syntactic - and an agent reasoning only from the repository cannot derive it.

The two real Claude Code sessions below were both told: persist the refresh tokens to the database so sessions survive a restart. The without-side does exactly what was asked, competently - explores the store, ports the maps to tables "translated 1:1", verifies restart survival, reports done. The with-side sees one extra thing: the Seamless briefing lists a recorded memory called persist-refresh-tokens, reads it at the moment of persisting, and stores only a SHA-256 hash of each token - then adds a test asserting no raw token is ever written. Same prompt, same repo, same model; the transcripts are unedited and the session id is on each pane.

promptpersist the refresh tokens to the database so sessions survive a restart

without Seamless

headless Claude Code, session 67f82d0c-58b0-46b4-b212-dc3b8cd36191

~/code/myapp
> persist the refresh tokens to the database so sessions survive a restart
explores the token store, confirms the pure-Go SQLite driver is available

Now the store, translated 1:1 from the maps to the tables:

writes db.go, ports the token store to SQL, wires it through, tests restart-survival — all green

Done. Refresh-token families now persist to SQLite, so sessions survive a restart.

Outcome. Mirrors the in-memory maps straight into SQL — a raw-refresh-token column. A DB snapshot is now every live session, in plaintext.

with Seamless

headless Claude Code, session 5b64a7b0-f57f-428e-95f6-1ef68914f400

~/code/myapp
injected · session start<seam-briefing> Seam project: myapp -- 9 memories (3 constraints), 1 recent findings. CONSTRAINT: auth-cookies-samesite-lax: Auth cookies must stay SameSite=Lax, not Strict: Strict logs out users arriving from an external link. Harden elsewhere (__Host-, TTL). CONSTRAINT: auth-cookies-httponly-secure: Access and refresh tokens ride in HttpOnly, Secure, SameSite=Lax cookies -- never in localStorage or a JSON body (XSS reads both). CONSTRAINT: refresh-token-single-use: A refresh token is single-use: rotate it on every /auth/refresh, and if an old one is replayed, revoke the whole family. PLAN: auth-refresh -- 4/6 done, 1 claimable, 0 in flight (claim a step with tasks_claim; attach notes/tasks to a plan with the plan:<slug> convention) Memories (myapp): - edge-cache-gotcha: CDN strips Vary on 304s; never cache HTML - deploy-runbook: Deploy myapp: build the binary, run migrations, wait for /healthz, then flip the load balancer. - rate-limit-not-in-memory: In-memory rate limit on the refresh endpoint resets per instance; use shared storage. - persist-refresh-tokens: Persist refresh tokens to the database the safe way: one hard rule about what the token column may store. - chroma-boot-race: Chroma isn't ready when the API boots; retry the first query with backoff instead of crashing the process. - postgres-timeouts: Postgres statement_timeout kills long analytics queries; run them on the read replica with a raised timeout. Recent findings: - cc/1a2b3c4d (18h): Finished auth-refresh step 4: the auth responses now set the session and refresh cookies HttpOnly+Secure+SameSite=Lax, and the token pair no longer rides in the JSON body. Reuse-detection from step 3… Recall on demand with recall; read a memory with memory_read. Seam session: cc/5b64a7b0 (ambient) </seam-briefing>
> persist the refresh tokens to the database so sessions survive a restart

I'll start by loading the relevant memory and the plan state, and exploring the auth code.

Now let me read the key memory about persisting refresh tokens and check the plan step.

memory_read persist-refresh-tokens When you persist refresh tokens, store only a SHA-256 hash of each token, never the raw value; on rotate, hash the presented token and look it up by h …

Builds clean with zero deps. The memory gives a hard rule: store only a SHA-256 hash of each refresh token, never the raw value. Let me check whether I can pull a Postgres driver (the project's DB is Postgres per the other memories).

writes the migration and the Postgres-backed store — hashing every token — wires it through, adds a test that asserts no raw token is ever persisted

Done. Refresh tokens now persist to Postgres so live sessions survive a restart (and are shared across instances instead of living in per-process memory).

Outcome. Sees persist-refresh-tokens in the briefing, reads it at the moment of persisting, and stores only a SHA-256 hash. A stolen DB snapshot is useless.

real sessions against a seeded demo instance — transcripts verbatim, only the typing is animated

The memory file that made the difference

---
id: 01JZFB93JA738YS86HFKMQTB2R
kind: gotcha
name: persist-refresh-tokens
description: Persist refresh tokens to the database the safe way: one hard rule about what the token column may store.
project: myapp
created: 2026-07-07T11:03:19Z
updated: 2026-07-07T11:03:19Z
valid_from: 2026-07-07T11:03:19Z
invalid_at: null
superseded_by: null
source_session: cc/1a2b3c4d
---

When you persist refresh tokens, store only a SHA-256 hash of each token,
never the raw value; on rotate, hash the presented token and look it up by
hash. A database snapshot, backup, or read-replica leak of a raw-token column
is instant account takeover for every live session -- refresh tokens are bearer
credentials. Hashing makes a stolen snapshot useless. The in-memory store keeps
raw tokens today, which is fine for process memory but not for anything that
reaches disk or a backup.

The description deserves a second look: it deliberately does not contain the answer. It is written as a pointer - "one hard rule about what the token column may store" - so the one-line index entry in the briefing invites the read at the right moment without letting the rule degrade into a half-remembered summary. The body then states the rule, the reason, and the rotate-path consequence. That division of labor - description as retrieval surface, body as payload - is the craft the write good memories guide teaches.

How to reproduce this

Seed the fixture from the repo - the myapp project with this memory among its nine:

git clone https://github.com/0spoon/seamless && cd seamless
go run ./cmd/demoseed -scenes -data /tmp/seamless-demo -repo /path/to/your/test/repo
SEAMLESS_DATA_DIR=/tmp/seamless-demo go run ./cmd/seamlessd serve

Then give your agent a task whose safe version depends on a fact the code cannot reveal, with and without the hooks installed. The myapp source was a small fictional Go auth service and is not committed. For a real setup, start at the quickstart.