Security & Roles¶
Authentication, authorization, and security patterns.
Authentication Flow¶
Session-Based Auth¶
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │────▶│ Next.js │────▶│ BetterAuth │
│ │ │ Middleware │ │ Service │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Protected │
│ Routes │
└─────────────┘
- User authenticates with BetterAuth
- Session cookie set with
credentials: include - Middleware validates session on each request
- Protected routes accessible only with valid session
JWT for API Calls¶
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Browser │────▶│ useAuth() │────▶│ BetterAuth │
│ │ │ hook │ │ token() │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Core API │
│ + JWT │
└─────────────┘
- Frontend calls
authClient.token() - BetterAuth returns short-lived JWT
- JWT cached for 10 minutes
- 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:
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: