Skillshub electric-proxy-auth
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/electric-sql/electric/electric-proxy-auth" ~/.claude/skills/comeonoliver-skillshub-electric-proxy-auth && rm -rf "$T"
skills/electric-sql/electric/electric-proxy-auth/SKILL.mdThis skill builds on electric-shapes. Read it first for ShapeStream configuration.
Electric — Proxy and Auth
Setup
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 })
Core Patterns
Tenant isolation with WHERE params
// 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)
Auth token refresh on 401
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 {} }, })
CORS configuration for cross-origin proxies
// In proxy response headers headers.set( 'Access-Control-Expose-Headers', 'electric-offset, electric-handle, electric-schema, electric-cursor' )
Subset security (AND semantics)
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.
Common Mistakes
CRITICAL Forwarding all client params to Electric
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
CRITICAL Not deleting content-encoding and content-length headers
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
CRITICAL Exposing ELECTRIC_SECRET or SOURCE_SECRET to browser
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
CRITICAL SQL injection in WHERE clause via string interpolation
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
HIGH Not exposing Electric response headers via CORS
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
CRITICAL Calling Electric directly from production client
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.
Version
Targets @electric-sql/client v1.5.10.