Better Auth provides a comprehensive authentication solution that integrates seamlessly with Rivet Actors using the onAuth hook.

View Example on GitHub

Check out the complete example

Installation

Install Better Auth alongside RivetKit:

npm install better-auth better-sqlite3
npm install -D @types/better-sqlite3

# For React integration
npm install @rivetkit/react

This example uses SQLite to keep the example. In production, replace this with a database like Postgres. Read more about configuring your database in Better Auth.

Backend Setup

1

Configure Better Auth

Create your authentication configuration:

auth.ts
import { betterAuth } from "better-auth";
import Database from "better-sqlite3";

export const auth = betterAuth({
  database: new Database("/tmp/auth.sqlite"),
  trustedOrigins: ["http://localhost:5173"],
  emailAndPassword: {
    enabled: true,
  },
});
2

Generate & Run Migrations

Create and apply the database schema:

# Generate migration files
pnpm dlx @better-auth/cli@latest generate --config auth.ts

# Apply migrations to create the database tables
pnpm dlx @better-auth/cli@latest migrate --config auth.ts -y
3

Create Protected Actor

Use the onAuth hook to validate sessions:

registry.ts
import { actor, setup } from "@rivetkit/actor";
import { Unauthorized } from "@rivetkit/actor/errors";
import { auth } from "./auth";

export const chatRoom = actor({
  // Validate authentication before actor access
  onAuth: async (opts) => {
    const { req } = opts;
    
    // Use Better Auth to validate the session
    const authResult = await auth.api.getSession({
      headers: req.headers,
    });
    if (!authResult) throw new Unauthorized();
    
    // Return user data to be available in actor
    return {
      user: authResult.user,
      session: authResult.session,
    };
  },
  
  state: {
    messages: [] as Array<{
      id: string;
      userId: string;
      username: string;
      message: string;
      timestamp: number;
    }>,
  },
  
  actions: {
    sendMessage: (c, message: string) => {
      // Access authenticated user data
      const { user } = c.conn.auth;
      
      const newMessage = {
        id: crypto.randomUUID(),
        userId: user.id,
        username: user.name,
        message,
        timestamp: Date.now(),
      };
      
      c.state.messages.push(newMessage);
      c.broadcast("newMessage", newMessage);
      
      return newMessage;
    },
    
    getMessages: (c) => c.state.messages,
  },
});

export const registry = setup({
  use: { chatRoom },
});
4

Setup Server with CORS

Configure your server to handle Better Auth routes and RivetKit:

// server.ts
import { registry } from "./registry";
import { auth } from "./auth";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { ALLOWED_PUBLIC_HEADERS } from "@rivetkit/actor";

const { serve } = registry.createServer();
const app = new Hono();

// Configure CORS for Better Auth + RivetKit
app.use("*", cors({
  // Where your frontend is running
  origin: ["http://localhost:5173"],
  // ALLOWED_PUBLIC_HEADERS are headers required for RivetKit to operate
  allowHeaders: ["Authorization", ...ALLOWED_PUBLIC_HEADERS],
  allowMethods: ["POST", "GET", "OPTIONS"],
  exposeHeaders: ["Content-Length"],
  maxAge: 600,
  credentials: true,
}));

// Mount Better Auth routes
app.on(["GET", "POST"], "/api/auth/**", (c) => 
  auth.handler(c.req.raw)
);

// Start RivetKit server
serve(app);

Frontend Integration

1

Setup Better Auth Client

Create a Better Auth client for your frontend:

// auth-client.ts
import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: "http://localhost:8080",
});
2

Authentication Form

Create login/signup forms:

// AuthForm.tsx
import React, { useState } from "react";
import { authClient } from "./auth-client";

