Skip to content

Security & Roles

Authentication, authorization, and security patterns.

Authentication Flow

Session-Based Auth

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Browser   │────▶│  Next.js    │────▶│  BetterAuth │
│             │     │  Middleware │     │  Service    │
└─────────────┘     └─────────────┘     └─────────────┘
                    ┌─────────────┐
                    │  Protected  │
                    │  Routes     │
                    └─────────────┘
  1. User authenticates with BetterAuth
  2. Session cookie set with credentials: include
  3. Middleware validates session on each request
  4. Protected routes accessible only with valid session

JWT for API Calls

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Browser   │────▶│  useAuth()  │────▶│  BetterAuth │
│             │     │  hook       │     │  token()    │
└─────────────┘     └─────────────┘     └─────────────┘
                    ┌─────────────┐
                    │  Core API   │
                    │  + JWT      │
                    └─────────────┘
  1. Frontend calls authClient.token()
  2. BetterAuth returns short-lived JWT
  3. JWT cached for 10 minutes
  4. Sent as Authorization: Bearer {token} to Core API

Route Protection

Middleware

proxy.ts (functions as Next.js middleware)

const PROTECTED_ROUTES = ["/portal", "/pay", "/profile"];
const AUTH_ROUTES = ["/login", "/signup"];

export async function proxy(request: NextRequest) {
  const session = await getSession(request);
  const path = request.nextUrl.pathname;

  // Require auth for protected routes
  if (PROTECTED_ROUTES.some(r => path.startsWith(r)) && !session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  // Redirect authenticated users from auth routes
  if (AUTH_ROUTES.some(r => path.startsWith(r)) && session) {
    return NextResponse.redirect(new URL("/portal", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    "/((?!api|_next/static|_next/image|favicon.ico|public).*)"
  ]
};

Client-Side Protection

Components check auth state:

function ProtectedPage() {
  const { data: session, isLoading } = authClient.useSession();

  if (isLoading) return <Loading />;
  if (!session) {
    redirect("/login");
    return null;
  }

  return <Content />;
}

Role-Based Access Control (RBAC)

Role Hierarchy

Role Level Permissions
owner 3 Full access, billing, delete org
admin 2 Member management, training
member 1 Basic read/write access

RoleProvider

context/role-provider.tsx

interface RoleContextType {
  role: Role | null;
  isAdmin: boolean;   // admin OR owner
  isManager: boolean; // owner only
  isBasic: boolean;   // member only
}

export function RoleProvider({ 
  children, 
  requireRole 
}: { 
  children: ReactNode;
  requireRole?: Role;
}) {
  const router = useRouter();
  const params = useParams();
  const [role, setRole] = useState<Role | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const fetchRole = async () => {
      const result = await authClient.organization.getActiveMemberRole();
      setRole(result.data?.role || null);
      setIsLoading(false);
    };
    fetchRole();
  }, []);

  // Loading state
  if (isLoading) {
    return <LoadingSpinner />;
  }

  // No membership - redirect to home
  if (!role) {
    router.push("/");
    return null;
  }

  // Insufficient role - redirect to portal
  if (requireRole && !hasRole(role, requireRole)) {
    router.push(`/portal/${params.organizationSlug}`);
    return null;
  }

  return (
    <RoleContext.Provider value={{
      role,
      isAdmin: role === "admin" || role === "owner",
      isManager: role === "owner",
      isBasic: role === "member"
    }}>
      {children}
    </RoleContext.Provider>
  );
}

function hasRole(userRole: Role, requiredRole: Role): boolean {
  const levels = { member: 1, admin: 2, owner: 3 };
  return levels[userRole] >= levels[requiredRole];
}

useRole Hook

export function useRole() {
  const context = useContext(RoleContext);
  if (!context) {
    throw new Error("useRole must be used within RoleProvider");
  }
  return context;
}

Role-Gated UI

function TrainingSection() {
  const { isAdmin } = useRole();

  if (!isAdmin) return null;

  return <TrainingContent />;
}

// Or inline
{isAdmin && <AdminPanel />}

Role-Gated Routes

// In layout.tsx
export default function TrainingLayout({ children }) {
  return (
    <RoleProvider requireRole="admin">
      {children}
    </RoleProvider>
  );
}

Organization Scoping

All data is scoped to the active organization:

// In hooks
const { activeOrganization } = useAuth();
const orgSlug = activeOrganization?.slug;

// In API calls
fetch(`/sessions?org=${orgSlug}`, {
  headers: { Authorization: `Bearer ${token}` }
});

Team Scoping

Some features are further scoped to teams:

const { activeTeamId } = session.session;

fetch(`/sessions?team=${activeTeamId}`, ...);

Security Best Practices

Token Handling

// ✓ Tokens cached with stale time
const { data: token } = useQuery({
  queryKey: ["auth-token"],
  queryFn: () => authClient.token(),
  staleTime: 10 * 60 * 1000 // 10 minutes
});

// ✓ Tokens never logged
logger.info("API call", { endpoint }); // No token

// ✓ Tokens in Authorization header only
headers: { Authorization: `Bearer ${token}` }

Credential Handling

// ✓ Credentials included for cookie auth
fetch(authUrl, { credentials: "include" });

// ✓ Sensitive data not in URL
// BAD: /api/login?password=xxx
// GOOD: POST /api/login with body

Input Validation

// ✓ Validate with Zod before API calls
const schema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100)
});

const validated = schema.parse(formData);
await createUser(validated);

Error Messages

// ✓ Generic error messages to users
toast.error("Failed to save");

// ✓ Detailed errors in logs only
logger.error("Save failed", { 
  error: error.message,
  stack: error.stack,
  userId 
});

Secrets

// ✓ Secrets in environment variables
const key = process.env.STRIPE_SECRET_KEY;

// ✓ Public keys prefixed
const pubKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;

// ✗ Never commit secrets
// .env.local is gitignored

Multi-Factor Authentication

2FA Setup

// Enable TOTP
const { data } = await authClient.twoFactor.enable({ type: "totp" });
// Returns QR code URL and secret

// Verify during login
await authClient.twoFactor.verify({ code: "123456" });

Passkeys

// Register passkey
await authClient.passkey.register();
// Browser prompts for biometric

// Login with passkey
await authClient.passkey.authenticate();

Audit Logging

All sensitive operations are logged:

// User actions
logger.info("User signed in", { userId });
logger.info("Session created", { sessionId, userId });
logger.info("Device registered", { deviceId, adminUserId });

// Security events
logger.warn("Failed login attempt", { email, ip });
logger.error("Unauthorized access attempt", { path, userId });

CORS & Headers

Handled by Next.js and backend services. Frontend uses:

// Auth client
fetchOptions: { credentials: "include" }

// API calls
headers: {
  "Content-Type": "application/json",
  Authorization: `Bearer ${token}`
}