Skip to content

Auth

How to do auth with Electric.

Including examples for proxy and gatekeeper auth.

It's all HTTP

The golden rule with Electric is that it's all just HTTP.

So when it comes to auth, you can use existing primitives, such as your API, middleware and external authorization services.

Shapes are resources

With Electric, you sync data using Shapes and shapes are just resources.

You access them by making a request to GET /v1/shape, with the shape definition in the query string (?table=items, etc.). You can authorise access to them exactly the same way you would any other web resource.

Requests can be proxied

When you make a request to Electric, you can route it through an HTTP proxy or middleware stack. This allows you to authorise the request before it reaches Electric.

Illustration of an authorzing proxy

You can proxy the request in your cloud, or at the edge, in-front of a CDN. Your auth logic can query your database, or call an external service. It's all completely up-to-you.

Rules are optional

You don't have to codify your auth logic into a database rule system. There's no need to use database rules to secure a sync engine when it runs over standard HTTP.

Patterns

The two patterns we recommend and describe below, with code and examples, are:

  • proxy auth — authorising Shape requests using a proxy
  • gatekeeper auth — using your API to generate shape-scoped access tokens

Proxy auth

GitHub example

See the proxy-auth example on GitHub for an example that implements this pattern.

The simplest pattern is to authorise Shape requests using a reverse-proxy.

The proxy can be your API, or a seperate proxy service or edge-function. When you make a request to sync a shape, route it via your API/proxy, validate the user credentials and shape parameters, and then only proxy the data through if authorized.

For example:

  1. add an Authorization header to your GET /v1/shape request
  2. use the header to check that the client exists and has access to the shape
  3. if not, return a 401 or 403 status to tell the client it doesn't have access
  4. if the client does have access, proxy the request to Electric and stream the response back to the client

Example

When using the Typescript client, you can pass in a headers option to add an Authorization header.

tsx
const usersShape = (): ShapeStreamOptions => {
  const user = loadCurrentUser()

  return {
    url: new URL(`/api/shapes/users`, window.location.origin).href,
    headers: {
      authorization: `Bearer ${user.token}`
    }
  }
}

export default function ExampleComponent () {
  const { data: users } = useShape(usersShape())
}

Then for the /api/shapes/users route:

tsx
export async function GET(
  request: Request,
) {
  const url = new URL(request.url)

  // Construct the upstream URL
  const originUrl = new URL(`http://localhost:3000/v1/shape`)

  // Copy over the relevant query params that the Electric client adds
  // so that we return the right part of the Shape log.
  url.searchParams.forEach((value, key) => {
    if ([`live`, `table`, `handle`, `offset`, `cursor`].includes(key)) {
      originUrl.searchParams.set(key, value)
    }
  })

  //
  // Authentication and authorization
  //

  const user = await loadUser(request.headers.get(`authorization`))

  // If the user isn't set, return 401
  if (!user) {
    return new Response(`user not found`, { status: 401 })
  }

  // Only query data the user has access to unless they're an admin.
  if (!user.roles.includes(`admin`)) {
    originUrl.searchParams.set(`where`, `"org_id" = ${user.org_id}`)
  }

  // When proxying long-polling requests, content-encoding &
  // content-length are added erroneously (saying the body is
  // gzipped when it's not) so we'll just remove them to avoid
  // content decoding errors in the browser.
  //
  // Similar-ish problem to https://github.com/wintercg/fetch/issues/23
  let resp = await fetch(originUrl.toString())
  if (resp.headers.get(`content-encoding`)) {
    const headers = new Headers(resp.headers)
    headers.delete(`content-encoding`)
    headers.delete(`content-length`)
    resp = new Response(resp.body, {
      status: resp.status,
      statusText: resp.statusText,
      headers,
    })
  }
  return resp
}

Gatekeeper auth

GitHub example

See the gatekeeper-auth example on GitHub for an example that implements this pattern.

The Gatekeeper pattern works as follows:

  1. post to a gatekeeper endpoint in your API to generate a shape-scoped auth token
  2. make shape requests to Electric via an authorising proxy that validates the auth token against the request parameters

The auth token should include a claim containing the shape definition. This allows the proxy to authorize the shape request by comparing the shape claim signed into the token with the shape defined in the request parameters.

