theAuth is open-source auth for AI agents and humans. Star the repo on GitHub · Read the docs · Run the quickstart · theauth.dev

The first enterprise deal usually arrives with one sentence in an email. "Can our employees sign in with Okta, and can you turn them off when they leave?"

I have watched teams answer that sentence with a six-week detour. They bolt a company_id column onto every table, hand-roll invite emails, and then discover that SAML has opinions. This guide walks the whole path in one sitting: organizations, roles, a resource hierarchy, SSO, and SCIM provisioning.

I use theAuth for the code, an open-source TypeScript auth library (@glinr/theauth). I will also tell you where it stops and where your own code has to take over. Some of those gaps matter more than the features.

This is guide 3 of 8 in the theAuth guides. It stands on its own, so you can start right here. It builds on a working login (guide 1) and does not need the other guides.

Guide Title Read it when
1 Add login to an existing Next.js app You have an app with no auth yet
2 Passwordless login: passkeys, links, OTP You want to drop passwords or add 2FA
3 Multi-tenant SaaS auth: orgs, RBAC, SSO, SCIM (this guide) You sell to teams and companies
4 Migrate from Auth0 or Clerk You already run another provider
5 Give every AI agent its own identity You run AI agents and need to start somewhere
6 Cap agent spend and require human approval Your agents spend money or act on risky things
7 Secure an MCP server for production You expose tools over MCP
8 Build an audit trail for AI agent actions Someone will ask what your agents did

Building for people? Start at guide 1. Building for AI agents? Start at guide 5. Every guide links to the docs page for each concept it touches.

TL;DR

Layer What it does theAuth piece Docs
Tenancy Groups users into a company org module Organizations
Roles Answers "can this member do X in this org" built-in and custom org roles Organizations
Resource access Answers "can this user see this one document" ReBAC engine ReBAC
Enterprise login SAML 2.0 or OIDC, routed by email domain sso module SSO
Lifecycle Create and deactivate users from the IdP scim plugin SCIM

You will end with one createTheAuth call that wires all five layers, plus a short list of checks you must add yourself.

Prerequisites

You need Node, a TypeScript project, and a database. The examples use SQLite so you can run them in a scratch folder. The docs show the same config with provider: 'postgres' for production.

You also need a way to receive HTTP requests. The examples assume you mounted the theAuth adapter at /api/theauth, which is the default mount path in the docs. Any framework works. Adapter setup is outside this guide.

One honest scoping note before we start. If your product only has individual users and no companies, none of this applies. Skip the whole stack and use plain sign-in.

Step 1: Install and create the instance

npm install @glinr/theauth 

Start with only the organization module and grow from there. The org key is what creates theauth.org. Without it, that property is null, and every call in the next steps fails.

import { createTheAuth } from '@glinr/theauth'; export const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, org: { maxMembers: 100, maxOrgsPerUser: 5, allowCustomRoles: true, }, }); 

Those three numbers match the defaults in the docs: 100 members per org, 5 orgs per user, custom roles on. I set them in code to document the choice. Read the full option list on the Organizations page.

Step 2: Create an organization

An organization is your tenant in practice. Slugs allow lowercase letters, numbers and single hyphens.

const org = await theauth.org!.create({ name: 'Acme Corp', slug: 'acme-corp', ownerId: 'user_abc', metadata: { plan: 'pro' }, }); console.log(org.id); // org_... 

The ownerId user becomes a member with the owner role automatically. That is one less write for you to forget.

I want to flag a naming trap here. theAuth also has a separate tenant module with theauth.tenant.create. It stores a tenant record, and agents can carry a tenantId. The Multi-tenant page is blunt about it: tenants are a data model, not an enforcement layer. authorize() does not compare tenants, and a suspended tenant does not block anything.

For human members of a SaaS product, I reach for organizations. I treat the tenant module as a label for agents, and I check agent.tenantId myself before acting.

Step 3: Invite members and assign roles

Invitations expire after 7 days by default. You can change that with invitationExpiryMs, and cap invites per org per hour with maxInvitationsPerHour (default 50).

const invitation = await theauth.org!.invite({ orgId: org.id, email: 'alice@acme.com', role: 'admin', invitedBy: 'user_abc', }); // Later, in the accept-invite handler for the signed-in user: const member = await theauth.org!.acceptInvitation(invitation.id, 'user_xyz'); 

Day-two management is three calls.

const members = await theauth.org!.getMembers(org.id); await theauth.org!.updateMemberRole(org.id, 'user_xyz', 'member'); await theauth.org!.removeMember(org.id, 'user_xyz'); 

These methods take user ids as plain arguments, such as invitedBy. Your route handler must confirm that the caller may invite before it calls invite. Step 4 shows the check.

Step 4: Roles and permissions

Four roles ship built in.

