Auth Denied
Auth failures in plasma come in three distinct flavours. Which one you’re hitting determines the fix.
Flavour 1 — request-level 401
Section titled “Flavour 1 — request-level 401”Symptom:
- HTTP 401 on
/sync/pushor/sync/pull client.onErrorfires with{ kind: "push-http", status: 401, url }or{ kind: "pull-http", status: 401, url }after retry exhaustion
Cause: SyncHandlerOptions.auth(req) returned { ok: false }.
Fix: Check what your auth function is doing:
auth: async (req) => { const token = req.headers.get("authorization")?.replace("Bearer ", "") console.log("auth token:", token?.slice(0, 8)) // sanity log
if (!token) return { ok: false, reason: "no token" }
const user = await verifyJWT(token) if (!user) return { ok: false, reason: "bad token" }
return { ok: true, clientGroupID: user.id, clientID: req.headers.get("x-client") ?? "unknown", ctx: { userId: user.id }, }}Common causes:
- The client isn’t sending the
Authorizationheader. Make surePlasmaClientOptions.authHeadersis wired:createPlasmaClient({...,authHeaders: async () => ({"authorization": `Bearer ${await getToken()}`,}),}) - The token expired mid-session.
authHeadersis called on every request, so an eagerly-refreshing token function is the fix.
Flavour 2 — mutator-level PlasmaAuthorizationError
Section titled “Flavour 2 — mutator-level PlasmaAuthorizationError”Symptom:
- Push returned
200 OK({ ok: true }on the wire). - No
SyncClientErrorfires. - The user’s optimistic apply looked successful for a moment, then the row silently disappeared on the next pull.
Cause: A table’s auth.write(ctx, row) returned false. The
server-side mutator threw PlasmaAuthorizationError, the transaction
rolled back, last_mutation_id still advanced (so the poison isn’t
retried), and no change log row was written. On the next pull the
client’s dropConfirmed removes the outbox entry; rebuildOptimistic
replays the surviving outbox on top of the base store (which never
saw the write), and the optimistic view reverts.
Fix: Check the table’s auth.write predicate. Common causes:
- The
ctxvalue on the server doesn’t have the field you expected. If yourauth()returnsctx: { userId: user.id }but the row’suserIduses a different format (email vs UUID), the predicate never matches. - The client optimistically wrote a row with a
userIdthat doesn’t belong to the current user — perhaps a stale form value from before a user switch. - The row you’re updating was created by a different user, and the UI didn’t gate the “edit” button on ownership.
UI response: pre-check permissions in your component before
calling mutate(). Since the client can’t observe the server-side
PlasmaAuthorizationError directly, the visible failure mode is
“your row just came back”. Design the button state to reflect
ownership:
<button disabled={row.userId !== ctx.userId} onClick={() => update.mutate({ ... })}> Edit</button>Flavour 3 — missing rows on pull
Section titled “Flavour 3 — missing rows on pull”Symptom:
- No error events fire.
- But rows the user expects to see never appear.
- The Devtools panel shows the
_plasma_changeson the server has them; the client’s IDB doesn’t.
Cause: The table’s auth.read(ctx, row) returned false for
the rows on pull. plasma silently filters unauthorised rows from
pull responses so a user never sees a row they aren’t allowed to.
Fix: Same predicate diagnosis as Flavour 2. Check:
- Is
ctx.userId(or whatever field your predicate uses) what you expect at pull time? Log it in yourauth()handler. - Is the row’s own field (
row.userId) what you expect? Query the server DB directly:Terminal window wrangler d1 execute my-app-prod --command "SELECT id, userId FROM todos WHERE ..." - Are you accidentally using a strict equality where you meant a
set membership? (
row.teamId === ctx.userIdwhen the user is a member of many teams).
Debugging with wrangler tail
Section titled “Debugging with wrangler tail”Every auth decision goes through your auth() function first.
Log there and watch the tail:
auth: async (req) => { const result = await realAuthFunction(req) console.log("auth:", req.url, result.ok, result.reason) return result}pnpm wrangler tailEvery request logs a line; you can correlate the client’s failing mutation to the server’s rejection.
Preventing recurrence
Section titled “Preventing recurrence”- Pre-check on the client where the UX benefits. Don’t rely on the reversion for the common case.
- Test with realistic ctx values. Include auth tests in your
server-side test suite that exercise
auth.writereturningfalseand assert that the transaction rolled back (change log has no matching row) andlast_mutation_idadvanced to drop the poison. - Log rejections aggregated. In production, hook
auth()return values into your telemetry so you can spot patterns (“users on version X are all getting 401”; possibly a token format change).
What to read next
Section titled “What to read next”- Auth and permissions — the three levels of auth
- Sync errors — full error kind table