Backend: Protecting API Routes
Learn how to verify user sessions and protect your backend API routes using Optare authentication. This guide covers session verification, role-based authorization, and best practices for securing your API.
Setup
The backend auth configuration should already be set up from the Quick Start guide. If not, create lib/auth.server.ts:
import { betterAuth } from "better-auth";
export const auth = betterAuth({
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.NEXT_PUBLIC_APP_URL || process.env.APP_URL || "https://yourapp.com",
socialProviders: {
optare: {
clientId: process.env.OPTARE_CLIENT_ID!,
clientSecret: process.env.OPTARE_CLIENT_SECRET!,
authorizationURL: "https://id.optare.one/oauth/authorize",
tokenURL: "https://id.optare.one/oauth/token",
userInfoURL: "https://id.optare.one/oauth/userinfo",
},
},
});Verifying Sessions
In Next.js API Routes
Verify the user's session in API routes:
// app/api/projects/route.ts
import { auth } from "@/lib/auth.server";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// Get session from request
const session = await auth.api.getSession({
headers: request.headers,
});
// Check if user is authenticated
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// User is authenticated - proceed with logic
const projects = await getProjectsForOrganization(session.organizationId);
return NextResponse.json({ projects });
}In Remix Loaders
Verify sessions in Remix loaders:
// app/routes/api.projects.ts
import { auth } from "~/lib/auth.server";
import { json, type LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
// Get session from request
const session = await auth.api.getSession({
headers: request.headers,
});
// Check if user is authenticated
if (!session) {
throw json({ error: "Unauthorized" }, { status: 401 });
}
// User is authenticated - proceed with logic
const projects = await getProjectsForOrganization(session.organizationId);
return json({ projects });
}In Remix Actions
Verify sessions in Remix actions (POST, PUT, DELETE):
// app/routes/api.projects.new.ts
import { auth } from "~/lib/auth.server";
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
export async function action({ request }: ActionFunctionArgs) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
throw json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await request.formData();
const projectName = formData.get("name");
// Create project scoped to user's organization
const project = await createProject({
name: projectName,
organizationId: session.organizationId,
});
return redirect(`/projects/${project.id}`);
}Session Object
The session object contains all the information you need about the authenticated user:
interface Session {
user: {
id: string;
name: string;
email: string;
image?: string;
emailVerified: boolean;
};
organization?: {
id: string;
name: string;
slug: string;
};
role?: "owner" | "admin" | "member";
organizationId?: string;
expiresAt: string;
}Role-Based Authorization
Checking Roles
Enforce role-based permissions in your API routes:
// app/api/admin/users/route.ts
import { auth } from "@/lib/auth.server";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
// Check if user is admin or owner
if (session.role !== "admin" && session.role !== "owner") {
return NextResponse.json(
{ error: "Forbidden - Admin access required" },
{ status: 403 }
);
}
// User has admin access
const users = await getOrganizationUsers(session.organizationId);
return NextResponse.json({ users });
}Role Hierarchy Helper
Create a helper function to check role hierarchy:
// lib/permissions.ts
type Role = "owner" | "admin" | "member";
const roleHierarchy: Record<Role, number> = {
owner: 3,
admin: 2,
member: 1,
};
export function hasRequiredRole(userRole: Role, requiredRole: Role): boolean {
return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
}
export function requireRole(session: Session | null, requiredRole: Role) {
if (!session) {
throw new Error("Unauthorized");
}
const userRole = session.role || "member";
if (!hasRequiredRole(userRole, requiredRole)) {
throw new Error(`Forbidden - ${requiredRole} role required`);
}
}Usage:
import { auth } from "@/lib/auth.server";
import { requireRole } from "@/lib/permissions";
export async function DELETE(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
});
// Throws error if user doesn't have owner role
requireRole(session, "owner");
// Only owners reach this point
await deleteOrganization(session.organizationId);
return NextResponse.json({ success: true });
}Organization Scoping
Always scope database queries to the user's organization:
// app/api/projects/route.ts
import { auth } from "@/lib/auth.server";
import { db } from "@/lib/db";
import { projects } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
export async function GET(request: NextRequest) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session || !session.organizationId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ✅ Good - scoped to user's organization
const userProjects = await db
.select()
.from(projects)
.where(eq(projects.organizationId, session.organizationId));
return NextResponse.json({ projects: userProjects });
}Always include organization ID in WHERE clauses:
// ✅ Good
const project = await db.query.projects.findFirst({
where: and(
eq(projects.id, projectId),
eq(projects.organizationId, session.organizationId)
),
});
// ❌ Bad - could access other organizations' data
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
});Common Patterns
Protected Loader with Redirect
Redirect unauthenticated users to sign-in:
import { auth } from "~/lib/auth.server";
import { redirect, type LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
throw redirect("/sign-in");
}
// User is authenticated
return json({ user: session.user });
}Reusable Auth Helper
Create a helper function for common auth checks:
// lib/auth-helpers.server.ts
import { auth } from "./auth.server";
import { redirect } from "@remix-run/node";
export async function requireAuth(request: Request) {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
throw redirect("/sign-in");
}
return session;
}
export async function requireRole(request: Request, role: "owner" | "admin") {
const session = await requireAuth(request);
if (session.role !== role && session.role !== "owner") {
throw new Response("Forbidden", { status: 403 });
}
return session;
}Usage:
import { requireAuth, requireRole } from "~/lib/auth-helpers.server";
// Simple auth check
export async function loader({ request }: LoaderFunctionArgs) {
const session = await requireAuth(request);
// ...
}
// Role-based check
export async function action({ request }: ActionFunctionArgs) {
const session = await requireRole(request, "admin");
// Only admins and owners reach this point
}API Key Authentication
For service-to-service authentication, verify API keys instead of sessions:
// app/api/webhooks/route.ts
import { db } from "@/lib/db";
import { apiKeys } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { createHash } from "crypto";
export async function POST(request: NextRequest) {
// Get API key from Authorization header
const authHeader = request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "Missing API key" },
{ status: 401 }
);
}
const apiKey = authHeader.substring(7); // Remove "Bearer "
// Hash the API key (they're stored hashed in the database)
const hashedKey = createHash("sha256").update(apiKey).digest("hex");
// Verify API key exists and is active
const key = await db.query.apiKeys.findFirst({
where: eq(apiKeys.hashedKey, hashedKey),
});
if (!key || key.status !== "active") {
return NextResponse.json(
{ error: "Invalid API key" },
{ status: 401 }
);
}
// API key is valid - proceed
const organizationId = key.organizationId;
// ... handle webhook
return NextResponse.json({ success: true });
}Error Handling
Handle authentication errors gracefully:
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
// Your logic here
} catch (error) {
console.error("Auth error:", error);
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 500 }
);
}
}Testing Protected Routes
Test your protected routes with tools like curl:
# Get session cookie from browser dev tools
# Then make request with cookie
curl https://yourapp.com/api/projects \
-H "Cookie: better-auth.session_token=YOUR_SESSION_TOKEN"Or use Postman/Thunder Client:
- Sign in through your app's UI
- Open browser dev tools → Application → Cookies
- Copy the
better-auth.session_tokenvalue - Add it as a cookie in your API client
- Make requests to your protected endpoints
Best Practices
1. Always Verify Sessions on the Server
Never trust client-side authentication state. Always verify the session on the server:
// ✅ Good - verify on server
export async function loader({ request }: LoaderFunctionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw redirect("/sign-in");
}
// ❌ Bad - relying on client state
export async function loader({ request }: LoaderFunctionArgs) {
// Don't assume user is authenticated
}2. Scope All Queries to Organizations
Prevent data leaks by always filtering by organization ID:
// ✅ Good
where: and(
eq(table.id, id),
eq(table.organizationId, session.organizationId)
)
// ❌ Bad
where: eq(table.id, id)3. Use Specific Error Messages
Help developers debug auth issues:
if (!session) {
return json({ error: "Unauthorized - No session found" }, { status: 401 });
}
if (session.role !== "admin") {
return json({ error: "Forbidden - Admin access required" }, { status: 403 });
}4. Log Authentication Failures
Track failed auth attempts for security monitoring:
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
console.warn("Unauthorized access attempt", {
path: request.url,
ip: request.headers.get("x-forwarded-for"),
timestamp: new Date().toISOString(),
});
return json({ error: "Unauthorized" }, { status: 401 });
return session;
}
export async function requireRole(request: Request, role: "owner" | "admin") {
const session = await requireAuth(request);
if (session.role !== role && session.role !== "owner") {
throw new Response("Forbidden", { status: 403 });
}
return session;
}Usage:
import { requireAuth, requireRole } from "~/lib/auth-helpers.server";
// Simple auth check
export async function loader({ request }: LoaderFunctionArgs) {
const session = await requireAuth(request);
// ...
}
// Role-based check
export async function action({ request }: ActionFunctionArgs) {
const session = await requireRole(request, "admin");
// Only admins and owners reach this point
}API Key Authentication
For service-to-service authentication, verify API keys instead of sessions:
// app/api/webhooks/route.ts
import { db } from "@/lib/db";
import { apiKeys } from "@/lib/db/schema";
import { eq } from "drizzle-orm";
import { createHash } from "crypto";
export async function POST(request: NextRequest) {
// Get API key from Authorization header
const authHeader = request.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return NextResponse.json(
{ error: "Missing API key" },
{ status: 401 }
);
}
const apiKey = authHeader.substring(7); // Remove "Bearer "
// Hash the API key (they're stored hashed in the database)
const hashedKey = createHash("sha256").update(apiKey).digest("hex");
// Verify API key exists and is active
const key = await db.query.apiKeys.findFirst({
where: eq(apiKeys.hashedKey, hashedKey),
});
if (!key || key.status !== "active") {
return NextResponse.json(
{ error: "Invalid API key" },
{ status: 401 }
);
}
// API key is valid - proceed
const organizationId = key.organizationId;
// ... handle webhook
return NextResponse.json({ success: true });
}Error Handling
Handle authentication errors gracefully:
export async function GET(request: NextRequest) {
try {
const session = await auth.api.getSession({
headers: request.headers,
});
if (!session) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
);
}
// Your logic here
} catch (error) {
console.error("Auth error:", error);
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 500 }
);
}
}Testing Protected Routes
Test your protected routes with tools like curl:
# Get session cookie from browser dev tools
# Then make request with cookie
curl https://yourapp.com/api/projects \
-H "Cookie: better-auth.session_token=YOUR_SESSION_TOKEN"Or use Postman/Thunder Client:
- Sign in through your app's UI
- Open browser dev tools → Application → Cookies
- Copy the
better-auth.session_tokenvalue - Add it as a cookie in your API client
- Make requests to your protected endpoints
Best Practices
1. Always Verify Sessions on the Server
Never trust client-side authentication state. Always verify the session on the server:
// ✅ Good - verify on server
export async function loader({ request }: LoaderFunctionArgs) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) throw redirect("/sign-in");
}
// ❌ Bad - relying on client state
export async function loader({ request }: LoaderFunctionArgs) {
// Don't assume user is authenticated
}2. Scope All Queries to Organizations
Prevent data leaks by always filtering by organization ID:
// ✅ Good
where: and(
eq(table.id, id),
eq(table.organizationId, session.organizationId)
)
// ❌ Bad
where: eq(table.id, id)3. Use Specific Error Messages
Help developers debug auth issues:
if (!session) {
return json({ error: "Unauthorized - No session found" }, { status: 401 });
}
if (session.role !== "admin") {
return json({ error: "Forbidden - Admin access required" }, { status: 403 });
}4. Log Authentication Failures
Track failed auth attempts for security monitoring:
const session = await auth.api.getSession({ headers: request.headers });
if (!session) {
console.warn("Unauthorized access attempt", {
path: request.url,
ip: request.headers.get("x-forwarded-for"),
timestamp: new Date().toISOString(),
});
return json({ error: "Unauthorized" }, { status: 401 });
}Webhook Verification
Secure your webhooks by verifying the signature sent by Optare.
client.webhooks.verifySignature()
Verifies that the webhook payload was signed with your webhook secret.
Signature:
verifySignature(payload: string, signature: string, secret: string): booleanUsage (Next.js App Router):
// app/api/webhooks/route.ts
import { OptareClient } from '@optare/optareid-js';
const client = new OptareClient({ ... });
export async function POST(req: Request) {
const payload = await req.text();
const signature = req.headers.get('x-optare-signature');
const secret = process.env.OPTARE_WEBHOOK_SECRET;
try {
client.webhooks.verifySignature(payload, signature!, secret!);
// Process webhook...
return new Response('OK', { status: 200 });
} catch (err) {
return new Response('Invalid Signature', { status: 400 });
}
}Next Steps
- Organizations - Learn about multi-tenancy concepts
- API Reference - Full API documentation
- React Hooks - Frontend authentication