Authorization model

Every svelte-realtime primitive - both the realtime layer and the adapter plugins, plus every extension - is identity-blind: the framework delivers, locks, fences, throttles, or stores on whatever key the caller passes, full stop. The framework does not know who is allowed to take which action. Authorization is the application’s job.

This page is the canonical statement of that contract, the three building blocks the framework gives you, and the failure mode each primitive will exhibit if authorization is missed.

The contract

Three responsibilities, in order:

  1. upgrade() establishes identity for the WebSocket connection. Whatever you return becomes ctx.user for the lifetime of the connection. This is the only place identity enters the system.
  2. Each handler is the authorization gate. live(), live.stream(), live.cron(), live.channel(), live.room(), live.upload(), live.validated(), etc. - the handler decides whether the action is allowed for ctx.user. Throw LiveError('FORBIDDEN', ...) to deny.
  3. Backend primitives are not gates. createDistributedLock, createIdempotencyStore, createPresence, createReplay, live.push, live.notify, ctx.publish, every plugin’s primitive - they all execute on whatever key you pass them. They will not check if the caller is allowed.

If the gate is missed, the primitive will happily run with attacker-controlled inputs. The framework adds defense-in-depth caps (256-character key length, 8 KB cursor data, validated topic shapes, __-prefix protection), but those are not authorization. They limit the blast radius if authorization is missed; they do not replace it.

Where identity enters

// src/hooks.ws.js
export function upgrade({ cookies, getHeader }) {
  const session = validateSession(cookies.session_id);
  if (!session) return false;
  return {
    id: session.userId,
    tenantId: session.tenantId,
    role: session.role
  };
}

This is the only trusted source. Inside every handler, ctx.user is whatever was returned here. Anything on the wire is suspect until the handler proves otherwise.

The placeholder scaffold from npx svelte-realtime deliberately keeps a permissive upgrade() so the first npm run dev works. It also emits a per-connection runtime console.warn until the SCAFFOLD_PLACEHOLDER marker is deleted - the marker is the explicit “I have replaced this with real auth” action. See Quick Start for the three real-auth patterns.

Building blocks

The framework gives you four building blocks. Mix and match.

_guard (per-module)

Every file under src/live/ may export _guard = guard(...). The guard runs before every function in that file.

// src/live/admin.js
import { live, guard, LiveError } from 'svelte-realtime/server';

export const _guard = guard((ctx) => {
  if (ctx.user?.role !== 'admin') throw new LiveError('FORBIDDEN');
});

export const deleteUser = live(async (ctx, userId) => { /* admin-only */ });

Composable: guard(fn1, fn2, fn3) runs in order; earlier guards can enrich ctx for later ones. See Auth.

live.middleware() (global)

Cross-cutting authorization that should fire on every call.

import { live, LiveError } from 'svelte-realtime/server';

live.middleware(async (ctx, next) => {
  if (!ctx.user) throw new LiveError('UNAUTHENTICATED');
  return next();
});

Middleware runs before per-module guards. See Auth -> Global middleware.

Stream access predicate

live.stream({ access }) is the per-subscription gate. The predicate receives ctx and is checked once at subscription time.

export const adminFeed = live.stream('admin-feed', loader, {
  access: (ctx) => ctx.user?.role === 'admin'
});

The predicate may be sync or async. Composable via live.access.all(...) / live.access.any(...). See Auth -> Stream access control.

Inline ctx.user checks

For one-off authorization inside an otherwise-public module:

export const deleteItem = live(async (ctx, id) => {
  if (!ctx.user) throw new LiveError('UNAUTHENTICATED');
  if (!ctx.user.canDelete) throw new LiveError('FORBIDDEN');
  await db.items.delete(id);
});

Failure modes per primitive

The same identity-blind contract applies everywhere. The specific failure mode depends on what each primitive does with the key it receives.

PrimitiveWhat happens if authorization is missed
live.stream(topic, loader, { access })A subscriber without permission lands in the topic’s subscriber set and receives every published frame.
live.push(target, event, data) / live.notifyThe delivery lands at whatever userId was passed. A wire-supplied target.userId lets an attacker direct an arbitrary frame at an arbitrary user. The fix is mustOwnUser(ctx, targetUserId) at the handler entry. See Auth -> Server-initiated push.
ctx.publish(topic, event, data)Publishes on whatever topic is passed. __-prefixed topics are framework-reserved and rejected with INVALID_TOPIC; everything else broadcasts to the topic’s subscriber set.
createDistributedLock(...).withLock(key, fn)The lock is on whatever key is passed. A wire-supplied key blocks the legitimate owner across the entire cluster, not just one process.
createIdempotencyStore(...).acquire(idempotencyKey)A colliding idempotencyKey reads another caller’s cached commit. The wrappers (live.idempotent, tasks.run) auto-namespace by RPC path and user; raw stores do not.
createReplay(redis).publish(topic, event, data)Publishes on whatever topic is passed. Cross-tenant topic collision merges replay streams. See Multi-tenant deployments.
createPresence(redis).join(ws, topic, platform)Presence groups by (topic, userKey). A handler that joins on a wire-supplied topic places the user in an attacker-chosen pool.
createNotifyBridge(pg, { channel, topic })LISTEN/NOTIFY fans out to every subscriber of the channel. The channel name itself is the trust boundary.
createTaskRunner(...).run(name, args)Runs whatever task name was passed with whatever args. Both are trust inputs from the handler’s perspective.
createJobQueue(...).enqueue(payload)Stores the payload verbatim; downstream workers consume what was enqueued.

The pattern: derive backend keys from server-trust identity, not from wire fields. When a backend key has to incorporate a wire-supplied value (an order ID, a document ID), validate ownership before constructing the key.

The mustOwnUser pattern

For RPCs that take a userId and route based on it, the canonical helper:

import { LiveError } from 'svelte-realtime/server';

export function mustOwnUser(ctx, targetUserId) {
  if (!ctx.user) throw new LiveError('UNAUTHENTICATED');
  if (ctx.user.role === 'admin') return;            // admin override
  if (ctx.user.id === targetUserId) return;         // self-targeted
  if (ctx.user.tenantId && targetUserId.startsWith(`${ctx.user.tenantId}:`)) return; // tenant peer
  throw new LiveError('FORBIDDEN');
}

Apply it at the handler entry, before any backend primitive sees the value:

import { live, mustOwnUser } from '$lib/auth';

export const sendDirectMessage = live.notify(async (ctx, msg) => {
  mustOwnUser(ctx, msg.recipientId);
  return { event: 'message:received', data: { from: ctx.user.id, text: msg.text } };
});

The helper is intentionally generic - the four cases above are illustrative defaults; an app’s actual ownership logic may differ. The point is the function: a named, single-purpose gate that the handler must call before passing user-controlled identity downstream. The same shape extends to any future push-target shape ({ group }, { role }, { tenant }).

What’s safe and what isn’t

A userId (or any identity-shaped value) is safe to pass to a backend primitive when its source is server-trust:

  • Server-authored database rows: db.orders.findOne(...).customerId.
  • ctx.user.id (set by upgrade()).
  • A verified webhook claim with HMAC- or signature-validated payload.
  • A cron handler’s own scheduled state.

It is unsafe without an ownership check when its source is the wire:

  • Any field from the RPC’s args array.
  • A query-string value forwarded into the handler.
  • The first argument to live.upload(name, mime) (the filename / mime is wire data).
  • Anything destructured from a JSON envelope without prior validation.

live.validated() plus a schema does not authorize - it only validates shape. A z.object({ userId: z.string() }) accepts any string, including one that names another user. Schema validation and authorization are independent layers.

The default-allow default

The framework’s posture is “any authenticated WS can invoke any registered handler unless a _guard says otherwise.” This matches every other web framework and keeps the “Hello, world” path zero-config. To keep the default-allow from silently leaking auth, the vite codegen warns at build / dev time when a module exports any handler but has no _guard. Three opt-out paths:

  1. Export a _guard = guard(...) - the standard auth gate.
  2. Wrap individual handlers in live.public(fn) - a runtime no-op marker that says “this RPC is intentionally public”.
  3. Add a // realtime-allow-public (or /* realtime-allow-public */) source comment - module-wide opt-out.

The warning is a soft nudge, not a hard error. Apps that intentionally want a public module pick one of the three; apps that forgot a guard see the warning. See Auth -> live.public.

Defense-in-depth caps

Where the framework can name a safe maximum without surprising a caller, it does:

CapDefaultWhere
maxIdLength / maxKeyLength / maxTopicLength256 charsadapter plugins: dedup, lock, throttle, debounce, queue, cursor
maxDataBytes8 KBadapter cursor plugin (cursor positions are ~30 bytes)
maxWaitersPerKey1000adapter lock plugin (flood control)
maxResultBytes256 KBidempotency stores (Redis + Postgres)
maxEnvelopeBytes1 MBbus validator (DoS cap before JSON.parse)
maxEnvelopeDepth64handleRpc (post-parse JSON nesting)
maxBuckets32Prometheus histogram registration
maxSeries10000Prometheus per-metric labelset cardinality
__-prefix blockalways onctx.publish, wire subscribes
Sensitive key denylistalways ondefault select in adapter + Redis createPresence, createCursor; bus envelope sanitizer
Object.create(null)always onmiddleware ctx.locals (prototype pollution)

The caps are configurable per-call when the default does not fit; they are not authorization. They reduce blast radius if authorization is missed.

See also

Was this page helpful?