Role Permissions
owner everything, including org:manage, org:delete, roles:manage
admin members:invite, members:remove, agents:create, agents:revoke, agents:manage
member agents:create, agents:manage
viewer none

The runtime check is one call. Here is the guard I put in front of the invite route.

export async function canInvite(orgId: string, userId: string): Promise<boolean> { return theauth.org!.hasPermission(orgId, userId, 'members:invite'); } 

Your product will have domain permissions that the built-ins do not name. A billing role is the classic example. Create it per organization.

await theauth.org!.createRole(org.id, { name: 'billing', permissions: ['invoices:read', 'invoices:pay'], }); 

Then call hasPermission(org.id, userId, 'invoices:pay') in your billing routes. Permission strings are yours to define. theAuth stores them and answers membership questions about them.

Set allowCustomRoles: false if you want every org locked to the four built-ins. I do that for products where support staff need to reason about every account by role name alone.

The HTTP route option

You can also serve organization routes without writing handlers. The organization() plugin registers /auth/org/* routes for your adapter to serve, and every one of them requires an authenticated user.

import { organization } from '@glinr/theauth/auth'; const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, auth: { session: { secret: process.env.SESSION_SECRET! } }, plugins: [organization({ maxMembers: 100, maxOrgsPerUser: 5, allowCustomRoles: true })], }); 

The plugin does not add theauth.org to the instance. Pick one style per concern, or pass both org and the plugin if you want the programmatic API and the routes. The route table lives on the Organizations page.

A second route table on that page covers theauth.org.handleRequest. It serves more paths and does no authentication at all. Mount it only behind your own checks.

Step 5: Fine-grained access with ReBAC

Roles answer "what can an admin do in this org". They cannot answer "can Alice open this one document because she edits its workspace". That question needs a graph.

theAuth ships a relationship-based engine for it. You register resources in a tree, then add relationship tuples between subjects and resources.

import { createReBACModule, createDatabase, createTables } from '@glinr/theauth'; const db = await createDatabase({ provider: 'sqlite', url: 'theauth.db' }); await createTables(db, 'sqlite'); const rebac = createReBACModule({}, db); await rebac.createResource({ id: 'acme', type: 'org' }); await rebac.createResource({ id: 'eng', type: 'workspace', parentId: 'acme', parentType: 'org' }); await rebac.createResource({ id: 'api', type: 'project', parentId: 'eng', parentType: 'workspace' }); await rebac.createResource({ id: 'spec', type: 'document', parentId: 'api', parentType: 'project' }); await rebac.addRelationship({ subjectType: 'user', subjectId: 'alice', relation: 'editor', objectType: 'workspace', objectId: 'eng', }); const result = await rebac.check({ subjectType: 'user', subjectId: 'alice', permission: 'viewer', objectType: 'document', objectId: 'spec', }); // result.data.allowed === true 

Two rules produce that true. An editor implies viewer. Also, workspace, project and document resources inherit from their parent, so the grant on eng reaches spec, two levels down.

Four details bit me or would have bitten me.

Resource ids are the primary key across all types. A document:spec and a project:spec cannot coexist, so prefix your ids with something unique.

Deleting a resource cascades to its children and to every relationship that names it. That is convenient and also a good reason to confirm before calling it.

The org type does not inherit from a parent. Types missing from the built-in table, such as doc, get no implied relations, so only an exact relation match grants access. Add your own rules with permissionRules for a wiki type.

Methods return a Result with success and data rather than throwing on expected failures such as PARENT_NOT_FOUND. Check success.

You can also list what a user reaches, which powers "shared with me" screens.

const projects = await rebac.listObjects({ subjectType: 'user', subjectId: 'alice', permission: 'viewer', objectType: 'project', }); // projects.data = ['api', 'web'] 

Where ReBAC does and does not connect

Here is a limit worth knowing now. theauth.authorize() does not consult ReBAC. The docs describe the bridge: set relation on a permission and call theauth.policy.evaluate(), which runs the graph check for you.

const decision = await theauth.policy.evaluate({ subject: { userId: 'usr_alice', orgId: org.id }, action: 'read', resource: 'document:spec', }); if (!decision.allowed) { throw new Error(`Denied: ${decision.reason}`); } 

The policy engine builds its own ReBAC module with the default rules. Your custom permissionRules are not used there. The resource must also be a concrete type:id, so a wildcard never matches a relation. Read the Policy engine page before you plan around this. For user subjects it also folds in org role permissions, scoped by the orgId you pass.

I also keep the cache in mind. The engine caches decisions per process for 60 seconds by default. After you revoke a relationship, call invalidate for that user, and accept that other processes catch up when their entries expire.

Step 6: Add SSO per organization

Now the enterprise part. The sso key takes your provider definitions. SSO has no plugin form, and theauth.sso is null unless you pass the key.

export const theauth = await createTheAuth({ database: { provider: 'sqlite', url: 'theauth.db' }, org: { maxMembers: 100, maxOrgsPerUser: 5, allowCustomRoles: true }, sso: { saml: [ { id: 'okta', name: 'Okta', entryPoint: process.env.OKTA_SSO_URL!, issuer: process.env.APP_URL!, cert: process.env.OKTA_SAML_CERT!, callbackUrl: `${process.env.APP_URL}/api/theauth/auth/sso/saml/conn_id/acs`, }, ], oidc: [ { id: 'azure', name: 'Azure AD', issuer: process.env.AZURE_ISSUER!, clientId: process.env.AZURE_CLIENT_ID!, clientSecret: process.env.AZURE_CLIENT_SECRET!, callbackUrl: `${process.env.APP_URL}/api/theauth/auth/sso/oidc/conn_id/callback`, scopes: ['openid', 'profile', 'email'], }, ], }, }); 

A provider definition is a template. A connection ties one provider to one organization and one email domain.

const connection = await theauth.sso!.createConnection({ orgId: org.id, providerId: 'okta', type: 'saml', domain: 'acme.com', }); // On the login page, after the user types an email: const conn = await theauth.sso!.getConnectionByDomain('acme.com'); 

That getConnectionByDomain call is the heart of the "enter your work email" screen. If it returns a connection, you redirect to the IdP. If it returns nothing, you show the normal sign-in form.

The SAML flow

// 1. Send the browser to the IdP const authUrl = await theauth.sso!.getSamlAuthUrl(connection.id, '/dashboard'); // 2. Handle the POST the IdP sends to your ACS URL const { user, orgId } = await theauth.sso!.handleSamlResponse( connection.id, samlResponseFromFormData, ); 

The module verifies the response against the IdP certificate. The module rejects unsigned responses unless you set wantAuthnResponseSigned: false, and I would not. It does not support encrypted assertions, so ask the customer's IT admin for unencrypted ones. That request comes up in real onboarding calls, so put it in your setup doc.

Pass expectedRequestId as a third argument to bind the response to a request you started.

The OIDC flow

const state = theauth.sso!.generateState(); const authUrl = await theauth.sso!.getOidcAuthUrl(connection.id, state); // redirect the browser to authUrl // In the callback route, after you validate the state: const { user, orgId } = await theauth.sso!.handleOidcCallback(connection.id, codeFromQuery); 

Discovery runs automatically from issuer/.well-known/openid-configuration, and the module verifies the id_token against the IdP's JWKS. It rejects an id_token without an email claim. Use validateState on the way back, and pass a nonce through both calls if you want replay protection.

The default rate limit is 10 attempts per connection per 60 seconds, tunable with rateLimitMax and rateLimitWindowSeconds. Failed logins return 401, or 429 when limited.

Step 7: Finish the login yourself

Demos skip this step. It cost me the most time, and nobody warned me.

The SSO module does not create a user row. It does not add a membership. It does not issue a session. It hands you a verified identity, { user: { id, email, name? }, orgId }, and stops.

The user.id is deterministic: saml_ plus a SHA-256 hash of providerId:email for SAML, and oidc_ plus a hash of providerId:sub for OIDC. The same person always maps to the same ID. But that ID is not a row in your users table.

Your callback owns three jobs: find or create your user, ensure the org membership, and start a session. Here is the shape I use for the middle one. The findOrCreateUser and startSession helpers are yours, not theAuth's.

export async function completeSsoLogin(identity: { user: { id: string; email: string; name?: string }; orgId: string; }) { const localUser = await findOrCreateUser(identity.user.email, identity.user.name); const members = await theauth.org!.getMembers(identity.orgId); const isMember = members.some((m) => m.userId === localUser.id); if (!isMember) { await theauth.org!.addMember(identity.orgId, localUser.id, 'member'); } return startSession(localUser.id); } 

Two notes on that code. addMember is on the module in the package source, though the docs page lists it only as an HTTP route, so confirm the signature in your installed version. And auto-joining everyone who authenticates through a connection is a policy choice. For a connection tied to acme.com it is usually right, because the IdP already vouched for the person.

Pass onAuditEvent in the sso config if you want login success and failure events delivered to your own logging.

Step 8: Add SCIM for provisioning

SSO decides who can sign in. SCIM decides who exists. Without it, an employee who leaves the company keeps their account until someone remembers to clean up.

SCIM is a plugin from the auth entry point.

import { scim } from '@glinr/theauth/auth'; export const theauth = await createTheAuth({ database: { provider: 'postgres', url: process.env.DATABASE_URL! }, org: { maxMembers: 100, maxOrgsPerUser: 5, allowCustomRoles: true }, plugins: [ scim({ bearerToken: process.env.SCIM_TOKEN!, }), ], }); 

Then point the IdP's provisioning settings at your server.

Base URL: <your app origin>/api/theauth/scim/v2 Auth: Bearer token Token: <your SCIM_TOKEN> 

Use a random secret of...