Skip to content

Authentication

In a collaborative editing system, authentication is not just about knowing who a user is — it determines what documents they can access, what operations they can perform, and how their actions are attributed. Unlike a traditional web app where a session cookie gates access to pages, a sync server must authorize every individual message: each keystroke, cursor move, or file upload passes through a permission check.

Teleportal provides two approaches: a built-in JWT token system with IAM-like document access patterns, or a bring-your-own-auth hook for integrating with your existing identity provider.

You can bypass the built-in token system entirely by implementing your own logic in the onUpgrade and checkPermission hooks. This is useful when you already have session management (cookies, OAuth tokens, API keys) and don’t want to introduce a second token format.

The onUpgrade hook runs when a WebSocket connection is first established. It receives the HTTP request and returns a context object that is attached to the connection for its lifetime:

const handlers = getWebsocketHandlers({
onUpgrade: async (request) => {
// Use your existing session/auth system
const user = await verifySession(request);
if (!user) throw new Response("Unauthorized", { status: 401 });
return { context: { userId: user.id, role: user.role } };
},
});

The checkPermission hook runs before every message is processed. It receives the connection context (from onUpgrade), the target document or file, and the message itself:

const server = new Server({
storage: async (ctx) => documentStorage,
checkPermission: async ({ context, documentId, fileId, message, rpcMethod }) => {
// Derive read/write intent from the MESSAGE, not the `type` argument.
// (For inbound client messages `type` is always "write" — see the caveat below.)
const isWrite =
message.type === "doc" &&
(message.payload.type === "sync-step-2" || message.payload.type === "update");
return await yourAuthService.canAccess(context.userId, documentId, isWrite ? "write" : "read");
},
});

This two-hook design separates identity verification (once, at connection time) from authorization (continuously, per message).

For applications that need a self-contained permission model, Teleportal provides a JWT-based token manager with document-level access control patterns.

The token lifecycle:

  1. Your application server creates a token using TokenManager, encoding the user’s identity and document access rules
  2. The client passes this token when connecting (via query parameter or Authorization header)
  3. The onUpgrade hook verifies the token signature, expiration, and claims
  4. The checkPermission hook checks the token’s document access patterns against each incoming message

Each token contains:

{
userId: string; // User identifier
room: string; // Room/organization identifier
documentAccess?: [ // Document access patterns (OPTIONAL — see gotcha below)
{
pattern: string; // Document pattern (`*` is the only wildcard)
permissions: Permission[];
}
];
exp?: number; // Expiration time (Unix timestamp) — set + enforced by verifyToken
iat?: number; // Issued at time (Unix timestamp)
iss?: string; // Issuer — set + enforced
aud?: string; // Audience (default: "teleportal") — set + enforced
}

The room field scopes the token to a single room or organization, preventing a token issued for one tenant from being used in another.

  • read: View document content and receive awareness updates
  • write: Modify document content
  • comment: Add comments to documents
  • suggest: Make suggestions for document changes
  • admin: Full access to all operations (supersedes other permissions)
import { createTokenManager } from "teleportal/token";
const tokenManager = createTokenManager({
secret: "your-secret-key-here",
expiresIn: 3600, // 1 hour
issuer: "my-collaborative-app",
});
// Token with explicit access patterns
const token = await tokenManager.createToken("user-123", "org-456", [
{ pattern: "shared/*", permissions: ["read", "comment"] },
{ pattern: "user-123/*", permissions: ["read", "write", "admin"] },
]);
// Admin token (full access to all documents in the room)
const adminToken = await tokenManager.createAdminToken("admin-789", "org-456");
const result = await tokenManager.verifyToken(token);
if (result.valid && result.payload) {
// Check a specific permission against a document
const canRead = tokenManager.hasDocumentPermission(result.payload, "user-123/document1", "read");
// Get all permissions the token grants for a document
const permissions = tokenManager.getDocumentPermissions(result.payload, "user-123/document1");
}

The permission system uses pattern matching to map tokens to documents, similar to IAM policy resources. This avoids needing to enumerate every document in the token.

* is the only wildcard — it matches any run of characters (including none). Every other character is matched literally. Patterns are compiled to an anchored RegExp with all regex metacharacters escaped, so a pattern like logs[prod]* matches the literal text logs[prod]… and is never treated as a regex character class. This escaping is a security boundary: neither patterns nor document names can inject regex syntax.

