How to Manage Open-SEO Users and Permissions: A Complete Implementation Guide

A. Open-SEO uses Drizzle ORM with a relational model of User, Organization, Member, and Invitation tables, enforced by the ensure-user middleware that resolves authentication context from request headers and validates role-based access before every operation.

Understanding how to manage Open-SEO users and permissions requires familiarity with its multi-tenant architecture. The codebase implements a lightweight but robust authorization system where organizations serve as logical tenants, members carry role-based privileges, and middleware intercepts every request to enforce security boundaries. This guide walks through the core data model, permission enforcement mechanisms, and practical code patterns for common user management workflows.


Core Data Model for Users and Permissions

Open-SEO stores all authentication and authorization data in four interconnected tables defined in src/db/better-auth-schema.ts (SQLite) and src/db/pg/better-auth-schema.ts (PostgreSQL).

User Table

The User table contains primary account information including id, email, name, and hashed password. Users are created during sign-up or when initiating the hosted/local-no-auth authentication flow.

Organization Table

The Organization table represents the logical tenant boundary. Every project belongs to exactly one organization, and users gain access by becoming members of that organization.

Member Table

The Member table is the critical junction linking users to organizations with assigned roles:

// From src/db/better-auth-schema.ts
role: text("role").default("member").notNull(),

This role column drives all permission checks. The default value is "member", while "owner" grants elevated privileges for billing management and member invitations.

Invitation Table

The Invitation table tracks pending email invitations:

// From src/db/better-auth-schema.ts
email: text("email").notNull(),
role: text("role"),

When accepted, an invitation row transforms into a member row with the specified role.


Permission Enforcement with ensure-user Middleware

All server-side functions begin by resolving the caller's identity through the ensure-user middleware, implemented in src/middleware/ensure-user/resolve.ts.

Authentication Context Resolution

The middleware inspects the AUTH_MODE environment variable and handles two deployment scenarios:

  • local_noauth: Returns a local admin context for self-hosted deployments
  • Hosted/SaaS: Verifies Cloudflare Access tokens or hosted JWTs

The resolved context contains:

{
  userId: string,
  userEmail: string,
  organizationId: string,
  scopes: string[]
}

This AuthInfo object populates downstream permission checks. The entry point in src/server.ts runs resolveUserContextFromHeaders for every incoming request.

Project-Level Access Control

Server functions validate organization boundaries before operating on project data. As shown in src/serverFunctions/middleware.ts:

if (!authenticatedContext.project) {
  throw new Error("Project context missing from authenticated server function");
}

Common User and Permission Workflows

Creating an Organization

When a logged-in user calls createOrganization, the server:

  1. Inserts a row into the organization table
  2. Creates a corresponding member row with role owner

This ensures the creator has full administrative control from inception.

Inviting Team Members

Invitation endpoints verify the caller's role before proceeding. Only members with owner role can create invitations:

// Verify inviter's role
const inviterMember = await db
  .select()
  .from(member)
  .where(and(eq(member.userId, inviterId), eq(member.organizationId, orgId)));

if (inviterMember[0]?.role !== "owner") {
  throw new Error("Only owners can invite members");
}

Accepting Invitations

The acceptance flow:

  1. Validates the invitation token
  2. Creates a member row with the invited role
  3. Deletes the invitation row

Cross-Tenant Protection

Every project operation verifies the projectId belongs to the authenticated organizationId, preventing unauthorized cross-tenant access.


Practical Code Examples for Managing Open-SEO Users

Resolving Authentication Context

Use this pattern in every API route that requires user identification:

import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";

export async function handler(request: Request) {
  const auth = await resolveUserContextFromHeaders(request.headers);
  // auth now contains userId, userEmail, organizationId, scopes
}

Sending Member Invitations

import { db } from "@/db";
import { invitation, member } from "@/db/better-auth-schema";

