packages/typescript-client/skills/electric-proxy-auth/SKILL.md
Set up a server-side proxy to forward Electric shape requests securely. Covers ELECTRIC_PROTOCOL_QUERY_PARAMS forwarding, server-side shape definition (table, where, params), content-encoding/content-length header cleanup, CORS configuration for electric-offset/electric-handle/ electric-schema/electric-cursor headers, auth token injection, ELECTRIC_SECRET/SOURCE_SECRET server-side only, tenant isolation via WHERE positional params, onError 401 token refresh, and subset security (AND semantics). Load when creating proxy routes, adding auth, or configuring CORS for Electric.
npx skillsauth add electric-sql/electric electric-proxy-authInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill builds on electric-shapes. Read it first for ShapeStream configuration.
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client'
// Server route (Next.js App Router example)
export async function GET(request: Request) {
const url = new URL(request.url)
const originUrl = new URL('/v1/shape', process.env.ELECTRIC_URL)
// Only forward Electric protocol params — never table/where from client
url.searchParams.forEach((value, key) => {
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
originUrl.searchParams.set(key, value)
}
})
// Server decides shape definition
originUrl.searchParams.set('table', 'todos')
originUrl.searchParams.set('secret', process.env.ELECTRIC_SOURCE_SECRET!)
const response = await fetch(originUrl)
const headers = new Headers(response.headers)
headers.delete('content-encoding')
headers.delete('content-length')
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}
Client usage:
import { ShapeStream } from '@electric-sql/client'
const stream = new ShapeStream({
url: '/api/todos', // Points to your proxy, not Electric directly
})
// In proxy route — inject user context server-side
const user = await getAuthUser(request)
originUrl.searchParams.set('table', 'todos')
originUrl.searchParams.set('where', 'org_id = $1')
originUrl.searchParams.set('params[1]', user.orgId)
const stream = new ShapeStream({
url: '/api/todos',
headers: {
Authorization: async () => `Bearer ${await getToken()}`,
},
onError: async (error) => {
if (error instanceof FetchError && error.status === 401) {
const newToken = await refreshToken()
return { headers: { Authorization: `Bearer ${newToken}` } }
}
return {}
},
})
// In proxy response headers
headers.set(
'Access-Control-Expose-Headers',
'electric-offset, electric-handle, electric-schema, electric-cursor'
)
Electric combines the main shape WHERE (set in proxy) with subset WHERE (from POST body) using AND. Subsets can only narrow results, never widen them:
-- Main shape: WHERE org_id = $1 (set by proxy)
-- Subset: WHERE status = 'active' (from client POST)
-- Effective: WHERE org_id = $1 AND status = 'active'
Even WHERE 1=1 in the subset cannot bypass the main shape's WHERE.
Wrong:
url.searchParams.forEach((value, key) => {
originUrl.searchParams.set(key, value)
})
Correct:
import { ELECTRIC_PROTOCOL_QUERY_PARAMS } from '@electric-sql/client'
url.searchParams.forEach((value, key) => {
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
originUrl.searchParams.set(key, value)
}
})
originUrl.searchParams.set('table', 'todos')
Forwarding all params lets the client control table, where, and columns, accessing any Postgres table. Only forward ELECTRIC_PROTOCOL_QUERY_PARAMS.
Source: examples/proxy-auth/app/shape-proxy/route.ts
Wrong:
return new Response(response.body, {
status: response.status,
headers: response.headers,
})
Correct:
const headers = new Headers(response.headers)
headers.delete('content-encoding')
headers.delete('content-length')
return new Response(response.body, { status: response.status, headers })
fetch() decompresses the response body but keeps the original content-encoding and content-length headers, causing browser decoding failures.
Source: examples/proxy-auth/app/shape-proxy/route.ts:49-56
Wrong:
// Client-side code
const url = `/v1/shape?table=todos&secret=${import.meta.env.VITE_ELECTRIC_SOURCE_SECRET}`
Correct:
// Server proxy only
originUrl.searchParams.set('secret', process.env.ELECTRIC_SOURCE_SECRET!)
Bundlers like Vite expose VITE_* env vars to client code. The secret must only be injected server-side in the proxy.
Source: AGENTS.md:17-20
Wrong:
originUrl.searchParams.set('where', `org_id = '${user.orgId}'`)
Correct:
originUrl.searchParams.set('where', 'org_id = $1')
originUrl.searchParams.set('params[1]', user.orgId)
String interpolation in WHERE clauses enables SQL injection. Use positional params ($1, $2).
Source: website/docs/guides/auth.md
Wrong:
// No CORS header configuration — browser strips custom headers
return new Response(response.body, { headers })
Correct:
headers.set(
'Access-Control-Expose-Headers',
'electric-offset, electric-handle, electric-schema, electric-cursor'
)
return new Response(response.body, { headers })
The client throws MissingHeadersError if Electric response headers are stripped by CORS. Expose electric-offset, electric-handle, electric-schema, and electric-cursor.
Source: packages/typescript-client/src/error.ts:109-118
Wrong:
new ShapeStream({
url: 'https://my-electric.example.com/v1/shape',
params: { table: 'todos' },
})
Correct:
new ShapeStream({
url: '/api/todos', // Your proxy route
})
Electric's HTTP API is public by default with no auth. Always proxy through your server so the server controls shape definitions and injects secrets.
Source: AGENTS.md:19-20
See also: electric-shapes/SKILL.md — Shape URLs must point to proxy routes, not directly to Electric. See also: electric-deployment/SKILL.md — Production requires ELECTRIC_SECRET and proxy; dev uses ELECTRIC_INSECURE=true. See also: electric-postgres-security/SKILL.md — Proxy injects secrets that Postgres security enforces.
Targets @electric-sql/client v1.5.10.
development
Interactive blog post authoring. Produces a draft blog post file with structured outline, inline guidance comments, and meta briefs that the author proses up in place. Supports pyramid principle, best sales deck, and release post formats.
development
Set up ElectricProvider for real-time collaborative editing with Yjs via Electric shapes. Covers ElectricProvider configuration, document updates shape with BYTEA parser (parseToDecoder), awareness shape at offset='now', LocalStorageResumeStateProvider for reconnection with stableStateVector diff, debounceMs for batching writes, sendUrl PUT endpoint, required Postgres schema (ydoc_update and ydoc_awareness tables), CORS header exposure, and sendErrorRetryHandler. Load when implementing collaborative editing with Yjs and Electric.
tools
Configure ShapeStream and Shape to sync a Postgres table to the client. Covers ShapeStreamOptions (url, table, where, columns, replica, offset, handle), custom type parsers (timestamptz, jsonb, int8), column mappers (snakeCamelMapper, createColumnMapper), onError retry semantics, backoff options, log modes (full, changes_only), requestSnapshot, fetchSnapshot, subscribe/unsubscribe, and Shape materialized view. Load when setting up sync, configuring shapes, parsing types, or handling sync errors.
documentation
Design Postgres schema and Electric shape definitions together for a new feature. Covers single-table shape constraint, cross-table joins using multiple shapes, WHERE clause design for tenant isolation, column selection for bandwidth optimization, replica mode choice (default vs full for old_value), enum casting in WHERE clauses, and txid handshake setup with pg_current_xact_id() for optimistic writes. Load when designing database tables for use with Electric shapes.