pattern: "document1";
// Matches: "document1" only
pattern: "user/*";
// Matches: "user/doc1", "user/doc2", "user/project/doc3"
pattern: "*";
// Matches: any document name
pattern: "*.md";
// Matches: "readme.md", "document.md"

Patterns compose naturally with the permission system: a token can grant read to shared/* and admin to user-123/*, giving fine-grained control without per-document token issuance.

For complex permission scenarios, the DocumentAccessBuilder provides a fluent API to construct DocumentAccess[] arrays:

import { DocumentAccessBuilder } from "teleportal/token";
// Basic allow/deny
const access = new DocumentAccessBuilder()
.allow("user/*", ["read", "write"])
.deny("private/*")
.build();
// Permission convenience methods
const access = new DocumentAccessBuilder()
.readOnly("public/*")
.write("user/*")
.fullAccess("admin/*")
.admin("super-admin/*")
.build();
// User-owned documents + custom patterns
const access = new DocumentAccessBuilder()
.ownDocuments("user-123")
.allow("shared/*", ["read", "write"])
.allow("projects/my-project/*", ["read", "write"])
.build();
// Complex patterns with exclusions
const access = new DocumentAccessBuilder()
.allowAll(["read", "write"])
.deny("private/*")
.deny("*.secret")
.ownDocuments("user-456", ["read", "write", "comment", "suggest", "admin"])
.allow("projects/important-project/*", ["read", "write", "comment", "suggest"])
.admin("system/*")
.build();

Here is a complete example integrating the token manager with the WebSocket server:

import { getWebsocketHandlers } from "teleportal/websocket-server";
import { Server } from "teleportal/server";
import { createTokenManager } from "teleportal/token";
const tokenManager = createTokenManager({
secret: "your-secret-key",
expiresIn: 3600,
});
const server = new Server({
storage: async (ctx) => documentStorage,
checkPermission: async ({ context, documentId, fileId, message, type }) => {
const token = (context as any).token;
if (!token) return false;
const result = await tokenManager.verifyToken(token);
if (!result.valid || !result.payload) return false;
// Verify room scope
if (result.payload.room !== context.room) return false;
// Check document permissions
if (documentId) {
const requiredPermission = message.type === "awareness" ? "read" : "write";
return tokenManager.hasDocumentPermission(result.payload, documentId, requiredPermission);
}
return true;
},
});
const handlers = getWebsocketHandlers({
onUpgrade: async (request) => {
const url = new URL(request.url);
const authHeader = request.headers.get("authorization");
const token =
url.searchParams.get("token") ||
(authHeader && /^bearer\s+/i.test(authHeader) ? authHeader.replace(/^bearer\s+/i, "") : null);
if (!token) {
throw new Response("No token provided", { status: 401 });
}
const result = await tokenManager.verifyToken(token);
if (!result.valid || !result.payload) {
throw new Response("Invalid token", { status: 401 });
}
return {
context: {
userId: result.payload.userId,
room: result.payload.room,
token,
},
};
},
});
  1. Use strong secrets (≥256 bits): Generate cryptographically secure random secrets for signing tokens. jose does not enforce a minimum HMAC key length — that is on you.
  2. Algorithm is pinned to HS256: Tokens are always signed and verified with HS256 (symmetric HMAC-SHA-256). verifyToken pins algorithms: ["HS256"], so HS384/HS512 tokens and unsecured alg: "none" tokens are rejected.
  3. Always attach a documentAccess policy: A token minted without one fails open and grants room-wide admin access (see the danger callout above). Use createAdminToken only when you actually mean room-wide access.
  4. Set appropriate expiration: Short-lived tokens limit the window of compromise. Prefer tokens that expire in minutes to hours, not days.
  5. Validate room access: hasDocumentPermission ignores payload.room — you must compare the token’s room claim to the connection’s room yourself.
  6. Check permissions on every message: The checkPermission hook runs per message by design — do not cache authorization results, as tokens may have been revoked. Remember its type argument is always "write"; derive read/write intent from the message.
  7. Use HTTPS/WSS: Always use secure connections in production to prevent token interception.
  8. Rotate secrets: Regularly rotate your JWT signing secrets.
  • Server - How the server uses authentication for permission enforcement
  • Guides: Authentication - Step-by-step setup guide
  • Attribution - How userId from authentication flows into authorship tracking