Authentication & Security
Secure your local-first application by integrating authentication providers and managing access control.
Token-Based
Standard JWT authentication integrated directly into the sync protocol.
Provider Agnostic
Works with Clerk, Better Auth, Auth0, Firebase, or any JWT-based provider.
Access Control
Fine-grained RBAC permissions per collection with pattern matching.
How it Works
In a local-first architecture, authentication serves two main purposes:
- Gatekeeping Sync: Only authenticated users can connect to the synchronization server (WebSocket).
- Authorization: Ensuring users can only read or write data they are permitted to access.
TopGun decouples the authentication provider from the sync engine. You can use any provider to generate a JWT token. This token is passed to the TopGun client, which sends it to the server during the WebSocket handshake.
Integration Flow
Authenticate User
Use your preferred provider SDK to log the user in and retrieve a session token (JWT).
Set Token in Client
Pass the token to the TopGun client. The client will automatically authenticate the WebSocket connection.
Server Validation
The TopGun server validates the JWT using the configured secret. If valid, the connection is established with the user's principal attached.
Clerk Integration
1. Setup TopGun Client
import { TopGunClient } from '@topgunbuild/client';
import { IDBAdapter } from '@topgunbuild/adapters';
const adapter = new IDBAdapter();
export const tgClient = new TopGunClient({
serverUrl: 'ws://localhost:8090',
storage: adapter
}); 2. Create Auth Sync Component
Use setAuthTokenProvider to automatically refresh tokens when they expire.
import { useEffect } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { tgClient } from '../lib/topgun';
export function TopGunAuthSync() {
const { getToken, isSignedIn } = useAuth();
useEffect(() => {
if (isSignedIn) {
// Set a token provider that refreshes automatically
tgClient.setAuthTokenProvider(async () => {
try {
const token = await getToken();
return token;
} catch (err) {
console.error('Failed to get Clerk token', err);
return null;
}
});
}
}, [isSignedIn, getToken]);
return null;
} 3. Wire Up in App
import { ClerkProvider, SignedIn, SignedOut, SignIn } from '@clerk/clerk-react';
import { TopGunProvider } from '@topgunbuild/react';
import { TopGunAuthSync } from './components/TopGunAuthSync';
import { tgClient } from './lib/topgun';
const CLERK_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
export default function App() {
return (
<ClerkProvider publishableKey={CLERK_KEY}>
<TopGunProvider client={tgClient}>
<TopGunAuthSync />
<SignedIn>
{/* Your authenticated app */}
<Dashboard />
</SignedIn>
<SignedOut>
<SignIn />
</SignedOut>
</TopGunProvider>
</ClerkProvider>
);
} 4. User-Scoped Data
Use the user ID from Clerk to scope data per user:
import { useUser } from '@clerk/clerk-react';
import { useQuery } from '@topgunbuild/react';
export function UserNotes() {
const { user } = useUser();
// Each user gets their own notes collection
const mapName = `notes:${user?.id}`;
const { data: notes } = useQuery(mapName);
return (
<ul>
{notes.map(note => (
<li key={note.id}>{note.title}</li>
))}
</ul>
);
} Better Auth Integration
Note: This approach stores auth data (users, sessions) in TopGun itself, allowing authentication data to be distributed and synced like any other application data.
1. Install the Adapter
npm install @topgunbuild/adapter-better-auth better-auth 2. Configure Better Auth
import { betterAuth } from 'better-auth';
import { topGunAdapter } from '@topgunbuild/adapter-better-auth';
import { tgClient } from './topgun';
export const auth = betterAuth({
database: topGunAdapter({
client: tgClient,
// Optional: customize collection names
modelMap: {
user: 'auth_user',
session: 'auth_session',
account: 'auth_account',
verification: 'auth_verification'
}
}),
// Configure your auth methods
emailAndPassword: {
enabled: true
}
}); 3. Use in Your App
import { auth } from '../lib/auth';
export function SignUpForm() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const form = new FormData(e.target as HTMLFormElement);
await auth.signUp.email({
email: form.get('email') as string,
password: form.get('password') as string,
name: form.get('name') as string
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" />
<input name="email" type="email" placeholder="Email" />
<input name="password" type="password" placeholder="Password" />
<button type="submit">Sign Up</button>
</form>
);
} 4. Connect BetterAuth Session to TopGun
Important: BetterAuth manages sessions using cookies or opaque session tokens by default — it does not issue JWTs. TopGun requires a JWT for sync authentication. To connect BetterAuth sessions to TopGun, you must create a custom server endpoint that verifies the BetterAuth session and mints a signed JWT for TopGun consumption.
Two-step bridge pattern: BetterAuth session → custom endpoint → JWT → TopGun
import { auth } from '../lib/auth';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
// Custom endpoint: verifies the BetterAuth session cookie and mints a TopGun JWT.
// BetterAuth uses opaque session tokens by default — not JWTs.
// This endpoint bridges BetterAuth sessions into a JWT that TopGun can verify.
export async function GET(request: Request) {
// Verify the BetterAuth session from the request (cookie-based)
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, // RFC 7519 standard claim
roles: session.user.roles ?? ['USER']
},
JWT_SECRET,
{ expiresIn: '1h' }
);
return Response.json({ token });
} import { tgClient } from './topgun';
// In-memory cache — not persisted to storage
let cachedToken: string | null = null;
// Register the token provider. It is called on every AUTH_REQUIRED message,
// which happens on initial connect and on every reconnect after a network drop.
tgClient.setAuthTokenProvider(async () => {
if (cachedToken) return cachedToken;
// Exchange the BetterAuth session cookie for a TopGun JWT
const res = await fetch('/api/topgun-token', { credentials: 'include' });
if (!res.ok) return null;
const { token } = await res.json();
cachedToken = token;
return token;
});
// Clear the cache on logout so the next reconnect forces a fresh exchange
export function clearTopGunToken() {
cachedToken = null;
} Server Configuration
The TopGun server validates JWT tokens using a shared secret. Configure the secret to match your auth provider.
# Development: use the test-server binary (included in repo)
# cargo run --bin test-server --release
PORT=8090 \
DATABASE_URL=postgres://user:pass@localhost/myapp \
JWT_SECRET=your-secret-key \
cargo run --bin test-server
# Production: embed the server programmatically in your Rust application
# use topgun_server::{ServerBuilder, NetworkConfig};
#
# let server = ServerBuilder::new()
# .network(NetworkConfig { port: 8090, ..Default::default() })
# .build()
# .await?;
#
# Note: a standalone topgun-server production binary with env-var configuration
# is planned but does not yet exist. Use programmatic embedding for production.
#
# For the full Rust server embed API (NetworkConfig, TlsConfig, RBAC),
# see the Server API reference: /docs/reference/server JWT Token Structure
TopGun expects the following claims in 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
} Custom JWT Provider
If you’re using a custom authentication system, you can generate JWT tokens manually.
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET!;
export function generateToken(user: { id: string; roles: string[] }) {
return jwt.sign(
{
sub: user.id, // RFC 7519 standard claim — the only user identifier TopGun reads
roles: user.roles
},
JWT_SECRET,
{ expiresIn: '24h' }
);
}
// In your login endpoint
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
// Verify credentials...
const user = await verifyCredentials(email, password);
if (user) {
const token = generateToken(user);
res.json({ token });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
}); Client-Side Usage
import { tgClient } from './topgun';
// In-memory cache — not persisted to storage.
// The token is re-acquired from the server on page reload via the provider below.
let cachedToken: string | null = null;
// Register a token provider with TopGun.
// The provider is called on every AUTH_REQUIRED message from the server,
// which happens on initial connection and after any reconnect.
tgClient.setAuthTokenProvider(async () => {
if (cachedToken) {
return cachedToken;
}
try {
const res = await fetch('/api/token', {
method: 'POST',
credentials: 'include' // send session cookie to your auth endpoint
});
if (!res.ok) return null;
const { token } = await res.json();
cachedToken = token;
return token;
} catch {
return null;
}
});
// Call this after a user explicitly logs in so the cache is primed immediately
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; // prime the in-memory cache; provider will return this on next AUTH_REQUIRED
}
// Call this on logout to clear the cache
export function logout() {
cachedToken = null;
} Token Lifecycle
Understanding how token expiry interacts with active connections helps you build robust auth flows.
- Active connections are not terminated when a token expires. Once a WebSocket connection is established and authenticated, token expiry does not disconnect the client. The existing session continues until the connection is dropped.
- Token expiry only matters on reconnect. If the network drops, the page is reloaded, or the WebSocket is otherwise closed, the client must re-authenticate. At that point, the server sends an
AUTH_REQUIREDmessage. setAuthTokenProvider()is called on everyAUTH_REQUIREDmessage. This happens on initial connection and on every reconnect. The provider must return a valid, non-expired token for the connection to be established.- Returning
nullfrom the provider leaves the connection unauthenticated. The client will be connected but no data operations will be permitted. - 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.
Production Deployment with Clerk
Important: Clerk uses RS256 (asymmetric RSA) algorithm for JWT signing, not HS256. This means you need to use Clerk’s public key for token verification on the server.
Getting the Clerk Public Key
Clerk publishes its public keys via the JWKS (JSON Web Key Set) endpoint. You can find your instance’s public key at:
https://YOUR_CLERK_DOMAIN.clerk.accounts.dev/.well-known/jwks.json
// Fetch JWKS from Clerk
const response = await fetch('https://YOUR_CLERK_DOMAIN.clerk.accounts.dev/.well-known/jwks.json');
const jwks = await response.json();
// The response contains the public key in JWK format
// You'll need to convert it to PEM format for JWT_SECRET
// Use a tool like https://8gwifi.org/jwkconvertfunctions.jsp
// or the 'jwk-to-pem' npm package Configuring JWT_SECRET for Docker/Dokploy
When deploying to Docker-based platforms (Dokploy, Railway, etc.), you need to handle the PEM key format carefully:
# 1. Get your Clerk Public Key from the JWKS endpoint
# Visit: https://YOUR_CLERK_DOMAIN.clerk.accounts.dev/.well-known/jwks.json
# Copy the RSA public key (starts with -----BEGIN PUBLIC KEY-----)
# 2. Set the JWT_SECRET environment variable
# In Docker/Dokploy, use escaped newlines:
JWT_SECRET="-----BEGIN PUBLIC KEY-----\nMIIBIjAN...your-key...AQAB\n-----END PUBLIC KEY-----"
# Or in a shell script, use real newlines:
export JWT_SECRET="-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----" Note: The TopGun server automatically detects RSA public keys (by checking for -----BEGIN) and uses the RS256 algorithm. It also handles escaped newlines (\n) from Docker environment variables.
Algorithm Support
TopGun server supports both symmetric and asymmetric JWT algorithms:
| Provider | Algorithm | JWT_SECRET Value |
|---|---|---|
| Custom/Self-hosted | HS256 | Shared secret string |
| Clerk | RS256 | RSA Public Key (PEM) |
| Auth0 | RS256 | RSA Public Key (PEM) |
| Firebase | RS256 | RSA Public Key (PEM) |
Authentication Protocol
Here’s how the authentication handshake works at the WebSocket level:
AUTH_REQUIREDAUTH + JWT tokenAUTH_ACK (success) or close connection (failure)