DocsGuidesAuthentication

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

1

Authenticate User

Use your preferred provider SDK to log the user in and retrieve a session token (JWT).

2

Set Token in Client

Pass the token to the TopGun client. The client will automatically authenticate the WebSocket connection.

3

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

Clerk is a popular authentication provider with excellent React support. Here’s how to integrate it with TopGun.

1. Setup TopGun Client

src/lib/topgun.ts
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.

src/components/TopGunAuthSync.tsx
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

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';

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:

src/components/UserNotes.tsx
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

Better Auth is a framework-agnostic authentication library. TopGun provides a dedicated adapter that uses TopGun as the database backend for Better Auth.

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

Terminal
npm install @topgunbuild/adapter-better-auth better-auth

2. Configure Better Auth

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,
    // 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

src/components/AuthForm.tsx
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

server/api/topgun-token.ts
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 });
}
src/lib/topgun-auth.ts
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.

terminal
# 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:

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
}

Custom JWT Provider

If you’re using a custom authentication system, you can generate JWT tokens manually.

server/auth.ts
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

src/lib/auth.ts
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_REQUIRED message.
  • setAuthTokenProvider() is called on every AUTH_REQUIRED message. 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 null from 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
Fetching JWKS
// 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:

Environment Configuration
# 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:

ProviderAlgorithmJWT_SECRET Value
Custom/Self-hostedHS256Shared secret string
ClerkRS256RSA Public Key (PEM)
Auth0RS256RSA Public Key (PEM)
FirebaseRS256RSA Public Key (PEM)

Authentication Protocol

Here’s how the authentication handshake works at the WebSocket level:

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