Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/tldraw/tldraw/llms.txt

Use this file to discover all available pages before exploring further.

Authentication

Learn how to add authentication and authorization to your multiplayer rooms to control access and identify users.

Overview

Authentication for multiplayer involves:
  1. Client authentication - Passing credentials when connecting
  2. Server validation - Verifying credentials and establishing identity
  3. Access control - Enforcing read/write permissions
  4. Session management - Tracking authenticated users

Client-side authentication

Static tokens

Pass authentication tokens in the WebSocket URL:
import { useSync } from '@tldraw/sync'

function MyApp({ roomId, authToken }: { roomId: string; authToken: string }) {
  const store = useSync({
    uri: `wss://sync.example.com/room/${roomId}?token=${authToken}`,
    assets: myAssetStore
  })
  
  return <Tldraw store={store.store} />
}

Dynamic token refresh

Use a function to fetch tokens dynamically:
import { useSync } from '@tldraw/sync'

function MyApp({ roomId }: { roomId: string }) {
  const store = useSync({
    uri: async () => {
      // Fetch fresh token (e.g., from your auth API)
      const response = await fetch('/api/auth/token', {
        credentials: 'include'
      })
      const { token } = await response.json()
      
      return `wss://sync.example.com/room/${roomId}?token=${token}`
    },
    assets: myAssetStore
  })
  
  return <Tldraw store={store.store} />
}
The URI function is called on each connection attempt, allowing automatic token refresh when reconnecting.

JWT authentication

import { useSync } from '@tldraw/sync'
import jwt from 'jsonwebtoken'

function MyApp({ roomId, userId }: { roomId: string; userId: string }) {
  const store = useSync({
    uri: async () => {
      // Generate JWT on client (or fetch from your backend)
      const token = jwt.sign(
        { userId, roomId, exp: Math.floor(Date.now() / 1000) + 3600 },
        process.env.NEXT_PUBLIC_JWT_SECRET!
      )
      
      return `wss://sync.example.com/room/${roomId}?token=${token}`
    },
    assets: myAssetStore,
    userInfo: {
      id: userId,
      name: 'User Name',
      color: '#ff0000'
    }
  })
  
  return <Tldraw store={store.store} />
}

Server-side validation

Basic token validation

import { WebSocketServer } from 'ws'
import { TLSocketRoom } from '@tldraw/sync-core'
import { createTLSchema } from '@tldraw/tlschema'

const wss = new WebSocketServer({ port: 8080 })
const rooms = new Map<string, TLSocketRoom>()

wss.on('connection', (ws, req) => {
  const url = new URL(req.url!, `ws://${req.headers.host}`)
  const roomId = url.pathname.split('/').pop()!
  const token = url.searchParams.get('token')
  
  // Validate token
  if (!isValidToken(token)) {
    ws.close(4403, 'Unauthorized')
    return
  }
  
  const userId = getUserIdFromToken(token!)
  
  // Get or create room
  let room = rooms.get(roomId)
  if (!room) {
    room = new TLSocketRoom({
      schema: createTLSchema(),
      onSessionRemoved: (room, { numSessionsRemaining }) => {
        if (numSessionsRemaining === 0) {
          room.close()
          rooms.delete(roomId)
        }
      }
    })
    rooms.set(roomId, room)
  }
  
  // Connect with user metadata
  room.handleSocketConnect({
    sessionId: url.searchParams.get('sessionId')!,
    socket: ws,
    meta: { userId },
    isReadonly: false
  })
})

function isValidToken(token: string | null): boolean {
  // Implement your token validation logic
  return token != null && token.length > 0
}

function getUserIdFromToken(token: string): string {
  // Extract user ID from token
  return 'user-' + token
}

JWT validation

import jwt from 'jsonwebtoken'
import { TLSyncErrorCloseEventReason } from '@tldraw/sync-core'

interface JWTPayload {
  userId: string
  roomId: string
  exp: number
}

