Skip to main content

Authentication & RBAC

Terra uses WorkOS AuthKit for authentication and implements a fail-secure role-based access control (RBAC) system.

Architecture Overview

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   WorkOS        │────▶│   Terra         │────▶│   Supabase      │
│   (Auth)        │     │   (Session)     │     │   (Roles)       │
└─────────────────┘     └─────────────────┘     └─────────────────┘
     SSO/MFA               JWT Cookie              users.role
ComponentResponsibility
WorkOSAuthentication (SSO, MFA, social login)
Terra SessionEncrypted JWT cookie with user + role
SupabaseRole storage and authorization policies

User Roles

Terra supports four user roles:
RoleAccess LevelDefault Routes
super_adminFull system access/ (Dashboard)
adminProgram administration/ (Dashboard)
userApplicant (legacy alias)/portal
applicantApplicant portal only/portal
user and applicant are functionally equivalent. New accounts default to applicant.

Fail-Secure Design

Terra implements a “deny by default” security model:
// src/lib/auth.ts
export async function getUserRole(workosId: string): Promise<UserRole> {
  // On ANY error (network, DB, etc.), default to applicant (no admin access)
  try {
    const { data } = await supabaseAdmin
      .from("users")
      .select("role")
      .eq("workos_id", workosId)
      .single();
    
    return data?.role || "applicant";
  } catch {
    console.error("Role lookup failed - defaulting to applicant");
    return "applicant"; // FAIL-SECURE: No admin access on error
  }
}
Why this matters: If the database is unreachable or has a bug, users are denied admin access rather than potentially granted it.

Route Protection

The middleware (src/middleware.ts) enforces access control:

Admin Routes (Require admin or super_admin)

  • / - Dashboard
  • /applications - Submission management
  • /workflows - Workflow configuration
  • /settings - System settings
  • /intake - Intake management
  • /forms - Form builder

Portal Routes (Any authenticated user)

  • /portal - Applicant dashboard
  • /portal/application/* - Individual applications

Public Routes (No authentication)

  • /f/[slug] - Public form pages
  • /p/[slug] - Public program landing pages
  • /login - Authentication page

Deep Linking

Terra preserves intended destinations through the OAuth flow using the state parameter.

How It Works

  1. User visits protected route (e.g., /forms/abc123/edit)
  2. Middleware redirects to login with ?next=/forms/abc123/edit
  3. Login page encodes the path into OAuth state
  4. After authentication, callback extracts and redirects to original destination

Implementation

// src/app/login/page.tsx
export default function LoginPage({ searchParams }) {
  const next = searchParams.next || searchParams.redirect;
  const authUrl = getAuthorizationUrl(next); // Encodes in OAuth state
  // ...
}

// src/app/auth/callback/route.ts
export async function GET(request: NextRequest) {
  const state = request.nextUrl.searchParams.get("state");
  const { returnTo } = decodeOAuthState(state);
  
  if (returnTo && isValidReturnPath(returnTo)) {
    return redirect(returnTo);
  }
  
  // Default based on role
  return redirect(isAdmin ? "/" : "/portal");
}

Security

The return path is validated to prevent open redirects:
function isValidReturnPath(path: string): boolean {
  // Must start with / (relative path)
  // Cannot contain protocol (no http://)
  // Cannot start with // (protocol-relative)
  return path.startsWith("/") && 
         !path.includes("://") && 
         !path.startsWith("//");
}

Session Management

Sessions are stored in encrypted JWT cookies:
interface Session {
  user: {
    id: string;
    email: string;
    firstName?: string;
    lastName?: string;
    role: "super_admin" | "admin" | "user" | "applicant";
  };
  sessionId: string; // WorkOS session ID for logout
  accessToken: string;
  refreshToken: string;
}

Logout

Proper logout terminates both the local session and the WorkOS SSO session:
// src/app/auth/logout/route.ts
export async function GET() {
  const session = await getSession();
  
  // Clear local cookie
  cookies().delete("wos-session");
  
  // Terminate WorkOS session (prevents auto-login)
  if (session?.sessionId) {
    const logoutUrl = getLogoutUrl(session.sessionId);
    return redirect(logoutUrl);
  }
  
  return redirect("/login");
}
Without terminating the WorkOS session, users may be automatically logged back in when visiting /login.

Database Schema

The users table stores role information:
CREATE TABLE users (
  id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
  email text UNIQUE NOT NULL,
  workos_id text UNIQUE,
  role user_role DEFAULT 'applicant',
  created_at timestamptz DEFAULT now(),
  updated_at timestamptz DEFAULT now()
);

CREATE TYPE user_role AS ENUM ('super_admin', 'admin', 'user', 'applicant');

Promoting a User to Admin

UPDATE users 
SET role = 'admin' 
WHERE email = 'user@example.com';

Debugging

Terra includes a debug endpoint for troubleshooting authentication issues:
# Check current session state
curl http://localhost:3000/api/debug/session
Response:
{
  "hasSession": true,
  "user": {
    "id": "user_abc123",
    "email": "admin@example.com",
    "role": "admin"
  },
  "hasAdminAccess": true,
  "sessionId": "session_xyz789"
}
The debug endpoint should be disabled or protected in production.

Common Issues

The user’s role in the database is applicant or user. To grant admin access:
UPDATE users SET role = 'admin' WHERE email = 'user@example.com';
Then have the user log out and back in to get a new session with the updated role.
The WorkOS session wasn’t terminated. Ensure logout uses getLogoutUrl(sessionId):
const logoutUrl = getLogoutUrl(session.sessionId);
return redirect(logoutUrl);
The user doesn’t exist in the users table, or their workos_id doesn’t match. The system will:
  1. Create a new user with applicant role
  2. Store their WorkOS ID for future lookups

Next: Public Landing Pages

Learn how to create public program landing pages.