| name | electric-proxy-auth |
| description | 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, Bun fetch concurrency cap (BUN_CONFIG_MAX_HTTP_REQUESTS default 256), 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.
|
| type | core |
| library | electric |
| library_version | 1.5.10 |
| requires | ["electric-shapes"] |
| sources | ["electric-sql/electric:packages/typescript-client/src/constants.ts","electric-sql/electric:examples/proxy-auth/app/shape-proxy/route.ts","electric-sql/electric:website/docs/guides/auth.md","electric-sql/electric:website/docs/guides/security.md"] |
This 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'
export async function GET(request: Request) {
const url = new URL(request.url)
const originUrl = new URL('/v1/shape', process.env.ELECTRIC_URL)
url.searchParams.forEach((value, key) => {
if (ELECTRIC_PROTOCOL_QUERY_PARAMS.includes(key)) {
originUrl.searchParams.set(key, value)
}
})
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',
})
Core Patterns
Tenant isolation with WHERE params
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
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:
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:
const url = `/v1/shape?table=todos&secret=${import.meta.env.VITE_ELECTRIC_SOURCE_SECRET}`
Correct:
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 Bun fetch concurrency cap (256) bottlenecks the proxy under load
Wrong:
bun run proxy.ts
Correct:
BUN_CONFIG_MAX_HTTP_REQUESTS=4096 bun run proxy.ts
Bun caps simultaneous fetch() calls at 256 per process by default. Excess requests queue silently — the proxy keeps accepting inbound connections but upstream calls to Electric stall, surfacing as latency spikes rather than errors. Raise BUN_CONFIG_MAX_HTTP_REQUESTS (max 65,336) to match your expected concurrent shape requests. Node and Deno do not impose this cap.
Source: https://bun.com/docs/runtime/networking/fetch
HIGH Not exposing Electric response headers via CORS
Wrong:
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',
})
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.