wss.on('connection', (ws, req) => {
  const url = new URL(req.url!, `ws://${req.headers.host}`)
  const roomId = url.pathname.split('/').pop()!
  const token = url.searchParams.get('token')
  
  if (!token) {
    ws.close(4401, TLSyncErrorCloseEventReason.NOT_AUTHENTICATED)
    return
  }
  
  try {
    const payload = jwt.verify(
      token, 
      process.env.JWT_SECRET!
    ) as JWTPayload
    
    // Verify room access
    if (payload.roomId !== roomId) {
      ws.close(4403, TLSyncErrorCloseEventReason.FORBIDDEN)
      return
    }
    
    // Connect user to room
    room.handleSocketConnect({
      sessionId: url.searchParams.get('sessionId')!,
      socket: ws,
      meta: { 
        userId: payload.userId,
        authenticatedAt: Date.now()
      },
      isReadonly: false
    })
  } catch (error) {
    ws.close(4403, TLSyncErrorCloseEventReason.NOT_AUTHENTICATED)
  }
})

Access control

Read-only access

Restrict certain users to read-only mode:
wss.on('connection', (ws, req) => {
  const url = new URL(req.url!, `ws://${req.headers.host}`)
  const token = url.searchParams.get('token')
  const userId = getUserIdFromToken(token!)
  
  // Check permissions
  const hasWriteAccess = await checkWritePermission(userId, roomId)
  
  room.handleSocketConnect({
    sessionId: url.searchParams.get('sessionId')!,
    socket: ws,
    meta: { userId },
    isReadonly: !hasWriteAccess  // Enforce read-only
  })
})

async function checkWritePermission(
  userId: string, 
  roomId: string
): Promise<boolean> {
  // Query your database for permissions
  const permissions = await db.query(
    'SELECT can_edit FROM room_permissions WHERE user_id = ? AND room_id = ?',
    [userId, roomId]
  )
  return permissions[0]?.can_edit ?? false
}

Room ownership

Enforce room ownership and access lists:
interface RoomMetadata {
  ownerId: string
  collaborators: string[]
  isPublic: boolean
}

const roomMetadata = new Map<string, RoomMetadata>()

wss.on('connection', async (ws, req) => {
  const url = new URL(req.url!, `ws://${req.headers.host}`)
  const roomId = url.pathname.split('/').pop()!
  const token = url.searchParams.get('token')
  const userId = getUserIdFromToken(token!)
  
  // Load room metadata
  const metadata = roomMetadata.get(roomId)
  if (!metadata) {
    ws.close(4404, TLSyncErrorCloseEventReason.NOT_FOUND)
    return
  }
  
  // Check access
  const hasAccess = 
    metadata.isPublic ||
    metadata.ownerId === userId ||
    metadata.collaborators.includes(userId)
  
  if (!hasAccess) {
    ws.close(4403, TLSyncErrorCloseEventReason.FORBIDDEN)
    return
  }
  
  // Connect with appropriate permissions
  const isOwner = metadata.ownerId === userId
  
  room.handleSocketConnect({
    sessionId: url.searchParams.get('sessionId')!,
    socket: ws,
    meta: { userId, isOwner },
    isReadonly: !isOwner && !metadata.collaborators.includes(userId)
  })
})

Rate limiting

Protect your server from abuse:
import { TLSyncErrorCloseEventReason } from '@tldraw/sync-core'

const connectionCounts = new Map<string, number>()
const MAX_CONNECTIONS_PER_USER = 5

wss.on('connection', (ws, req) => {
  const url = new URL(req.url!, `ws://${req.headers.host}`)
  const token = url.searchParams.get('token')
  const userId = getUserIdFromToken(token!)
  
  // Check rate limit
  const currentConnections = connectionCounts.get(userId) || 0
  if (currentConnections >= MAX_CONNECTIONS_PER_USER) {
    ws.close(4429, TLSyncErrorCloseEventReason.RATE_LIMITED)
    return
  }
  
  // Track connection
  connectionCounts.set(userId, currentConnections + 1)
  
  ws.on('close', () => {
    const count = connectionCounts.get(userId) || 0
    if (count <= 1) {
      connectionCounts.delete(userId)
    } else {
      connectionCounts.set(userId, count - 1)
    }
  })
  
  // ... connect to room
})

