DocsGuidesAuthentication

Authentication & Security

Authentication and security: connect any JWT-issuing provider (Clerk, BetterAuth, Firebase, or custom) to your TopGunClient instance; the client sends the token to the server on every WebSocket handshake; the server validates it and enforces per-collection RBAC access control. Local-first writes happen before the network authenticates, so offline clients can write immediately and reconcile permissions on reconnect.

Token-based

Standard JWT authentication integrated directly into the sync protocol.

Provider agnostic

Works with Clerk, BetterAuth, Firebase, Auth0, or any JWT-based provider.

Access control

Fine-grained RBAC permissions per collection with pattern matching.

Setup

Create the client once and export it. The storage field accepts new IDBAdapter() (zero-arg) for IndexedDB persistence in the browser.

src/lib/topgun.ts
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';

export const tgClient = new TopGunClient({
  serverUrl: 'ws://localhost:8080',
  storage: new IDBAdapter(),
});

Register a token provider after the client is created. The provider is called on every AUTH_REQUIRED message — which the server sends on initial connection and after any reconnect.

src/lib/topgun.ts
// The provider must return a valid JWT or null.
// Returning null leaves the connection unauthenticated.
tgClient.setAuthTokenProvider(async () => {
  const token = await yourAuthProvider.getToken();
  return token ?? null;
});

await tgClient.start();

How it works

1

Authenticate user

Use your preferred provider SDK to log the user in and retrieve a JWT.

2

Register token provider

Call tgClient.setAuthTokenProvider(async () => token). The client calls this on every AUTH_REQUIRED message.

3

Server validates JWT

The TopGun server verifies the JWT using the configured JWT_SECRET. If valid, the sync connection is established with the user's principal attached.

Clerk integration

Clerk is a popular React-first auth provider. Its JWTs use RS256 — configure JWT_SECRET with the Clerk RSA public key.
src/components/TopGunAuthSync.tsx
import { useEffect } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { tgClient } from '../lib/topgun';

// Mount this once near the root of your app, inside <ClerkProvider>
export function TopGunAuthSync() {
  const { getToken, isSignedIn } = useAuth();

  useEffect(() => {
    if (isSignedIn) {
      tgClient.setAuthTokenProvider(async () => {
        try {
          return await getToken();
        } catch {
          return null;
        }
      });
    }
  }, [isSignedIn, getToken]);

  return null;
}
src/App.tsx
import { ClerkProvider, SignedIn, SignedOut, SignIn } from '@clerk/clerk-react';
import { TopGunProvider } from '@topgunbuild/react';
import { TopGunAuthSync } from './components/TopGunAuthSync';
import { tgClient } from './lib/topgun';

export default function App() {
  return (
    <ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
      <TopGunProvider client={tgClient}>
        <TopGunAuthSync />
        <SignedIn><Dashboard /></SignedIn>
        <SignedOut><SignIn /></SignedOut>
      </TopGunProvider>
    </ClerkProvider>
  );
}

Clerk JWT_SECRET (production)

Clerk uses RSA asymmetric signing. Set JWT_SECRET to the RSA public key from Clerk’s JWKS endpoint:

https://YOUR_CLERK_DOMAIN.clerk.accounts.dev/.well-known/jwks.json
Environment
# Docker / Dokploy — use escaped newlines:
JWT_SECRET="-----BEGIN PUBLIC KEY-----\nMIIBIjAN...AQAB\n-----END PUBLIC KEY-----"

# Shell script — use real newlines:
export JWT_SECRET="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"

The server automatically detects RSA public keys (by checking for -----BEGIN) and uses the RS256 algorithm.


BetterAuth integration

BetterAuth is a framework-agnostic auth library. The @topgunbuild/adapter-better-auth package lets TopGun serve as the BetterAuth database backend.

BetterAuth sessions are not JWTs

BetterAuth uses opaque session cookies by default. TopGun requires a JWT for sync authentication. You must create a server-side bridge endpoint that exchanges a BetterAuth session for a signed JWT.

src/lib/auth.ts
import { betterAuth } from 'better-auth';
import { topGunAdapter } from '@topgunbuild/adapter-better-auth';
import { tgClient } from './topgun';

