# Auth

LiveStore doesn't include built-in authentication or authorization support, but you can implement it in your app's logic.

## Pass an auth payload to the sync backend

Use the `syncPayload` store option to send a custom payload to your sync backend.

### Example

The following example sends the authenticated user's JWT to the server.


## `patterns/auth/store-with-auth.tsx`

```tsx filename="patterns/auth/store-with-auth.tsx"
import { Suspense, useState } from 'react'
import { unstable_batchedUpdates as batchUpdates } from 'react-dom'

import { makeInMemoryAdapter } from '@livestore/adapter-web'
import { type LiveStoreSchema, StoreRegistry } from '@livestore/livestore'
import { StoreRegistryProvider, useStore } from '@livestore/react'

const schema = {} as LiveStoreSchema
const storeId = 'demo-store'
const user = { jwt: 'user-token' }
const adapter = makeInMemoryAdapter()
const suspenseFallback = <div>Loading...</div>

// ---cut---
const useAppStore = () =>
  useStore({
    storeId,
    schema,
    adapter,
    batchUpdates,
    syncPayload: {
      authToken: user.jwt, // Using a JWT
    },
  })

export const App = () => {
  const [storeRegistry] = useState(() => new StoreRegistry())
  return (
    <Suspense fallback={suspenseFallback}>
      <StoreRegistryProvider storeRegistry={storeRegistry}>
        <AppContent />
      </StoreRegistryProvider>
    </Suspense>
  )
}

const AppContent = () => {
  const _store = useAppStore()
  // Use the store in your components
  return <div>{/* Your app content */}</div>
}
```

On the sync server, validate the token and allow or reject the sync based on the result. See the following example:


## `patterns/auth/pass-auth-payload.ts`

```ts filename="patterns/auth/pass-auth-payload.ts"
import * as jose from 'jose'

import { makeDurableObject, makeWorker } from '@livestore/sync-cf/cf-worker'

const JWT_SECRET = 'a-string-secret-at-least-256-bits-long'

export class SyncBackendDO extends makeDurableObject({
  onPush: async (message) => {
    console.log('onPush', message.batch)
  },
  onPull: async (message) => {
    console.log('onPull', message)
  },
}) {}

export default makeWorker({
  syncBackendBinding: 'SYNC_BACKEND_DO',
  validatePayload: async (payload: any, context) => {
    const { storeId } = context
    const { authToken } = payload

    if (authToken == null) {
      throw new Error('No auth token provided')
    }

    const user = await getUserFromToken(authToken)

    if (user == null) {
      throw new Error('Invalid auth token')
    } else {
      // User is authenticated!
      console.log('Sync backend payload', JSON.stringify(user, null, 2))
    }

    // Check if token is expired
    if (payload.exp !== undefined && payload.exp < Date.now() / 1000) {
      throw new Error('Token expired')
    }

    await checkUserAccess(user, storeId)
  },
  enableCORS: true,
})

const getUserFromToken = async (token: string): Promise<jose.JWTPayload | undefined> => {
  try {
    const { payload } = await jose.jwtVerify(token, new TextEncoder().encode(JWT_SECRET))
    return payload
  } catch (error) {
    console.log('⚠️ Error verifying token', error)
    return undefined
  }
}

const checkUserAccess = async (payload: jose.JWTPayload, storeId: string): Promise<void> => {
  // Check if user is authorized to access the store
  console.log('Checking access for store', storeId, 'with payload', payload)
}
```

