Skip to content

Auth Denied

Auth failures in plasma come in three distinct flavours. Which one you’re hitting determines the fix.

Symptom:

  • HTTP 401 on /sync/push or /sync/pull
  • client.onError fires 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 Authorization header. Make sure PlasmaClientOptions.authHeaders is wired:
    createPlasmaClient({
    ...,
    authHeaders: async () => ({
    "authorization": `Bearer ${await getToken()}`,
    }),
    })
  • The token expired mid-session. authHeaders is 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 SyncClientError fires.
  • 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 ctx value on the server doesn’t have the field you expected. If your auth() returns ctx: { userId: user.id } but the row’s userId uses a different format (email vs UUID), the predicate never matches.
  • The client optimistically wrote a row with a userId that 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>

Symptom:

  • No error events fire.
  • But rows the user expects to see never appear.
  • The Devtools panel shows the _plasma_changes on 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 your auth() 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.userId when the user is a member of many teams).

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
}
Terminal window
pnpm wrangler tail

Every request logs a line; you can correlate the client’s failing mutation to the server’s rejection.

  • 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.write returning false and assert that the transaction rolled back (change log has no matching row) and last_mutation_id advanced 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).