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.
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.
// 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
Authenticate user
Use your preferred provider SDK to log the user in and retrieve a JWT.
Register token provider
Call tgClient.setAuthTokenProvider(async () => token). The client calls this on every AUTH_REQUIRED message.
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
JWT_SECRET with the Clerk RSA public key.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;
} 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
# 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
@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.
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
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 });
} 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.
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' },
);
} 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.
| Provider | Algorithm | JWT_SECRET value |
|---|---|---|
| Custom / self-hosted | HS256 | Shared secret string |
| Clerk | RS256 | RSA public key (PEM) |
| Firebase | RS256 | RSA public key (PEM) |
| Auth0 | RS256 | RSA 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:
{
"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.setAuthTokenProvideris called at that point to obtain a fresh token. - Returning
nullfrom 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
AUTH_REQUIREDAUTH + JWT tokenAUTH_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