The above example uses [`jose`](https://www.npmjs.com/package/jose), a popular JavaScript module that supports JWTs. It works across various runtimes, including Node.js, Cloudflare Workers, Deno, Bun, and others.

The `validatePayload` function receives the `authToken`, checks if the payload exists, and verifies that it's valid and hasn't expired. If all checks pass, sync continues as normal. If any check fails, the server rejects the sync.

The client app still works as expected, but saves data locally. If the user re-authenticates or refreshes the token later, LiveStore syncs any local changes made while the user was unauthenticated.

## Re-validate payload inside the Durable Object

When you rely on `syncPayload`, treat it as untrusted input. Decode the token inside `validatePayload` to gate the connection, and then repeat the same verification inside the Durable Object before trusting per-push metadata.


## `patterns/auth/keep-payload-canonical.ts`

```ts filename="patterns/auth/keep-payload-canonical.ts"
import { makeDurableObject, makeWorker } from '@livestore/sync-cf/cf-worker'
import type { SyncMessage } from '@livestore/sync-cf/common'

import { verifyJwt } from './verify-jwt.ts'

// ---cut---
type SyncPayload = { authToken?: string; userId?: string }

type AuthorizedSession = {
  authToken: string
  userId: string
}

const ensureAuthorized = (payload: unknown): AuthorizedSession => {
  if (payload === undefined || payload === null || typeof payload !== 'object') {
    throw new Error('Missing auth payload')
  }

  const { authToken, userId } = payload as SyncPayload
  if (authToken == null) {
    throw new Error('Missing auth token')
  }

  const claims = verifyJwt(authToken)
  if (claims.sub == null) {
    throw new Error('Token missing subject claim')
  }

  if (userId !== undefined && userId !== claims.sub) {
    throw new Error('Payload userId mismatch')
  }

  return { authToken, userId: claims.sub }
}

export default makeWorker({
  syncBackendBinding: 'SYNC_BACKEND_DO',
  validatePayload: (payload) => {
    ensureAuthorized(payload)
  },
})

export class SyncBackendDO extends makeDurableObject({
  onPush: async (message: SyncMessage.PushRequest, { payload }) => {
    const { userId } = ensureAuthorized(payload)
    await ensureTenantAccess(userId, message.batch)
  },
}) {}

const ensureTenantAccess = async (_userId: string, _batch: SyncMessage.PushRequest['batch']) => {
  // Replace with your application-specific access checks.
}
```

### `patterns/auth/verify-jwt.ts`

```ts filename="patterns/auth/verify-jwt.ts"
export type Claims = {
  sub?: string
}

export const verifyJwt = (token: string): Claims => {
  if (token.length === 0) {
    throw new Error('Missing token')
  }

  // Replace with real JWT verification (e.g. via `jose`)
  return { sub: token }
}
```

- `validatePayload` runs once per connection and rejects mismatched tokens before LiveStore upgrades to WebSocket.
- `onPush` (and `onPull`, if you need it) must repeat the verification because the payload forwarded to the Durable Object is the original client input.
- The HTTP transport does not forward payloads today; embed the necessary authorization context directly in the events or move those clients to WebSocket/DO-RPC if you must rely on shared payload metadata.

You can extend `ensureAuthorized` to project additional claims, memoise verification per `authToken`, or enforce application-specific policies without changing LiveStore internals.

## Cookie-based authentication

If you prefer cookie-based authentication (e.g., with [better-auth](https://www.better-auth.com/)), you can forward HTTP headers to your `onPush` and `onPull` callbacks using the `forwardHeaders` option.

### Why forward headers?

Passing tokens in URL parameters (`syncPayload`) exposes them in browser history, server logs, and referrer headers. Cookie-based auth avoids these issues since cookies are sent automatically with each request and aren't logged in URLs.

### Example

The following example forwards `Cookie` and `Authorization` headers to the Durable Object callbacks:


## `patterns/auth/cookie-auth.ts`

```ts filename="patterns/auth/cookie-auth.ts"
import { makeDurableObject, makeWorker } from '@livestore/sync-cf/cf-worker'

export class SyncBackendDO extends makeDurableObject({
  // Forward Cookie and Authorization headers to onPush/onPull callbacks
  forwardHeaders: ['Cookie', 'Authorization'],

  onPush: async (message, context) => {
    const { storeId, headers } = context

    // Access forwarded headers in callbacks
    const cookie = headers?.get('cookie')
    const _authorization = headers?.get('authorization')

    if (cookie != null) {
      // Parse session from cookie (example with better-auth)
      const sessionToken = parseCookie(cookie, 'session_token')
      const session = await getSessionFromToken(sessionToken)

      if (session == null) {
        throw new Error('Invalid session')
      }

      console.log('Push from user:', session.userId, 'store:', storeId)
    }

    console.log('onPush', message.batch)
  },

  onPull: async (message, context) => {
    const { storeId, headers } = context

    // Same header access in onPull
    const cookie = headers?.get('cookie')

    if (cookie != null) {
      const sessionToken = parseCookie(cookie, 'session_token')
      const session = await getSessionFromToken(sessionToken)

      if (session == null) {
        throw new Error('Invalid session')
      }

      console.log('Pull from user:', session.userId, 'store:', storeId)
    }

    console.log('onPull', message)
  },
}) {}

export default makeWorker({
  syncBackendBinding: 'SYNC_BACKEND_DO',
  // Optional: validate at worker level using headers
  validatePayload: async (_payload, context) => {
    const { headers } = context
    const cookie = headers.get('cookie')

    if (cookie != null) {
      const sessionToken = parseCookie(cookie, 'session_token')
      const session = await getSessionFromToken(sessionToken)

      if (session == null) {
        throw new Error('Unauthorized: Invalid session')
      }
    }
  },
  enableCORS: true,
})

// --- Helper functions (implement based on your auth library) ---

const parseCookie = (cookieHeader: string, name: string): string | undefined => {
  const cookies = cookieHeader.split(';').map((c) => c.trim())
  for (const cookie of cookies) {
    const [key, value] = cookie.split('=')
    if (key === name) return value
  }
  return undefined
}

interface Session {
  userId: string
  email: string
}

const getSessionFromToken = async (_token: string | undefined): Promise<Session | null> => {
  // Implement session lookup using your auth library
  // Example with better-auth:
  // return await auth.api.getSession({ headers: { cookie: `session_token=${token}` } })
  return { userId: 'user-123', email: 'user@example.com' }
}
```

### How it works

1. **Configure `forwardHeaders`** in `makeDurableObject()` to specify which headers to forward.
2. **Headers are stored** in the WebSocket attachment during connection upgrade, surviving hibernation.
3. **Access headers** via `context.headers` in `onPush` and `onPull` callbacks.
4. **Worker-level validation** can also access headers via `context.headers` in `validatePayload`.

### Custom header extraction

For more control, pass a function to `forwardHeaders`:

```typescript
export class SyncBackendDO extends makeDurableObject({
  forwardHeaders: (request) => ({
    'x-user-id': request.headers.get('x-user-id') ?? '',
    'x-session': request.headers.get('cookie')?.split('session=')[1]?.split(';')[0] ?? '',
  }),
  // ...
}) {}
```

## Client identity vs user identity

LiveStore's `clientId` identifies a client instance, while user identity is an application-level concern that must be modeled through your application's events and logic.

### Key points
- `clientId`: Automatically managed by LiveStore, identifies a client instance
- User identity: Managed by your application through events and syncPayload

### Using syncPayload for authentication

The `syncPayload` is primarily intended for authentication purposes:


## `patterns/auth/store-with-auth.tsx`

```tsx filename="patterns/auth/store-with-auth.tsx"
import { Suspense, useState } from 'react'
import { unstable_batchedUpdates as batchUpdates } from 'react-dom'

import { makeInMemoryAdapter } from '@livestore/adapter-web'
import { type LiveStoreSchema, StoreRegistry } from '@livestore/livestore'
import { StoreRegistryProvider, useStore } from '@livestore/react'

const schema = {} as LiveStoreSchema
const storeId = 'demo-store'
const user = { jwt: 'user-token' }
const adapter = makeInMemoryAdapter()
const suspenseFallback = <div>Loading...</div>

// ---cut---
const useAppStore = () =>
  useStore({
    storeId,
    schema,
    adapter,
    batchUpdates,
    syncPayload: {
      authToken: user.jwt, // Using a JWT
    },
  })

export const App = () => {
  const [storeRegistry] = useState(() => new StoreRegistry())
  return (
    <Suspense fallback={suspenseFallback}>
      <StoreRegistryProvider storeRegistry={storeRegistry}>
        <AppContent />
      </StoreRegistryProvider>
    </Suspense>
  )
}

const AppContent = () => {
  const _store = useAppStore()
  // Use the store in your components
  return <div>{/* Your app content */}</div>
}
```

User identification and semantic data (like user IDs) should typically be handled through your event payloads and application state rather than relying solely on the sync payload.