export function AuthForm() {
  const [isLogin, setIsLogin] = useState(true);
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [name, setName] = useState("");

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    try {
      if (isLogin) {
        await authClient.signIn.email({ email, password });
      } else {
        await authClient.signUp.email({ email, password, name });
      }
    } catch (error) {
      console.error("Auth error:", error);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <h2>{isLogin ? "Sign In" : "Sign Up"}</h2>
      
      {!isLogin && (
        <input
          type="text"
          placeholder="Name"
          value={name}
          onChange={(e) => setName(e.target.value)}
          required
        />
      )}
      
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      
      <button type="submit">
        {isLogin ? "Sign In" : "Sign Up"}
      </button>
      
      <button 
        type="button" 
        onClick={() => setIsLogin(!isLogin)}
      >
        {isLogin ? "Need an account?" : "Have an account?"}
      </button>
    </form>
  );
}
3

Integrate with RivetKit

Use authenticated sessions with RivetKit:

// ChatRoom.tsx
import React, { useState } from "react";
import { createClient } from "@rivetkit/client";
import { createRivetKit } from "@rivetkit/react";
import { authClient } from "./auth-client";
import type { registry } from "../backend/registry";

const client = createClient<typeof registry>("http://localhost:8080");
const { useActor } = createRivetKit(client);

interface ChatRoomProps {
  session: { user: { id: string; name: string } };
  roomId: string;
}

export function ChatRoom({ session, roomId }: ChatRoomProps) {
  const [newMessage, setNewMessage] = useState("");
  
  const chatRoom = useActor({
    name: "chatRoom",
    key: [roomId],
  });

  const sendMessage = async () => {
    if (!newMessage.trim()) return;
    
    await chatRoom.sendMessage(newMessage);
    setNewMessage("");
  };

  return (
    <div>
      <div>
        <span>Welcome, {session.user.name}!</span>
        <button onClick={() => authClient.signOut()}>Sign Out</button>
      </div>
      
      <div>
        {chatRoom.state.messages.map(msg => (
          <div key={msg.id}>
            <strong>{msg.username}:</strong> {msg.message}
          </div>
        ))}
      </div>
      
      <div>
        <input
          value={newMessage}
          onChange={(e) => setNewMessage(e.target.value)}
          onKeyPress={(e) => e.key === "Enter" && sendMessage()}
          placeholder="Type a message..."
        />
        <button onClick={sendMessage}>Send</button>
      </div>
    </div>
  );
}

Advanced Features

Role-Based Access

Add role checking to your actors:

export const adminActor = actor({
  onAuth: async (opts) => {
    const authResult = await auth.api.getSession({
      headers: opts.req.headers,
    });
    if (!authResult) throw new Unauthorized();
    
    return { user: authResult.user };
  },
  
  actions: {
    deleteUser: (c, userId: string) => {
      // Check user role (assuming you store roles in user data)
      const { user } = c.conn.auth;
      if (user.role !== "admin") {
        throw new Unauthorized("Admin access required");
      }

      // Admin-only action
      // ... implementation
    },
  },
});

Session Management

Handle session expiration gracefully:

// hooks/useAuth.ts
import { authClient } from "./auth-client";
import { useEffect } from "react";

export function useAuthWithRefresh() {
  const { data: session, error } = authClient.useSession();
  
  useEffect(() => {
    if (error?.message?.includes("session")) {
      // Redirect to login on session expiration
      window.location.href = "/login";
    }
  }, [error]);
  
  return session;
}

Production Deployment

For production, you’ll need a database from a provider like Neon, PlanetScale, AWS RDS, or Google Cloud SQL.

Configure your production database connection:

// auth.ts
import { betterAuth } from "better-auth";
import { Pool } from "pg";

export const auth = betterAuth({
  database: new Pool({
    connectionString: process.env.DATABASE_URL,
  }),
  trustedOrigins: [process.env.FRONTEND_URL],
  emailAndPassword: { enabled: true },
});

Set the following environment variables for production:

DATABASE_URL=postgresql://username:password@localhost:5432/myapp
FRONTEND_URL=https://myapp.com
BETTER_AUTH_SECRET=your-secure-secret-key
BETTER_AUTH_URL=https://api.myapp.com

Read more about configuring Postgres with Better Auth.

Don’t forget to re-generate & re-apply your database migrations if you change the database in your Better Auth config.