This keeps your main auth logic:

  • in your API (in the gatekeeper endpoint) where it's natural to do things like query the database and call external services
  • running once when generating a token, rather than on the "hot path" of every shape request in your authorising proxy

Implementation

The GitHub example provides an ./api service for generating auth tokens and three options for validating those auth tokens when proxying requests to Electric:

  1. ./api the API itself
  2. ./caddy a Caddy web server as a reverse proxy
  3. ./edge an edge function that you can run in front of a CDN

The API is an Elixir/Phoenix web application that exposes two endpoints:

  1. a gatekeeper endpoint at POST /gatekeeper/:table
  2. a proxy endpoint at GET /proxy/v1/shape
Illustration of the gatekeeper request flow
Gatekeeper endpoint
  1. the user makes a POST request to POST /gatekeeper/:table with some authentication credentials and a shape definition in the request parameters; the gatekeeper is then responsible for authorising the user's access to the shape
  2. if access is granted, the gatekeeper generates a shape-scoped auth token and returns it to the client
  3. the client can then use the auth token when connecting to the Electric HTTP API, via the proxy endpoint
Proxy endpoint
  1. the proxy validates the JWT and verifies that the shape claim in the token matches the shape being requested; if so it sends the request on to Electric
  2. Electric then handles the request as normal
  3. sending a response back through the proxy to the client

The client can then process the data and make additional requests using the same token (step 3). If the token expires or is rejected, the client starts again (step 1).

Interactive walkthrough

See How to run on GitHub for an interactive walkthrough of the three different gatekeeper-auth example proxy options.

Example

See the ./client for an example using the Typescript client with gatekeeper and proxy endpoints:

typescript
import { Shape, ShapeStream } from '@electric-sql/client'

const API_URL = process.env.API_URL || "http://localhost:4000"

interface Definition {
  table: string,
  where?: string,
  columns?: string
}

/*
 * Fetch the shape options and start syncing. When new data is recieved,
 * log the number of rows. When an auth token expires, reconnect.
 */
async function sync(definition: Definition, handle?: string, offset: string = '-1') {
  console.log('sync: ', offset)

  const options = await fetchShapeOptions(definition)
  const stream = new ShapeStream({...options, handle: handle, offset: offset})
  const shape = new Shape(stream)

  shape.subscribe(async ({ rows }) => {
    if (shape.error && 'status' in shape.error) {
      const status = shape.error.status
      console.warn('fetch error: ', status)

      if (status === 401 || status === 403) {
        shape.unsubscribeAll()

        return await sync(definition, shape.handle, shape.lastOffset)
      }
    }
    else {
      console.log('num rows: ', rows ? rows.length : 0)
    }
  })
}

/*
 * Make a request to the gatekeeper endpoint to get the proxy url and
 * auth headers to connect to/with.
 */
async function fetchShapeOptions(definition: Definition) {
  const { table, ...params} = definition

  const qs = new URLSearchParams(params).toString()
  const url = `${API_URL}/gatekeeper/${table}${qs ? '?' : ''}${qs}`

  const resp = await fetch(url, {method: "POST"})
  return await resp.json()
}

// Start syncing.
await sync({table: 'items'})

Notes

External services

Both proxy and gatekeeper patterns work well with external auth services.

If you're using an external authentication service, such as Auth0, to generate user credentials, for example, to generate a JWT, you just need to make sure that you can decode the JWT in your proxy or gatekeeper endpoint.

If you're using an external authorization service to authorize a user's access to a shape, then you can call this whereever you run your authorization logic. For proxy auth this is the proxy. For gatekeeper auth this is the gatekeeper endpoint.

Note that if you're using a distributed auth service to ensure consistent distributed auth, such as Authzed, then this works best with the proxy auth pattern. This is because you explicitly want to authorize the user each shape request, as opposed to the gatekeeper generating a token that can potentially become stale.

CDN <-> Proxy

If you're deploying Electric behind a CDN, then it's best to run your authorising proxy at the edge, between your CDN and your user. Both proxy and gatekeeper patterns work well for this.

The gatekeeper pattern is ideal because it minimises the logic that your proxy needs to perform at the edge and minimises the network and database access that you need to provide to your edge worker. See the edge function proxy option in the gatekeeper example for an example designed to run at the edge on Supabase Edge Functions.