export const auth = betterAuth({
  database: topGunAdapter({
    client: tgClient,
    modelMap: {
      user: 'auth_user',
      session: 'auth_session',
      account: 'auth_account',
      verification: 'auth_verification',
    },
  }),
  emailAndPassword: { enabled: true },
});

Bridge endpoint: BetterAuth session → TopGun JWT

server/api/topgun-token.ts
import { auth } from '../lib/auth';
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET!;

// POST /api/topgun-token
// Verifies the BetterAuth session cookie and mints a TopGun JWT.
export async function GET(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers });

  if (!session?.user) {
    return new Response('Unauthorized', { status: 401 });
  }

  const token = jwt.sign(
    { sub: session.user.id, roles: session.user.roles ?? ['USER'] },
    JWT_SECRET,
    { expiresIn: '1h' },
  );

  return Response.json({ token });
}
src/lib/topgun-auth.ts
import { tgClient } from './topgun';

let cachedToken: string | null = null;

tgClient.setAuthTokenProvider(async () => {
  if (cachedToken) return cachedToken;
  const res = await fetch('/api/topgun-token', { credentials: 'include' });
  if (!res.ok) return null;
  const { token } = await res.json();
  cachedToken = token;
  return token;
});

export function clearTopGunToken() {
  cachedToken = null; // Call on logout to force re-auth on next reconnect
}

Custom auth provider

If you manage your own JWT issuance (e.g. a custom login endpoint), call setAuthTokenProvider with an async function that returns the token from your session store.

server/auth.ts
import jwt from 'jsonwebtoken';

// Server side: generate a JWT for a verified user
export function generateToken(user: { id: string; roles: string[] }) {
  return jwt.sign(
    { sub: user.id, roles: user.roles },  // sub is the only required claim
    process.env.JWT_SECRET!,
    { expiresIn: '24h' },
  );
}
src/lib/auth.ts
import { tgClient } from './topgun';

let cachedToken: string | null = null;

tgClient.setAuthTokenProvider(async () => {
  if (cachedToken) return cachedToken;
  try {
    const res = await fetch('/api/token', { method: 'POST', credentials: 'include' });
    if (!res.ok) return null;
    const { token } = await res.json();
    cachedToken = token;
    return token;
  } catch {
    return null;
  }
});

export async function login(email: string, password: string) {
  const res = await fetch('/api/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  const { token } = await res.json();
  cachedToken = token;
}

export function logout() {
  cachedToken = null;
}

Firebase integration

Firebase uses RS256 for JWT signing. Obtain the Firebase public key from the Google JWKS endpoint and set it as JWT_SECRET.

ProviderAlgorithmJWT_SECRET value
Custom / self-hostedHS256Shared secret string
ClerkRS256RSA public key (PEM)
FirebaseRS256RSA public key (PEM)
Auth0RS256RSA public key (PEM)

The TopGun server detects RSA keys automatically (by checking for -----BEGIN) and switches to RS256 validation.


JWT token structure

TopGun reads only the sub and roles claims from the JWT payload:

Expected JWT payload
{
  "sub": "user_123",    // User ID — required per RFC 7519
  "roles": ["USER"],    // Array of roles for RBAC (optional)
  "iat": 1699000000,    // Issued at
  "exp": 1699086400     // Expiration
}

Token lifecycle and refresh

  • Active connections are not terminated when a token expires. Once a WebSocket connection is established, token expiry does not disconnect the client. The session continues until the connection drops.
  • Token expiry only matters on reconnect. When the WebSocket closes (page reload, network drop), the server sends AUTH_REQUIRED. setAuthTokenProvider is called at that point to obtain a fresh token.
  • Returning null from the provider leaves the connection unauthenticated. No data operations are permitted in that state.
  • Recommendation: your token provider should call your app’s token or session refresh endpoint rather than relying on a cached token that may be stale after a long offline period.

Authentication protocol

ClientConnect to WebSocket
ServerAUTH_REQUIRED
ClientAUTH + JWT token
ServerVerify JWT, extract principal
ServerAUTH_ACK (success) or close connection (failure)

Next steps

  • RBAC — configure per-collection read/write access control on the server
  • Security (TLS) — enable TLS for the WebSocket connection in production
  • Offline-first apps — understand how authentication interacts with offline writes