Skip to main content

Electric SQL Auth Proxy

The Activation API provides an authenticated proxy for Electric SQL that enables secure, multi-tenant data access from external client applications.

Overview

Instead of clients connecting directly to Electric SQL, they connect through the Activation API which:
  1. Authenticates requests via OAuth 2.0 or API keys
  2. Filters data automatically by customer_id for multi-tenant isolation
  3. Proxies requests to the Electric SQL backend
  4. Streams responses back to the client

Benefits

  • External UI code - Dashboards/clients don’t need to be in this repo
  • Data isolation - Clients only see their own customer’s data
  • Standard auth - OAuth 2.0 and API key support
  • Centralized security - All auth/authz in one place
  • Real-time updates - Full Electric SQL streaming support

API Endpoint

GET /api/electric/v1/shape

Authentication

OAuth 2.0:
curl -H "Authorization: Bearer ${OAUTH_TOKEN}" \
  "https://api.agentic.scope3.com/api/electric/v1/shape?table=operations&offset=-1"
API Key:
curl -H "x-scope3-api-key: ${API_KEY}" \
  "https://api.agentic.scope3.com/api/electric/v1/shape?table=operations&offset=-1"

Query Parameters

All Electric SQL query parameters are supported:
  • table (required) - The table name
  • offset (optional) - Pagination offset (use -1 for latest)
  • where (optional) - Additional WHERE clauses
  • columns (optional) - Column selection
  • limit (optional) - Result limit

Automatic Customer Filtering

The proxy automatically adds customer_id filtering to all requests:
-- Your request:
?table=operations&where="status" = 'pending'

-- Becomes:
?table=operations&where=("status" = 'pending') AND "customer_id" = '123'

Client Example

JavaScript/TypeScript

const API_URL = "https://api.agentic.scope3.com/api/electric/v1/shape";
const API_KEY = process.env.SCOPE3_API_KEY;

async function streamOperations() {
  let offset = "-1"; // Start with latest

  while (true) {
    const url = `${API_URL}?table=operations&offset=${offset}`;

    const response = await fetch(url, {
      headers: {
        "x-scope3-api-key": API_KEY,
      },
    });

    const data = await response.json();

    // Process new operations
    for (const operation of data) {
      console.log("Operation update:", operation);
    }

    // Update offset from response header
    const newOffset = response.headers.get("electric-offset");
    if (newOffset) {
      offset = newOffset;
    }

    // Poll every 2 seconds
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

React Hook

import { useEffect, useState } from "react";

interface Operation {
  id: string;
  status: string;
  // ... other fields
}

export function useOperations() {
  const [operations, setOperations] = useState<Operation[]>([]);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const API_URL = "https://api.agentic.scope3.com/api/electric/v1/shape";
    const API_KEY = process.env.REACT_APP_SCOPE3_API_KEY;
    let offset = "-1";
    let isCancelled = false;

    async function poll() {
      while (!isCancelled) {
        try {
          const url = `${API_URL}?table=operations&offset=${offset}`;

          const response = await fetch(url, {
            headers: {
              "x-scope3-api-key": API_KEY!,
            },
          });

          if (!response.ok) throw new Error(`HTTP ${response.status}`);

          const data = await response.json();

          setOperations((prev) => {
            const updated = [...prev];
            for (const op of data) {
              const idx = updated.findIndex((o) => o.id === op.id);
              if (idx >= 0) {
                updated[idx] = op; // Update existing
              } else {
                updated.push(op); // Add new
              }
            }
            return updated;
          });

          // Update offset
          const newOffset = response.headers.get("electric-offset");
          if (newOffset) offset = newOffset;
        } catch (err) {
          setError(err instanceof Error ? err : new Error(String(err)));
        }

        await new Promise((resolve) => setTimeout(resolve, 2000));
      }
    }

    poll();

    return () => {
      isCancelled = true;
    };
  }, []);

  return { operations, error };
}

Security

Customer Isolation

Every request is automatically filtered by the authenticated user’s customer_id:
// Client request
GET /api/electric/v1/shape?table=operations

// Proxied to Electric SQL as
GET /v1/shape?table=operations&where="customer_id" = '123'
This ensures clients can ONLY see data belonging to their customer.

Authentication Required

All requests must include either:
  • Authorization: Bearer ${TOKEN} (OAuth 2.0)
  • x-scope3-api-key: ${API_KEY} (API key)
Unauthenticated requests return 401.

Cache Headers

Responses include Vary: Authorization to ensure proper caching per-customer.

Architecture

┌─────────────┐
│   Client    │
│ (Dashboard) │
└──────┬──────┘
       │ 1. GET /api/electric/v1/shape
       │    Authorization: Bearer ${token}

┌──────▼──────────────────────────────┐
│   Activation API                     │
│   (Auth Proxy)                       │
│                                      │
│   1. Authenticate user               │
│   2. Get customer_id from token      │
│   3. Add customer_id to WHERE        │
│   4. Proxy to Electric SQL           │
└──────┬───────────────────────────────┘
       │ 2. GET /v1/shape?where="customer_id"='123'

┌──────▼──────────────────────────────┐
│   Electric SQL                       │
│   (Real-time Sync)                   │
│                                      │
│   1. Query PostgreSQL                │
│   2. Stream updates                  │
└──────┬───────────────────────────────┘
       │ 3. Stream response

┌──────▼──────────────────────────────┐
│   PostgreSQL                         │
│   (Source of Truth)                  │
└──────────────────────────────────────┘

Configuration

Environment Variables

# Electric SQL backend URL
ELECTRIC_URL=http://localhost:3000/v1/shape

# OAuth configuration (for JWT validation)
OAUTH_AUTH_SERVER_URL=https://identity.scope3.com

# API key authentication
# (customer_id lookup via BigQuery)

Docker Compose

services:
  activation-api:
    environment:
      ELECTRIC_URL: http://electric:3000/v1/shape
      OAUTH_AUTH_SERVER_URL: https://identity.scope3.com
    depends_on:
      - electric
      - postgres

  electric:
    image: electricsql/electric:latest
    environment:
      DATABASE_URL: postgresql://postgres:postgres@postgres:5432/activation_api
      ELECTRIC_WRITE_TO_PG_MODE: direct_writes
      ELECTRIC_INSECURE: "true" # Dev only
    ports:
      - "3000:3000" # Not exposed externally - only via proxy

Migration from Direct Access

Before (Insecure)

// ❌ Client connects directly to Electric SQL
const ELECTRIC_URL = "http://localhost:3000/v1/shape";

fetch(`${ELECTRIC_URL}?table=operations&offset=-1`)
  .then((res) => res.json())
  .then((data) => {
    // Problem: No authentication, sees ALL customers' data!
  });

After (Secure)

// ✅ Client connects via authenticated proxy
const API_URL = "https://api.agentic.scope3.com/api/electric/v1/shape";

fetch(`${API_URL}?table=operations&offset=-1`, {
  headers: {
    "x-scope3-api-key": process.env.API_KEY,
  },
})
  .then((res) => res.json())
  .then((data) => {
    // ✅ Only sees own customer's data
  });

Testing

Local Development

# Start services
docker-compose up -d postgres electric

# Start API with proxy
npm run dev

# Test the proxy (needs API key)
curl -H "x-scope3-api-key: ${API_KEY}" \
  "http://localhost:3001/api/electric/v1/shape?table=operations&offset=-1"

Verify Customer Isolation

# User 1 (customer_id = 123)
curl -H "x-scope3-api-key: ${API_KEY_1}" \
  "${API_URL}?table=operations&offset=-1"
# Returns only customer 123's operations

# User 2 (customer_id = 456)
curl -H "x-scope3-api-key: ${API_KEY_2}" \
  "${API_URL}?table=operations&offset=-1"
# Returns only customer 456's operations

References

I