export async function inviteMember({
  orgId,
  inviterId,
  email,
  role = "member",
}: {
  orgId: string;
  inviterId: string;
  email: string;
  role?: string;
}) {
  // Verify inviter's role
  const inviterMember = await db
    .select()
    .from(member)
    .where(and(eq(member.userId, inviterId), eq(member.organizationId, orgId)));

  if (inviterMember[0]?.role !== "owner") {
    throw new Error("Only owners can invite members");
  }

  await db.insert(invitation).values({
    organizationId: orgId,
    inviterId,
    email,
    role,
    createdAt: new Date(),
  });
}

Processing Invitation Acceptance

import { db } from "@/db";
import { member, invitation } from "@/db/better-auth-schema";

export async function acceptInvitation({
  invitationId,
  userId,
}: { invitationId: string; userId: string }) {
  const inv = await db.select().from(invitation).where(eq(invitation.id, invitationId));

  if (!inv[0]) throw new Error("Invalid invitation");

  await db.insert(member).values({
    userId,
    organizationId: inv[0].organizationId,
    role: inv[0].role ?? "member",
  });

  await db.delete(invitation).where(eq(invitation.id, invitationId));
}

Guarding Project Operations

import { ensureUser } from "@/middleware/ensure-user";
import { project } from "@/db/project.schema";

export async function createKeywordList({ projectId, ...rest }, request: Request) {
  const auth = await ensureUser(request); // throws 403 if unauthenticated

  // Verify the project belongs to the caller's organization
  const proj = await db.select().from(project).where(eq(project.id, projectId));
  if (proj[0]?.organizationId !== auth.organizationId) {
    throw new Error("Forbidden: project does not belong to your organization");
  }

  // …business logic…
}

Extending the Permission System

To add new roles (e.g., admin, editor):

  1. Update the schema: Modify the role column default in src/db/better-auth-schema.ts
  2. Validate inputs: Add the new role to Zod schemas that narrow trusted input
  3. Adjust service logic: Update if (member.role === "owner") checks throughout service layers to recognize the new role hierarchy

Key Files for Understanding Open-SEO User Management

File Purpose
src/db/better-auth-schema.ts Defines user, organization, member (with role), and invitation tables
src/db/pg/better-auth-schema.ts PostgreSQL counterpart of the authentication schema
src/middleware/ensure-user/resolve.ts Core logic extracting authenticated user from request headers
src/server.ts Server entry point injecting auth context into every request
src/serverFunctions/middleware.ts Enforces project-organization boundary validation
src/routes/api/organization/invite.ts Real-world endpoint demonstrating permission checks

Summary

  • Four core tables (user, organization, member, invitation) implement Open-SEO's multi-tenant permission model in Drizzle ORM
  • Role-based access control defaults to "member" with "owner" granting administrative privileges
  • ensure-user middleware in src/middleware/ensure-user/resolve.ts resolves authentication context for every request, handling both local and hosted deployments
  • Cross-tenant protection requires verifying project.organizationId matches auth.organizationId before data operations
  • Invitation lifecycle creates pending records that convert to member rows upon acceptance, preserving the invited role

Frequently Asked Questions

How does Open-SEO handle authentication in self-hosted deployments?

Set AUTH_MODE=local_noauth to enable the local admin context. The resolveUserContextFromHeaders function in src/middleware/ensure-user/resolve.ts detects this mode and returns a pre-configured administrative context without requiring external identity providers, simplifying single-tenant deployments.

What permission checks are required before inviting a new organization member?

The inviting user must have an owner role in the target organization. The implementation queries the member table for the inviter's record and validates member.role === "owner" before inserting the invitation row. This check appears in src/routes/api/organization/invite.ts and related service layers.

Can I modify the default roles in Open-SEO?

Yes. Update the role column default in src/db/better-auth-schema.ts, extend any Zod validation schemas that constrain role values, and modify the conditional checks in service layer code that currently test member.role === "owner". The schema supports any string role value, though the codebase currently implements logic primarily for member and owner.

How does Open-SEO prevent users from accessing other organizations' projects?

Every project-related server function calls ensureUser(request) to obtain the authenticated context, then verifies the requested projectId belongs to auth.organizationId before executing business logic. The src/serverFunctions/middleware.ts file demonstrates this pattern with explicit thrown errors when the organization boundary check fails.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →