Optare v1.0 is now available. Get started →
SDKs
Backend

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:

  1. Sign in through your app's UI
  2. Open browser dev tools → Application → Cookies
  3. Copy the better-auth.session_token value
  4. Add it as a cookie in your API client
  5. 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:

  1. Sign in through your app's UI
  2. Open browser dev tools → Application → Cookies
  3. Copy the better-auth.session_token value
  4. Add it as a cookie in your API client
  5. 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): boolean

Usage (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