Session tracking

Track active sessions and user activity:
interface SessionInfo {
  userId: string
  connectedAt: number
  lastActivity: number
  ipAddress: string
}

const activeSessions = new Map<string, SessionInfo>()

const room = new TLSocketRoom({
  schema: createTLSchema(),
  
  onSessionRemoved: (room, { sessionId, meta }) => {
    // Log session end
    const session = activeSessions.get(sessionId)
    if (session) {
      const duration = Date.now() - session.connectedAt
      console.log(`Session ${sessionId} ended after ${duration}ms`)
      activeSessions.delete(sessionId)
    }
    
    // Cleanup room if empty
    if (room.getNumActiveSessions() === 0) {
      room.close()
    }
  }
})

wss.on('connection', (ws, req) => {
  const sessionId = crypto.randomUUID()
  const userId = getUserIdFromToken(token!)
  
  // Track session
  activeSessions.set(sessionId, {
    userId,
    connectedAt: Date.now(),
    lastActivity: Date.now(),
    ipAddress: req.socket.remoteAddress!
  })
  
  room.handleSocketConnect({
    sessionId,
    socket: ws,
    meta: { userId },
    isReadonly: false
  })
})

Complete example

Full authentication implementation:
import { WebSocketServer } from 'ws'
import { TLSocketRoom, TLSyncErrorCloseEventReason } from '@tldraw/sync-core'
import { createTLSchema } from '@tldraw/tlschema'
import jwt from 'jsonwebtoken'

interface JWTPayload {
  userId: string
  roomId: string
  permissions: string[]
}

const wss = new WebSocketServer({ port: 8080 })
const rooms = new Map<string, TLSocketRoom>()

wss.on('connection', async (ws, req) => {
  try {
    const url = new URL(req.url!, `ws://${req.headers.host}`)
    const roomId = url.pathname.split('/').pop()!
    const token = url.searchParams.get('token')
    const sessionId = url.searchParams.get('sessionId')!
    
    // Validate token
    if (!token) {
      ws.close(4401, TLSyncErrorCloseEventReason.NOT_AUTHENTICATED)
      return
    }
    
    // Verify JWT
    const payload = jwt.verify(
      token,
      process.env.JWT_SECRET!
    ) as JWTPayload
    
    // Verify room access
    if (payload.roomId !== roomId) {
      ws.close(4403, TLSyncErrorCloseEventReason.FORBIDDEN)
      return
    }
    
    // Get or create room
    let room = rooms.get(roomId)
    if (!room) {
      room = new TLSocketRoom({
        schema: createTLSchema(),
        onSessionRemoved: (room, { numSessionsRemaining }) => {
          if (numSessionsRemaining === 0) {
            room.close()
            rooms.delete(roomId)
          }
        }
      })
      rooms.set(roomId, room)
    }
    
    // Connect with permissions
    const canEdit = payload.permissions.includes('write')
    
    room.handleSocketConnect({
      sessionId,
      socket: ws,
      meta: { 
        userId: payload.userId,
        permissions: payload.permissions
      },
      isReadonly: !canEdit
    })
    
    console.log(`User ${payload.userId} connected to room ${roomId}`)
  } catch (error) {
    console.error('Authentication error:', error)
    ws.close(4403, TLSyncErrorCloseEventReason.NOT_AUTHENTICATED)
  }
})

console.log('Authenticated sync server running on port 8080')

Next steps

Deployment

Deploy your authenticated server to production

Customization

Customize sync behavior and presence

Server API

Complete server API reference