# LumoAuth documentation (full text) > LumoAuth is an authentication and authorization service for web and mobile applications and for AI agents. People sign in without passwords on a hosted page that you brand (passkeys, magic links, one-time codes, push approval, social and company accounts). They can also sign in through enterprise SSO (SAML 2.0, OpenID Connect), with directory sync over SCIM. AI agents get their own cryptographic identity (AAuth: signed HTTP requests), workload identity without stored secrets, and a fixed list of capabilities. They also get just-in-time permissions approved by a human, budgets and a kill switch, and MCP (Model Context Protocol) server authorization with RFC 9728 discovery. Fine-grained authorization (roles, attribute rules and Zanzibar-style relationships) is available through one check endpoint. Built on open standards (OAuth 2.1, OpenID Connect, FAPI 2.0, DPoP, SCIM 2.0, SAML 2.0, WebAuthn), hosted in the US and the EU. Generated from the sources of https://docs.lumoauth.dev in sidebar order. Each section starts with the page title and its canonical URL. The short index is at https://docs.lumoauth.dev/llms.txt. ## Key facts - Name: LumoAuth (one word, capital L and A). Website: https://lumoauth.dev. Documentation: https://docs.lumoauth.dev. Sign up and sign in: https://app.lumoauth.dev (EU region: https://eu.app.lumoauth.dev). Source and SDKs: https://github.com/lumoauth. - Two headline capabilities: passwordless sign-in for people, and identity plus guardrails for AI agents (registry, AAuth signed requests, workload identity, just-in-time permissions with human approval, budgets, kill switch, MCP server authorization). - Platform: enterprise SSO (SAML 2.0 SP and IdP, OpenID Connect federation, LDAP), SCIM 2.0 in and out, HRIS sync, customer organizations with a self-service admin portal, custom domains and branding, fine-grained authorization (RBAC, ABAC, ReBAC behind one /authz/check endpoint, OpenID AuthZEN), attack protection and adaptive MFA, Shared Signals (CAEP, RISC), audit log streaming to SIEMs, GDPR tooling, bring-your-own signing keys (KMS and HSM providers). - OAuth security profile: OAuth 2.1 with PKCE, PAR, JAR and JARM, DPoP, mTLS certificate-bound tokens, FAPI 2.0, CIBA, device authorization grant, token exchange (RFC 8693), rich authorization requests, resource indicators, dynamic client registration, Client ID Metadata Documents, back-channel logout. - Deployment: hosted in the US and the EU (Germany); the region is chosen when the organization is created. Plans: free Developer plan, Pro, Business, plus add-ons; see https://docs.lumoauth.dev/pricing-matrix and https://lumoauth.dev/pricing/. - Developer surface: hosted login or your own UI, REST Admin API with an OpenAPI spec, SDKs and generated API clients (https://docs.lumoauth.dev/developer/sdks), the lumo CLI (sandbox organizations, webhook tunnel, scaffolding, Auth0 migration), Terraform provider and Kubernetes operator, webhooks. - Migrations: guides and tooling for Auth0 (lumo migrate), Clerk, Okta, Amazon Cognito and Firebase Auth. --- # Start Source: https://docs.lumoauth.dev/ Add sign-in to an app or secure an AI agent — one quickstart each. LumoAuth is OAuth 2.0, OIDC, SAML, SCIM and passkeys with first-class AI agent identity (AAuth + MCP authorization). LumoAuth is an identity platform for apps **and** AI agents. It provides the standard stack (OAuth 2.0 sign-in and tokens, OpenID Connect, SAML single sign-on, directory sync over SCIM, passkeys). It adds a cryptographic identity for every agent (an Ed25519 key that signs each HTTP request), scoped tokens through token exchange, MCP authorization (protected resource metadata for tool servers) and a human-in-the-loop approval inbox. New to the vocabulary? See the [glossary](https://docs.lumoauth.dev/user-guide/getting-started/glossary). Pick what you are building: Add sign-in to an app Three commands scaffold a Next.js app with hosted login, sessions and tokens already wired. Have an app? One prompt for your AI editor adds it. [Next.js quickstart →](https://docs.lumoauth.dev/quickstarts/nextjs) Same scaffold for [Express](https://docs.lumoauth.dev/quickstarts/express) · [FastAPI](https://docs.lumoauth.dev/quickstarts/fastapi) · [Go](https://docs.lumoauth.dev/quickstarts/go). Guides for [React](https://docs.lumoauth.dev/quickstarts/react) · [Vue](https://docs.lumoauth.dev/quickstarts/vue) · [Angular](https://docs.lumoauth.dev/quickstarts/angular) · [React Native](https://docs.lumoauth.dev/quickstarts/react-native) Secure an AI agent Give an agent its own identity, scoped capabilities and an approval step, then call an MCP server with a real token. Five minutes, every artifact stays in your organization. [AI agent + MCP quickstart →](https://docs.lumoauth.dev/quickstarts/ai-agent-mcp-5min) Or [walk through the interactive demo](https://docs.lumoauth.dev/agents/live-demo) (simulated, no signup) · frameworks: [LangChain](https://docs.lumoauth.dev/agents/aauth-qs-langchain) · [OpenAI Agents](https://docs.lumoauth.dev/agents/registry-openai-agents) · [CrewAI](https://docs.lumoauth.dev/agents/jit-crewai) Both start from the same place: sign up at [app.lumoauth.dev](https://app.lumoauth.dev). Every new organization comes with a **Starter App** OAuth client that already allows `http://localhost:3000`. So the shortest path to a signed-in page is three commands: ```bash curl -fsSL https://raw.githubusercontent.com/LumoAuth/cli/main/install.sh | sh # or: brew install lumoauth/tap/lumo lumo login --org your-org-slug lumo init --framework next --client-id YOUR_CLIENT_ID # or express | fastapi | go ``` **Portal → Get started → Quickstarts** shows this command with your client ID filled in, and every snippet on that page carries your organization's real values. The [Next.js quickstart](https://docs.lumoauth.dev/quickstarts/nextjs) explains what the scaffold contains and how to add sign-in to an app that you already have. Other paths — tracks for specific roles and situations | You are… | Start with | Time | |---|---|---| | Building a SaaS where each customer is its own organization (SSO, SCIM, roles, branded login) | [Multi-customer SaaS track](https://docs.lumoauth.dev/tracks/b2b) | 30 min | | Protecting a backend API or service-to-service call | [Backend / Service track](https://docs.lumoauth.dev/tracks/backend) | 15 min | | Adding login to a single-page or mobile app and want the full picture | [Frontend / Mobile track](https://docs.lumoauth.dev/tracks/frontend) | 10 min | | Building agents and want the model before the code | [AI Agents track](https://docs.lumoauth.dev/tracks/agents) | 20 min | | Configuring the organization for enterprise customers (SSO, SCIM, custom domains, users, roles) | [Admin / IT track](https://docs.lumoauth.dev/tracks/admin) · [Your first 15 minutes in the portal](https://docs.lumoauth.dev/user-guide/getting-started/first-tenant) | 15 min | | Doing a security review, compliance audit or GDPR assessment | [Security & Compliance track](https://docs.lumoauth.dev/tracks/compliance) | — | | Migrating from Auth0, Clerk, Okta, Cognito or Firebase | [Migrations](https://docs.lumoauth.dev/migrations) — `lumo migrate auth0` imports an Auth0 export; the other guides are step-by-step manual migrations | — | | Looking for the guide behind a portal page | [Portal page → guide](https://docs.lumoauth.dev/user-guide/portal-map) | — | --- ## What LumoAuth ships | Capability | What you get | |---|---| | [**Agent + MCP**](https://docs.lumoauth.dev/agents) | Protected resource metadata for MCP servers, signed HTTP requests, delegation chains through token exchange, JIT human-in-the-loop approvals — built into the product | | [**Workload attestation**](https://docs.lumoauth.dev/agents/attestation) | Verify the identity that a platform already gives a workload (GitHub Actions OIDC, AWS IRSA, GCP, Kubernetes projected tokens) when the agent's token is issued | | [**Three authorization models**](https://docs.lumoauth.dev/authorization) | Roles, attribute rules and relationships behind one `/authz/check` | | [**EU + US data residency on Business**](https://docs.lumoauth.dev/tracks/compliance) | The Business plan includes the ISO/IEC 27001 certified EU region in Frankfurt, Germany | | [**Per-application login branding**](https://docs.lumoauth.dev/user-guide/multi-tenancy/branding) | Each OAuth client overrides logo, colours and button text, with optional AI-generated themes | ## Standards LumoAuth implements OAuth 2.0 · OAuth 2.1 · OpenID Connect · SAML 2.0 · SCIM 2.0 · Passkeys (WebAuthn / FIDO2) · **Token exchange** (delegation) · **Signed HTTP requests** (HTTP message signatures) · **Protected resource metadata** · **Device authorization** (sign in on another device) · **Rich authorization requests** · **FAPI 2.0** · **CIBA** (backchannel authentication) · **Sender-constrained tokens (DPoP)** · **Pushed authorization requests (PAR)** · **Shared signals (SSF/CAEP)** The [standards reference](https://docs.lumoauth.dev/reference/standards) maps each of these names to the specification that it implements. **Note: Base URL** All API endpoints are scoped to an organization: `https://app.lumoauth.dev/orgs/{orgId}/api/v1/`, where `{orgId}` is the organization slug. EU region: `https://eu.app.lumoauth.dev/...` ## Getting help - **Error codes** — [API Reference → Errors](https://docs.lumoauth.dev/api-reference/errors) - **Issues or bugs** — [github.com/lumoauth](https://github.com/lumoauth) - **SDK reference** — [SDKs & Libraries](https://docs.lumoauth.dev/developer/sdks) - **New to the vocabulary?** — [Glossary](https://docs.lumoauth.dev/user-guide/getting-started/glossary) ## Standards used on this page | Plain name | Standard | |---|---| | Signed HTTP requests (HTTP message signatures) | RFC 9421 | | Token exchange (delegation) | RFC 8693 | | Protected resource metadata | RFC 9728 | | Device authorization (sign in on another device) | RFC 8628 | | Rich authorization requests | RFC 9396 | | OAuth 2.0 (the core sign-in and token protocol) | RFC 6749 | | OpenID Connect (OIDC) | OpenID Connect Core 1.0 | | SAML 2.0 | OASIS SAML 2.0 | | SCIM 2.0 (user and group provisioning) | RFC 7642, RFC 7643, RFC 7644 | | Passkeys (WebAuthn and FIDO2) | W3C Web Authentication, FIDO CTAP | | Financial-grade API (FAPI) 2.0 | FAPI 2.0 Security Profile | | Client-initiated backchannel authentication (CIBA) | OpenID CIBA Core 1.0 | | Sender-constrained tokens (DPoP) | RFC 9449 | | Pushed authorization requests (PAR) | RFC 9126 | | Shared Signals Framework (SSF) and Continuous Access Evaluation Profile (CAEP) | OpenID SSF 1.0, OpenID CAEP 1.0 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Add sign-in to a Next.js app Source: https://docs.lumoauth.dev/quickstarts/nextjs Sign in to a Next.js app with LumoAuth in three commands, or paste one prompt into your AI editor for an existing app. Three commands scaffold a Next.js app that signs users in with LumoAuth. Already have an app? Paste the prompt below into your AI editor, or follow the [manual steps](#add-to-an-existing-app-by-hand). ## New app: three commands ```bash curl -fsSL https://raw.githubusercontent.com/LumoAuth/cli/main/install.sh | sh # or: brew install lumoauth/tap/lumo lumo login --org your-org-slug lumo init --framework next --dir my-app --client-id YOUR_CLIENT_ID ``` Then run it: ```bash cd my-app && npm install && npm run dev ``` Open [http://localhost:3000](http://localhost:3000) and click **Sign in**. You are redirected to your organization's hosted login, sign in, and return to the page signed in. **Where the values come from.** `lumo login` opens your browser to approve the CLI and stores your organization slug and base URL, so `.env.local` is pre-filled. The client ID is the only value that you supply. Every organization is created with a **Starter App** OAuth client. It already allows `http://localhost:3000/auth/callback` and the `http://localhost:3000` origin, so there is nothing to register: - **Portal → Get started → Quickstarts → Next.js** shows this exact `lumo init` command with the Starter App's client ID filled in. Copy it. - Or from the terminal: `lumo apps list` prints the client ID of the Starter App. ![The Quickstarts page with the Next.js tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-next.png) What the scaffold contains Nine files, no hidden framework. The three that matter: | File | Does | |---|---| | `app/layout.tsx` | Wraps the app in `` from `@lumoauth/nextjs`, reading `.env.local` | | `app/page.tsx` | Shows `` when signed out, `` and the user's name when signed in | | `app/auth/callback/page.tsx` | Mounts ``, which finishes the PKCE code exchange and redirects to `/` | `.env.local` holds `NEXT_PUBLIC_LUMOAUTH_DOMAIN`, `NEXT_PUBLIC_LUMOAUTH_ORG_ID` and `NEXT_PUBLIC_LUMOAUTH_CLIENT_ID`. Everything else is the stock Next.js 14 App Router setup. See [`lumo init`](https://docs.lumoauth.dev/cli/init) for flags such as `--org` (target a [sandbox organization](https://docs.lumoauth.dev/cli/dev)) and `--force`. ## Existing app: paste one prompt Open the prompt in Cursor, or copy it into Claude Code, Copilot or any coding agent. It carries the install, the provider, the callback route and the protection patterns, with the rules that an agent needs to get them right. Prompt for your AI editor: ```text Set up LumoAuth authentication in a Next.js application. Install the SDK: npm install @lumoauth/nextjs Configure .env.local: NEXT_PUBLIC_LUMOAUTH_DOMAIN=https://app.lumoauth.dev NEXT_PUBLIC_LUMOAUTH_ORG_ID=YOUR_ORG_ID NEXT_PUBLIC_LUMOAUTH_CLIENT_ID=YOUR_CLIENT_ID Create a client component provider in app/providers.tsx (App Router): 'use client'; import { LumoAuthProvider } from '@lumoauth/nextjs'; export function Providers({ children }: { children: React.ReactNode }) { return ( {children} ); } Wrap the root layout in app/layout.tsx: import { Providers } from './providers'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } Create the auth callback route at app/auth/callback/page.tsx: 'use client'; import { AuthCallback } from '@lumoauth/nextjs'; export default function CallbackPage() { return Signing in…

} />; } Add sign-in and sign-up pages: import { SignIn } from '@lumoauth/nextjs'; export default function SignInPage() { return ; } import { SignUp } from '@lumoauth/nextjs'; export default function SignUpPage() { return ; } Gate pages and components with SignedIn / SignedOut: 'use client'; import { SignedIn, SignedOut, RedirectToSignIn, UserButton } from '@lumoauth/nextjs'; export default function DashboardPage() { return ( <> ); } Use the Protect component for permission-based access control: 'use client'; import { Protect } from '@lumoauth/nextjs'; No access

}>
For Zanzibar (ReBAC): } > Check permissions as the signed-in user in a route handler (the browser sends the access token from useAuth().getToken() as a Bearer header): import { NextRequest, NextResponse } from 'next/server'; import { LumoAuth } from '@lumoauth/nextjs'; export async function GET(request: NextRequest) { const token = request.headers.get('authorization')?.replace('Bearer ', ''); if (!token) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); const lumo = new LumoAuth({ baseUrl: process.env.NEXT_PUBLIC_LUMOAUTH_DOMAIN!, token }); const allowed = await lumo.permissions.check('dashboard.view'); if (!allowed) return NextResponse.json({ error: 'forbidden' }, { status: 403 }); return NextResponse.json({ ok: true }); } Rules: - Always wrap the app root with LumoAuthProvider (client component) - Use SignedIn / SignedOut for layout-level auth gating - Use Protect for permission, RBAC, Zanzibar, or ABAC checks - All hooks and LumoAuth components require 'use client' in App Router - The AuthCallback component must be mounted at your redirect URI route - Run server-side permission checks with the LumoAuth client re-exported from @lumoauth/nextjs and the caller's Bearer token; use @lumoauth/backend with an organization API key only for admin operations Deprecated patterns (do not use): - Do not manually call fetch against /oauth/token; use built-in auth flow - Do not store tokens in localStorage; the SDK manages token storage Verification: 1. The app compiles without errors 2. Unauthenticated users are redirected to sign-in 3. After sign-in, the user sees the dashboard with UserButton 4. The Protect component hides content when permission is denied 5. The AuthCallback page completes the PKCE exchange without errors ``` Before you run it, make sure the OAuth client that you use allows your app's callback URL and origin. The Starter App already allows `http://localhost:3000/auth/callback` and `http://localhost:3000`. For any other URL, open the application in **Portal → Applications**, add the **Redirect URI** and the **Browser CORS origin** on its **Configuration** tab, and save. ## Add to an existing app by hand The same four steps that the prompt performs. Requires Next.js 14+ with the App Router and React 18+. **1. Install and configure.** ```bash npm install @lumoauth/nextjs ``` ```env title=".env.local" NEXT_PUBLIC_LUMOAUTH_DOMAIN=https://app.lumoauth.dev NEXT_PUBLIC_LUMOAUTH_ORG_ID=YOUR_ORG_ID NEXT_PUBLIC_LUMOAUTH_CLIENT_ID=YOUR_CLIENT_ID ``` **2. Wrap the app in the provider.** ```tsx title="app/layout.tsx" import { LumoAuthProvider } from '@lumoauth/nextjs'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ); } ``` **3. Mount the callback at your redirect URI.** ```tsx title="app/auth/callback/page.tsx" 'use client'; import { AuthCallback } from '@lumoauth/nextjs'; export default function CallbackPage() { return ; } ``` **4. Show sign-in, and protect what needs a user.** ```tsx title="app/page.tsx" 'use client'; import { SignedIn, SignedOut, SignIn, UserButton } from '@lumoauth/nextjs'; export default function Home() { return ( <> ); } ``` Use `` inside `` on pages that must not render signed out. Every LumoAuth component and hook is a client component, so files that use them start with `'use client'`. Pages Router `@lumoauth/nextjs` supports the App Router only. On the Pages Router install `@lumoauth/react` instead and put the same `` in `pages/_app.tsx`; the components and hooks are identical. ## Check it worked **Portal → Get started → Quickstarts → Next.js → Check it worked** shows the first sign-in that completes through the selected client. Every sign-in also appears under **Audit logs**. ## Next steps - **Permissions in the UI.** `` hides what the user may not see; `zanzibar={…}` and `abac={…}` do the same for relationship and attribute rules. See [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions). - **Permissions on the server.** The pattern for request handlers in the prompt above checks the caller's own token; [`@lumoauth/backend`](https://docs.lumoauth.dev/developer/sdks#javascript--typescript-sdk) runs admin operations with an organization API key. - **Server-rendered sessions.** `@lumoauth/nextjs/server` adds `auth()`, `currentUser()`, `protectPage()` and `lumoAuthMiddleware()` for apps that keep the session in an httpOnly cookie. The [React SDK reference](https://docs.lumoauth.dev/developer/sdks#react-sdk) covers every component and hook. - **Other frameworks.** `lumo init --framework express | fastapi | go` scaffolds the same starter for those stacks; see [`lumo init`](https://docs.lumoauth.dev/cli/init). --- # AI Agent + MCP in 5 Minutes Source: https://docs.lumoauth.dev/quickstarts/ai-agent-mcp-5min Build an AI agent with its own identity that calls an MCP server with a scoped, audited bearer token. Five steps. Real working code. No service accounts. Build an AI agent with **its own identity** that calls an **MCP server** with a **scoped, audited bearer token**. Five steps. Real working code. No service accounts. **Tip: Try it live — no signup** Want to see the whole flow (agent token → MCP metadata → allow/deny → JIT approval) before you write a line of code? **[Open the interactive demo →](https://docs.lumoauth.dev/agents/live-demo)** — a simulated walkthrough for a fictional organization, no account, nothing provisioned. Want the mental model first? [**How LumoAuth secures an agent →**](https://docs.lumoauth.dev/agents/architecture) shows this lifecycle as a diagram and maps every credential/token. **Tip: What you'll have at the end** - A registered MCP server publishing protected resource metadata (the document that tells clients which authorization server protects it) - An agent registered with capabilities and an audit trail - A short-lived MCP-scoped bearer minted by the admin API - An audit-logged tool call attributed to that specific agent - An escalation flow (JIT) for sensitive actions, with a human approver in the loop ```mermaid sequenceDiagram participant Agent participant LumoAuth participant MCP as MCP Server Agent->>LumoAuth: 1. POST /admin/agents/{id}/token LumoAuth-->>Agent: 2. MCP-scoped access token (TTL ≤ 1h) Agent->>MCP: 3. POST /mcp + Authorization: Bearer MCP->>LumoAuth: 4. protected resource metadata / JWKS verify LumoAuth-->>MCP: 5. allow + scopes (audit logged) MCP-->>Agent: 6. tool result ``` Two paths are documented below. **Path A (service-account agent)** is the 5-minute path: admin mints a bearer for the agent, agent calls the MCP server with `Authorization: Bearer …`. **Path B (AAuth, cryptographic identity)** is a production upgrade with Ed25519 keys and signed HTTP requests (every request carries a signature made with the agent's private key). Use Path A first; harden to Path B when you're ready. --- ## Prerequisites - A LumoAuth organization — [sign up](https://app.lumoauth.dev), then copy your **org slug** and an admin Bearer token. Run `lumo login` and copy the token from `~/.lumoauth/credentials.yaml`, or create an admin API key in the portal under **API keys**. - Python 3.10+ **or** Node 20+. Both are shown. - An MCP server that you control. (LumoAuth doesn't currently host a public sample MCP server; start `npx @modelcontextprotocol/server-everything` locally if you need one.) ```bash export LUMO_BASE_URL="https://app.lumoauth.dev" export LUMO_ORG="your-org-slug" export LUMO_ADMIN_TOKEN="..." # Bearer access token from `lumo login` or admin API key ``` > The admin API is mounted under `/orgs/$LUMO_ORG/api/v1/admin/...`. Every URL below uses that prefix. --- ## Step 1 — Register the MCP server (30 seconds, one-time admin) ```bash curl -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/mcp/servers" \ -H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoices MCP", "description": "Internal invoices MCP server", "resource_uri": "https://invoices.example.com/mcp", "endpoint_url": "https://invoices.example.com/mcp", "scopes_supported": ["invoices.read", "invoices.refund"], "transport": "http_streamable", "auth_mode": "oauth" }' ``` Response: ```json { "data": { "server_id": "mcp_01HF...", "name": "Invoices MCP", "resource_uri": "https://invoices.example.com/mcp", "scopes_supported": ["invoices.read", "invoices.refund"] } } ``` Point your MCP server at the URL of the protected resource metadata for token verification: ``` $LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/.well-known/oauth-protected-resource/mcp/ ``` That's the entire MCP-side change. --- ## Step 2 — Register the agent (30 seconds) ```bash curl -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/agents" \ -H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Invoice Analyzer", "description": "Reads invoices on behalf of finance team", "capabilities": ["invoices.read"] }' ``` Response: ```json { "data": { "agentId": "agt_01HF...", "name": "Invoice Analyzer", "capabilities": ["invoices.read"] } } ``` Save `agentId`. The agent is now a first-class principal — searchable in the portal under **AI Agents → Agents**, revocable in one click, audited everywhere. --- ## Step 3 — Mint an MCP-scoped bearer for the agent (Path A) ```bash curl -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/agents/agt_01HF.../token" \ -H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "expiresIn": 600, "scopes": ["invoices.read"], "resource": "https://invoices.example.com/mcp" }' ``` `expiresIn` is the lifetime in seconds (default 3600, at most 24 hours; anything above one hour needs `resource`, because a long-lived token is always bound to one MCP server). `scopes` is optional and can only **narrow** the agent's registered capabilities. Leave it out to get all of them; asking for one that the agent does not have returns a `400`. `resource` is the Resource URI of the MCP server that you registered. The token's audience (`aud`) is then that server, which is what an MCP server checks before it accepts a token. The lifetime is capped at the server's token lifetime. Such a token works only at the MCP server: the LumoAuth API refuses it. A server that requires DPoP is refused here (`400`), because this route cannot bind a token to a key. Response: ```json { "data": { "accessToken": "eyJ...", "tokenType": "Bearer", "expiresIn": 600, "scopes": ["invoices.read"], "resource": "https://invoices.example.com/mcp" } } ``` This is the same code path that the in-portal tour and `lumo agents token` use. **Note: Which token-mint route?** Two routes mint an agent token and are **not** interchangeable by audience: - **`POST /orgs/{orgId}/api/v1/admin/agents/{id}/token`** — the standard **admin API** route (shown here). Requires an admin bearer / API key. Use it for server-side provisioning. - **`POST /orgs/{orgId}/api/v1/agents/{id}/token`** — the route that the **`lumo` CLI** uses (`lumo agents token`), authenticated as the agent itself. Pick the admin path for admin-authenticated automation; use the CLI (or the agent-authenticated path) from the agent side. Don't mix the credential type with the wrong route. --- ## Step 4 — Call the MCP server **Python** ```python import os import requests # bundled with the lumoauth SDK; no extra install needed resp = requests.post( "https://invoices.example.com/mcp", headers={"Authorization": f"Bearer {os.environ['MCP_TOKEN']}"}, json={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_invoices", "arguments": {}}}, ) resp.raise_for_status() print(resp.json()) ``` **Node** ```js const resp = await fetch("https://invoices.example.com/mcp", { method: "POST", headers: { "Authorization": `Bearer ${process.env.MCP_TOKEN}`, "Content-Type": "application/json", }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "list_invoices", arguments: {} }, }), }); console.log(await resp.json()); ``` That's it. Open the LumoAuth portal → **Audit logs** → search for your agent's `agentId` and you see the call, the resource, the scope, the IP, and the result. ![The Audit logs page with its actor, search, event type, status, severity and date range filters](https://docs.lumoauth.dev/img/portal/mcp/audit-logs.png) --- ## Step 5 — JIT permissions for sensitive tools Don't put `invoices.refund` in the agent's standing capabilities. Request it just-in-time, with a human approver in the loop. JIT works on top of `LumoAuthAgent` (the standard agent client): ```python from lumoauth import LumoAuthAgent, JITContext agent = LumoAuthAgent() # reads LUMOAUTH_URL / LUMOAUTH_ORG_ID / agent creds agent.authenticate() with JITContext(agent) as jit: jit.create_task(name="Process refund for ticket #4429") result = jit.request_permission({ "type": "mcp_action", "resource": "https://invoices.example.com/mcp", "actions": ["invoices.refund"], "identifier": "invoice_88231", }, justification="Customer requested refund per ticket #4429") if result["status"] == "approved": elevated = jit.get_token(result["request_id"]) # Use elevated token for the refund call ``` A human approver sees this in the portal under **AI Agents → Approvals** (filter **Permission requests**) — risk score, justification, the agent's recent activity. They click Approve or Deny. Your agent resumes (or aborts). Every decision is logged. ![The Approvals page with its Waiting, Approved and Denied tabs and, under Waiting, the Permission requests, User consents and Action approvals filters](https://docs.lumoauth.dev/img/portal/mcp/approvals.png) --- ## Path B — AAuth (cryptographic agent identity, Ed25519 keys, signed HTTP requests) Use this in production when you don't want to include admin credentials inside your agent. The agent holds an Ed25519 private key; the public JWK is registered with LumoAuth; every token request is signed. **Tip: Python **and** Node** AAuth is included in both official SDKs: **`lumoauth[aauth]`** (Python) and **`@lumoauth/agent`** (Node/TypeScript). Pick your language below. For the full language quickstarts see [Python (AAuth)](https://docs.lumoauth.dev/agents/aauth-qs-python) and [Node.js (AAuth)](https://docs.lumoauth.dev/agents/aauth-qs-nodejs). **Python** ```python from lumoauth import AAuthClient # 1. Generate a keypair (once; persist private_pem in a secret manager) private_pem, jwks = AAuthClient.generate_keypair() # 2. Register the agent with its JWKS URI (instead of the simpler Step 2 above) # POST /admin/agents with identity_type=certificate and jwks_uri=... # 3. At runtime, mint an AAuth token client = AAuthClient( agent_identifier="https://invoice-analyzer.example.com", private_key_pem=private_pem, base_url=os.environ["LUMO_BASE_URL"], org_id=os.environ["LUMO_ORG"], ) # `agent_token` is the agent's own `agent+jwt` (presented in Agent-Auth); # `resource_token` comes from the MCP server's discovery + nonce flow. # request_type=auth requires both; see /agents/aauth for the full Flow 2. tokens = client.request_authorization( resource_token=resource_token, agent_token=agent_token, ) # 4. Make signed calls (Bearer + RFC 9421 HTTP Message Signature) resp = client.signed_request( "POST", "https://invoices.example.com/mcp", auth_token=tokens["auth_token"], data={"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "list_invoices", "arguments": {}}}, ) ``` **Node** ```javascript import { AAuthClient, generateKeypair } from '@lumoauth/agent'; // 1. Generate a keypair (once; persist privateKeyPem in a secret manager) const { privateKeyPem, jwks } = generateKeypair(); // 2. Register the agent with its JWKS (instead of the simpler Step 2 above) // POST /admin/agents with identity_type=certificate and jwks_uri=... // 3. At runtime, mint an AAuth token const client = new AAuthClient({ agentIdentifier: 'https://invoice-analyzer.example.com', privateKeyPem: process.env.AGENT_PRIVATE_KEY, baseUrl: process.env.LUMO_BASE_URL, orgId: process.env.LUMO_ORG, }); // agentToken — the agent's own `agent+jwt` (presented in Agent-Auth) // resourceToken — comes from the MCP server's discovery + nonce flow const result = await client.requestAuthToken({ resourceToken, agentToken }); // 4. Make signed calls (Bearer + RFC 9421 HTTP Message Signature) const resp = await client.signedRequest('POST', 'https://invoices.example.com/mcp', { authToken: result.authToken, data: { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_invoices', arguments: {} } }, }); ``` Each signed call carries the access token as a Bearer credential plus a standard HTTP message signature: a `Signature` header (with `Signature-Input` + `Content-Digest`). The signature covers `@method`, `@authority`, `@path`, `signature-key`, `content-digest`, `content-type`, and `authorization`, with a fresh `created` timestamp and a single-use `nonce`. Replays and tampering are therefore rejected. **Note: Scope comes from the resource token** For `request_type=auth`, LumoAuth derives the granted scope from the **resource token's** `scope` claim — a `scope` field in the request body is ignored. Set the scope when the resource token is minted, not on the auth request. See the [AAuth Protocol reference](https://docs.lumoauth.dev/agents/aauth#step-6-request-authorization-with-user-consent). --- ## What you just got vs. a service-account API key | | Service-account API key | LumoAuth Agent + MCP | |---|---|---| | Identity | "service account X used by 47 services" | `agt_01HF…` — one specific agent | | Scope | All-or-nothing | Per-token, per-resource, time-bound | | Sensitive actions | Same key | JIT + human approval (HITL) | | Revocation | Rotate everywhere | Revoke agent → CAEP invalidates in 30s | | Audit trail | "the API key did it" | Filter `actor_id=agt_…` for every action | | Delegation chain | Untracked | Token exchange records every hop | For the full lifecycle diagram and a map of every credential/token type that an agent uses, see [**How LumoAuth secures an agent**](https://docs.lumoauth.dev/agents/architecture). --- ## Next steps - [Set up `agent.jit_request.created` and `agent.budget_exhausted` webhooks](https://docs.lumoauth.dev/user-guide/integrations/webhooks) - Use the CLI: `lumo agents token agt_… --scopes invoices.read --resource https://invoices.example.com/mcp` ([CLI reference](https://docs.lumoauth.dev/cli/)) - [Multi-agent orchestration with delegation chains (token exchange)](https://docs.lumoauth.dev/agents/delegation) - [Workload attestation for K8s / GCP / AWS / Azure agents](https://docs.lumoauth.dev/agents/attestation) - [The full AAuth spec](https://docs.lumoauth.dev/agents/aauth) - In-portal guided tour: open **Get started** and click **Start the tour** under **AI agent + MCP in 60 seconds** (`/orgs/{orgId}/portal/onboarding/agent-mcp-tour`). Each step creates a real MCP server, agent and token in your organization. ![The AI agent + MCP in 60 seconds tour with its three steps: register an MCP server, register an agent, mint a bearer token](https://docs.lumoauth.dev/img/portal/mcp/agent-mcp-tour.png) ## Common errors | Error | Meaning | Fix | |---|---|---| | `401 Unauthorized` on `/admin/agents/.../token` | Admin token missing or expired | Re-run `lumo login` or rotate your admin API key | | `404 Not Found` on `/admin/mcp/servers` | Admin endpoints are `/orgs/{slug}/api/v1/admin/...`, not `/api/v1/...` | Add the `/admin` segment | | `invalid_request: Missing request_type parameter` | AAuth (Path B) requires `request_type ∈ {auth, code, exchange, refresh}` | Path A doesn't hit `/aauth/...` — verify you're calling `/admin/agents/{id}/token` | | `invalid_resource` | MCP server `resource_uri` typo | `GET /admin/mcp/servers` to list | | `insufficient_scope` | Capability not in agent | Add to agent (admin) or request JIT | | `agent_revoked` | CAEP invalidated mid-flight | Re-mint; check audit log | Error bodies aren't all the same shape. Protocol endpoints (OAuth/AAuth/JIT/MCP token errors) return OAuth-style `{error, error_description}`, while the admin and validation layer returns the richer `{error, message, status, details}` envelope. See the [Errors reference](https://docs.lumoauth.dev/api-reference/errors) for which family each endpoint uses. ## Standards used on this page | Plain name | Standard | |---|---| | Protected resource metadata | RFC 9728 | | Signed HTTP requests (HTTP message signatures) | RFC 9421 | | Token exchange (delegation) | RFC 8693 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # AI Agents Track Source: https://docs.lumoauth.dev/tracks/agents End-to-end path for giving AI agents verified identities, scoped capabilities, and auditable delegation. Use this track when you are building an agent that operates on its own, chains tool calls, or acts on a user's behalf. OAuth alone was not designed for this — you need an identity for the agent itself, not just the user. By the end of this track you will have a registered agent, scoped capabilities, and just-in-time approval connected. **Tip: In a hurry?** Do the [**AI Agent + MCP in 5 Minutes quickstart**](https://docs.lumoauth.dev/quickstarts/ai-agent-mcp-5min) — the flagship path, in the sidebar under Quickstarts → AI Agents. Prefer the mental model first? Read [How LumoAuth secures an agent](https://docs.lumoauth.dev/agents/architecture), or watch the same flow in the [interactive demo](https://docs.lumoauth.dev/agents/live-demo) (simulated, no signup). ## Prerequisites - A LumoAuth organization — [sign up](https://app.lumoauth.dev); agent identity (AAuth) is available on every plan, nothing to switch on - Node.js 18+ or Python 3.9+ --- ## 1. Understand why this matters Read [Why AI Access Control](https://docs.lumoauth.dev/user-guide/getting-started/ai-agents). Key ideas: - **Agent Registration** — every agent is a first-class identity, not a shared API key - **Scoped Capabilities** — agents request specific capabilities (`document.read`, `payments.authorize`), not a blanket "admin" role - **Chain of Agency** — token exchange (swapping one token for another that records who asked whom) encodes the full call chain: "Tool → Agent → User" - **JIT Permissions** — human approval in the loop for sensitive operations ## 2. Register your agent Follow the [AAuth Quick Start](https://docs.lumoauth.dev/agents/aauth-quickstart) — register an agent, generate an Ed25519 key pair, and get your first access token. Ed25519 is a modern public-key signature algorithm; it is fast and produces short keys. The wire format is pinned in the [AAuth 1.0 protocol spec](https://docs.lumoauth.dev/agents/aauth-spec). ## 3. Pick your framework | Framework | Registry | JIT | |---|---|---| | LangChain / LangGraph | [Registry](https://docs.lumoauth.dev/agents/registry-langchain) | [JIT](https://docs.lumoauth.dev/agents/jit-langgraph) | | CrewAI | [Registry](https://docs.lumoauth.dev/agents/registry-crewai) | [JIT](https://docs.lumoauth.dev/agents/jit-crewai) | | OpenAI Agents SDK | [Registry](https://docs.lumoauth.dev/agents/registry-openai-agents) | [JIT](https://docs.lumoauth.dev/agents/jit-openai-agents) | | Agno | [Registry](https://docs.lumoauth.dev/agents/registry-agno) | [JIT](https://docs.lumoauth.dev/agents/jit-agno) | | Google ADK | [Registry](https://docs.lumoauth.dev/agents/registry-google-adk) | [JIT](https://docs.lumoauth.dev/agents/jit-google-adk) | | Microsoft Agent Framework | [Registry](https://docs.lumoauth.dev/agents/registry-msft) | — | ## 4. Authorize agent actions - [Ask API](https://docs.lumoauth.dev/agents/ask-api) — natural-language permission checks, intended for reasoning loops in an LLM - [Fine-grained authorization](https://docs.lumoauth.dev/agents/fine-grained-authz) — the `/authz/check` family answering for agent subjects (ReBAC + ABAC + RBAC) - [JIT Permissions](https://docs.lumoauth.dev/agents/jit) — request human approval before running a sensitive operation - [Chain of Agency](https://docs.lumoauth.dev/agents/delegation) — used when the agent acts on a user's behalf; built on token exchange - [Cross-app access](https://docs.lumoauth.dev/agents/cross-app-access) — an agent authenticated in one app reaching a resource in another, without a second sign-in, via the identity-assertion grant (ID-JAG) - [Token vault](https://docs.lumoauth.dev/agents/token-vault) — store and mint delegated credentials for the third-party APIs that an agent calls ## 5. Approvals in the loop Sensitive actions pause for a human before they run. Before you test this, make sure somebody can approve: 1. The reviewer needs the `jit.approve` permission. Organization admins hold it by default; for other people, create a role that grants only `jit.approve` ([how](https://docs.lumoauth.dev/agents/jit#delegate-approval-to-a-reviewer-role)). 2. The reviewer must not be the owner of the agent, and must not be the user that the agent acts on behalf of. If you registered the agent yourself, you need a second person (or a sandbox organization) to approve its requests. 3. Decide from the portal inbox (`/orgs/{orgId}/portal/approvals`), a phone, or a chat or ticketing tool. The rules are the same everywhere; see [JIT review rules](https://docs.lumoauth.dev/agents/governance#jit-review-rules). ![The Approvals inbox with the Waiting, Approved, Denied and All permission requests tabs and the Everything, Permission requests, User consents and Action approvals filter](https://docs.lumoauth.dev/img/portal/getting-started/approvals.png) - [Async authorization](https://docs.lumoauth.dev/agents/async-authorization) — backchannel (CIBA-style) approval on the user's own device, connected to JIT, with a live-updating approvals inbox - [Push approval](https://docs.lumoauth.dev/agents/push-approval) and [mobile approvals](https://docs.lumoauth.dev/agents/mobile-approvals) — approve or deny agent requests from a phone - [Approval channels](https://docs.lumoauth.dev/integrations/approval-channels) — Slack, Microsoft Teams, ServiceNow and Jira Service Management ## 6. Advanced - [AAuth Protocol](https://docs.lumoauth.dev/agents/aauth) — cryptographic agent identity using signed HTTP requests (every request carries a signature made with the agent's private key) - [Attestation](https://docs.lumoauth.dev/agents/attestation) — attestation-based client authentication and verification of SPIFFE workload-identity tokens (JWT-SVID), so an agent proves where it runs - [Workload Federation](https://docs.lumoauth.dev/agents/workload-federation) — use the platform's own workload identity (Kubernetes, AWS, GCP) so the agent never needs a static secret - [Governance](https://docs.lumoauth.dev/agents/governance) — risk scoring, conditional-access rules (created with **Create rule** on the **Access rules** tab under **Agent rules**), the kill switch, and dormancy lifecycle for your agent fleet - [Shared signals](https://docs.lumoauth.dev/agents/shared-signals) — a shared-signals (SSF/CAEP) transmitter that pushes security events about agents, such as a revoked session, to the apps that rely on them - [MCP Servers](https://docs.lumoauth.dev/mcp) — authorize Model Context Protocol tool calls --- ## Next track - **Building the backend that the agent calls?** → [Backend / Service Track](https://docs.lumoauth.dev/tracks/backend) - **Operating at scale?** → [Security & Compliance Track](https://docs.lumoauth.dev/tracks/compliance) ## Standards used on this page | Plain name | Standard | |---|---| | Token exchange (delegation) | RFC 8693 | | Cross App Access (identity assertion JWT authorization grant, ID-JAG) | Identity Assertion Authorization Grant (draft) | | Client-initiated backchannel authentication (CIBA) | OpenID CIBA Core 1.0 | | Signed HTTP requests (HTTP message signatures) | RFC 9421 | | Shared Signals Framework (SSF) and Continuous Access Evaluation Profile (CAEP) | OpenID SSF 1.0, OpenID CAEP 1.0 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Multi-customer SaaS Track Source: https://docs.lumoauth.dev/tracks/b2b End-to-end path for building a SaaS where each customer is its own organization — organizations, SAML / OIDC SSO, SCIM directory sync, RBAC, and per-customer branded login. Use this track when you are building a SaaS where each customer is its own organization with its own users, SSO, and roles. By the end you will have: - An **Organization** model in your app, mapped to LumoAuth orgs - **Org-aware sign-in** so users arrive in the right organization - **SAML or OIDC SSO** (single sign-on through the customer's own identity provider) connected to your customer's IdP (one-click connection from a metadata URL) - **SCIM 2.0** directory sync (the standard provisioning protocol) so the customer's IT system provisions/deprovisions users automatically - **Org-scoped roles + permissions** that control what each member can do - A **branded login page** per customer (logo, colors, allowed sign-in methods) LumoAuth provides all of these building blocks together: SAML SSO, SCIM, RBAC, per-customer branding and custom domains. Plan availability is listed in the [pricing matrix](https://docs.lumoauth.dev/pricing-matrix). --- ## 1. Model your tenancy Read [Multi-Organization Overview](https://docs.lumoauth.dev/user-guide/multi-tenancy/overview). Decide: - **One LumoAuth org per customer** (recommended for a true multi-customer SaaS — clean isolation, separate audit logs, separate signing keys, separate domain customization). - **One LumoAuth org with multiple Organizations inside it** (use this if your "customers" are sub-organizations under your platform, e.g. agencies with sub-clients). Most multi-customer SaaS goes with the first model. The rest of this track is written for that model. --- ## 2. Provision the customer's organization When a customer signs up: ```bash curl -X POST "$LUMO_BASE_URL/orgs/$YOUR_PLATFORM_ORG/api/v1/admin/organizations" \ -H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "Acme Corp", "slug": "acme-corp" }' ``` The create endpoint accepts `name` (required) plus `slug`, `displayName`, `description`, `logoUrl`, `metadata`, and `isActive`. The response is `{ "data": { ... }, "message": "Organization created" }`; the `data.id` (and `data.slug`) identify the new org — store one on your customer record. Every later call for users, roles or SSO configuration uses this org id. [**Reference →**](https://docs.lumoauth.dev/api-reference/organization) --- ## 3. Connect customer SSO in one click (SAML) {#3-wire-customer-sso-in-one-click-saml} SAML IdP connections are created from the organization **Admin Portal**, not a REST call. Embed the portal in your customer-facing admin UI (an iframe or one-click handover URL). The customer can then paste their own IdP metadata, and you don't have to write the connector config yourself. Send the customer to the **Add SAML identity provider** page: ``` https://app.lumoauth.dev/orgs/{their-org-slug}/portal/saml-idps/new ``` They paste their IdP metadata URL and click **Fetch from metadata URL** (or paste the metadata XML). LumoAuth fetches and parses the Entity ID, SSO URL, and signing certificate. They then click **Add identity provider** to create the connection. The connection list is at `/orgs/{their-org-slug}/portal/saml-idps`. ![The Add SAML identity provider page with the Import from IdP metadata section](https://docs.lumoauth.dev/img/portal/getting-started/saml-idp-create.png) OIDC IdP setup follows the same portal-driven pattern: see [Enterprise SSO](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso). --- ## 4. Direct users to the right org at sign-in When a user arrives at your sign-in page, you have two options: **Option A — Org-scoped sign-in URLs.** Each customer's app uses a URL that includes their org slug: ```ts // In your Next.js app for Acme: ``` **Option B — Email-first sign-in.** First work out which org a user belongs to, from your own customer-to-org mapping or by routing on the email domain in your app. Then call the org-scoped `check-email` endpoint to learn which sign-in methods are enabled for that user, and render the right next step: ```ts const r = await fetch(`${domain}/orgs/${orgSlug}/check-email`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email }), }).then(r => r.json()); // r => { exists, has_passkey, has_push_device, // magic_link_enabled, passkey_enabled, password_enabled, masked_email } if (r.passkey_enabled && r.has_passkey) { // prompt for passkey } else if (r.magic_link_enabled) { // send a magic link } else { // fall back to password } ``` The endpoint is `POST /orgs/{orgSlug}/check-email` (or the global `POST /check-email`); it returns the enabled auth methods, not an SSO redirect. To send a user straight into a SAML IdP, link them to that org's hosted login page or the SP-initiated SSO URL `/orgs/{orgSlug}/saml/sp/login/{idpId}`. --- ## 5. SCIM 2.0 — auto-provision users from the customer's IdP Generate a SCIM bearer token from the organization portal: **Settings → Integrations → SCIM provisioning**, then the **Inbound SCIM** page, using the **Generate bearer token** action: ``` https://app.lumoauth.dev/orgs/{their-org-slug}/portal/integrations/scim ``` (The portal posts to `/orgs/{their-org-slug}/portal/integrations/scim/generate-token`; **Regenerate token** posts to `.../scim/regenerate-token` to rotate it.) Turn on **Enable SCIM provisioning** on the same page and click **Save changes**. Then give the customer: - **SCIM 2.0 base URL:** `https://app.lumoauth.dev/orgs/{their-org-slug}/api/v1/scim2.0` - **Bearer token:** the token shown after generating ![The Inbound SCIM page with the SCIM base URL and resource endpoints](https://docs.lumoauth.dev/img/portal/getting-started/scim-inbound.png) Their IdP (Okta, Azure AD, Google Workspace, JumpCloud) syncs users + groups to LumoAuth automatically. [Full SCIM reference →](https://docs.lumoauth.dev/scim) --- ## 6. Roles + permissions per org Each org has its own role set. Seed standard roles: ```bash for role in admin member viewer; do curl -X POST "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/roles" \ -H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d "{\"name\": \"$role\", \"slug\": \"$role\"}" done ``` In your app, protect features with the React permission hooks. For a simple role/permission slug, use `usePermission`: ```tsx import { usePermission } from "@lumoauth/react"; const { allowed, isLoading } = usePermission("billing.manage"); if (isLoading) return null; if (!allowed) return ; ``` For relationship-based checks (resource-level sharing, group inheritance), use `useZanzibar`, which takes a `{ object, relation, subject }` tuple: ```tsx import { useZanzibar } from "@lumoauth/react"; const { allowed } = useZanzibar({ object: `billing:${orgId}`, relation: "manage", subject: `user:${user.id}`, }); if (!allowed) return ; ``` See [Zanzibar tuples](https://docs.lumoauth.dev/authorization/zanzibar) for the relationship model. For attribute-based rules ("only managers in EU can see EU customers"), use `useAbac` and [ABAC policies](https://docs.lumoauth.dev/authorization/abac). --- ## 7. Brand the login per customer Each org's hosted login page can be restyled (logo, primary color, button text, allowed sign-in methods, optional self-registration). Add the customer's logo URL + brand color to your provisioning flow: ```bash curl -X PATCH "$LUMO_BASE_URL/orgs/$LUMO_ORG/api/v1/admin/settings/branding" \ -H "Authorization: Bearer $LUMO_ADMIN_TOKEN" \ -H "Content-Type: application/json" \ -d '{"logo_url": "https://acme.com/logo.png", "primary_color": "#FF6B35"}' ``` Per-OAuth-client overrides also work: a customer's mobile app can have different branding than their web app within the same org. --- ## 8. Webhooks for org lifecycle Subscribe to the events that your app needs (standard names from the [event catalogue](https://docs.lumoauth.dev/user-guide/integrations/webhooks)): - `organization.created`, `organization.updated`, `organization.deleted` - `organization.member.added`, `organization.member.removed`, `organization.member.updated` - `user.created`, `user.deleted` — fire for SCIM-driven provisioning and deprovisioning too - `group.member_added`, `group.member_removed` [Full event catalogue →](https://docs.lumoauth.dev/user-guide/integrations/webhooks) --- ## Next steps - [Multi-Organization architecture →](https://docs.lumoauth.dev/user-guide/multi-tenancy/overview) - [SAML / OIDC IdP configuration →](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso) - [SCIM 2.0 inbound + outbound →](https://docs.lumoauth.dev/scim) - [Authorization API (RBAC + ABAC + Zanzibar) →](https://docs.lumoauth.dev/authorization) - [Webhooks reference →](https://docs.lumoauth.dev/user-guide/integrations/webhooks) --- # Frontend / Mobile Track Source: https://docs.lumoauth.dev/tracks/frontend End-to-end path for adding LumoAuth login to a React, Next.js, Vue, Angular, or React Native app. Use this track when you are adding user login to a single-page app or a mobile app. By the end you will have a working login, issued tokens, and optional MFA. ## Prerequisites - A LumoAuth organization — [sign up](https://app.lumoauth.dev) - A public OAuth client (no client secret) with PKCE enabled. PKCE is a one-time secret that protects the sign-in code: the app generates it, so an intercepted code cannot be exchanged for tokens. --- ## 1. Learn the model Read [Core Concepts](https://docs.lumoauth.dev/user-guide/getting-started/concepts) to understand organizations, users, OAuth clients, and tokens. Then skim [Authentication Overview](https://docs.lumoauth.dev/user-guide/authentication/overview) to pick the flow that fits your app. For any browser or mobile client, that flow is almost always **Authorization Code + PKCE**. ## 2. Register your application Follow [Your first 15 minutes in the portal](https://docs.lumoauth.dev/user-guide/getting-started/first-tenant) to sign up, create your first organization, and register an OAuth application. On the **Create application** form, pick **Single-page or mobile app** under **What the application is**. That creates a public client that signs in with Authorization Code + PKCE. Then set the **Redirect URI** to match the URL that your app listens on for the OAuth callback. ![The Create application form with the application scenarios and the redirect URI field](https://docs.lumoauth.dev/img/portal/getting-started/application-create.png) ## 3. Run a framework quickstart Each quickstart runs end-to-end, with code that you can copy and paste. | Framework | Guide | |---|---| | React | [React quickstart](https://docs.lumoauth.dev/quickstarts/react) | | Next.js | [Next.js quickstart](https://docs.lumoauth.dev/quickstarts/nextjs) | | Vue.js | [Vue quickstart](https://docs.lumoauth.dev/quickstarts/vue) | | Angular | [Angular quickstart](https://docs.lumoauth.dev/quickstarts/angular) | | React Native | [React Native quickstart](https://docs.lumoauth.dev/quickstarts/react-native) | ## 4. Harden the login - [Passkeys / WebAuthn](https://docs.lumoauth.dev/user-guide/authentication/passkeys) — phishing-resistant passwordless login with a device biometric or security key - [Social Login](https://docs.lumoauth.dev/user-guide/authentication/social-login) — Google, GitHub, Microsoft, Apple - [MFA](https://docs.lumoauth.dev/user-guide/authentication/mfa) and [Adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa) — step up when risk is higher ## 5. Ship to production - Keep access tokens short-lived and use refresh-token rotation - Use exact-match redirect URIs (no wildcards) so attackers cannot redirect the code to a URL that they control - Validate the OAuth `state` parameter on the callback to prevent CSRF - See [Security Best Practices](https://docs.lumoauth.dev/user-guide/security/best-practices) --- ## Next track - **Your backend needs to validate tokens?** → [Backend / Service Track](https://docs.lumoauth.dev/tracks/backend) - **Selling to enterprise customers?** → [Admin / IT Track](https://docs.lumoauth.dev/tracks/admin) ## Standards used on this page | Plain name | Standard | |---|---| | Proof Key for Code Exchange (PKCE) | RFC 7636 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Backend / Service Track Source: https://docs.lumoauth.dev/tracks/backend End-to-end path for protecting APIs and service-to-service calls with LumoAuth. Use this track when you are protecting a REST or GraphQL API, a background worker, or a service-to-service call. By the end you will be issuing and validating tokens and making authorization decisions from your service. ## Prerequisites - A LumoAuth organization — [sign up](https://app.lumoauth.dev) - A confidential OAuth client (client ID + client secret). "Confidential" means the client can safely hold a secret, unlike a browser or mobile app. It is therefore allowed to use client-credential based grants. --- ## 1. Learn the model - [Core Concepts](https://docs.lumoauth.dev/user-guide/getting-started/concepts) — organizations, users, OAuth clients, tokens - [Authorization Overview](https://docs.lumoauth.dev/authorization) — when to pick RBAC, ABAC, or Zanzibar ## 2. Get an access token (Client Credentials) The `client_credentials` grant is part of core OAuth 2.0 (the sign-in and token protocol that every flow on this site builds on). It issues a token to the service itself, with no end user involved — useful for cron jobs, server-to-server calls, and background workers. ```bash curl -X POST https://app.lumoauth.dev/orgs/YOUR_ORG/api/v1/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=client_credentials" \ -d "client_id=YOUR_CLIENT_ID" \ -d "client_secret=YOUR_CLIENT_SECRET" \ -d "scope=read:users" ``` The response is a JWT `access_token` scoped to your app. See the [Token Endpoint reference](https://docs.lumoauth.dev/oauth/token) for all supported grants (`authorization_code`, `refresh_token`, `token-exchange`, CIBA, device). **Tip: Calling the Admin API from a service?** Pick the narrowest credential. An **API key** with one resource scope (for example `admin:users:read`) is the simplest. If you use an OAuth token instead, it must carry `admin` or `admin::`; a plain `openid profile email` token gets `403`. Neither credential ever creates a browser session. See [Credentials and access model](https://docs.lumoauth.dev/user-guide/security/credential-model) and [Admin API authentication](https://docs.lumoauth.dev/api-reference/authentication#admin-api-scopes-for-bearer-tokens). ## 3. Validate tokens in your service Pick one of two strategies: | Strategy | When to use | Endpoint | |---|---|---| | Local JWT validation | High-throughput services; no network hop per request | [`.well-known/jwks.json`](https://docs.lumoauth.dev/user-guide/applications/signing-keys) (JWKS, the list of public keys used to verify tokens) | | Token introspection | You need instant revocation to take effect | [`/oauth/introspect`](https://docs.lumoauth.dev/oauth/introspect) | With local validation, your service fetches the public keys from `jwks.json` (cached), then verifies the JWT (signed token) signature offline. With introspection, your service asks LumoAuth on every request whether the token is still valid. That is slower, but LumoAuth can deny a revoked token immediately. Whichever you pick, check `iss` against the `issuer` in the discovery document (byte-compare), `aud` against your resource, and `exp`. The issuer is `https://app.lumoauth.dev/orgs/YOUR_ORG/api/v1`, or your bare custom domain, and is identical everywhere a token or introspection response is produced. **Info: Why the issuer check matters** The issuer identifier in the response prevents mix-up attacks, where a code or token from one authorization server is presented to a client expecting another. The one thing to do: compare `iss` with the discovery `issuer` before trusting anything else in the token. See the [standards reference](https://docs.lumoauth.dev/reference/standards) for the specification behind it. ## 4. Make authorization decisions Your service now has an authenticated caller. Decide what they are allowed to do. - **Simple role checks:** [Permissions API](https://docs.lumoauth.dev/authorization/permissions) - **Contextual rules (time of day, IP, resource attributes):** [ABAC Policies](https://docs.lumoauth.dev/authorization/abac) - **Relationship-based (owner / team / folder hierarchy):** [Zanzibar / ReBAC](https://docs.lumoauth.dev/authorization/zanzibar) - **Natural-language rules:** [AI Policy Authoring](https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring) ## 5. Production checklist - [Credentials and access model](https://docs.lumoauth.dev/user-guide/security/credential-model) — least-privilege keys, admin scopes, stateless credentials - [Security best practices](https://docs.lumoauth.dev/user-guide/security/best-practices) — the integrator checklist - [Rate Limiting](https://docs.lumoauth.dev/user-guide/security/rate-limiting) - [Webhooks](https://docs.lumoauth.dev/user-guide/integrations/webhooks) — receive user-lifecycle events - [Audit Logs](https://docs.lumoauth.dev/user-guide/compliance/audit-logs) — record of who did what - [Observability](https://docs.lumoauth.dev/user-guide/integrations/observability) — Datadog and Axiom integration - [SDKs & Libraries](https://docs.lumoauth.dev/developer/sdks) ## Common mistakes - **`403 forbidden` on `/api/v1/admin/...` with a valid OAuth token.** The token lacks an admin scope. Add `admin::` to a first-party client's allowed scopes and request it, or use an API key. - **`403 insufficient scope` with an API key on a `POST`.** The key only has a `:read` scope for that resource. - **Expecting a session cookie after an API call.** API keys and Bearer tokens are stateless by design; send the credential on every request. - **Pinning the issuer to an old value.** Read `issuer` from discovery instead of hard-coding it. --- ## Next track - **Agents or LLM tool calls?** → [AI Agents Track](https://docs.lumoauth.dev/tracks/agents) - **Enterprise SSO and SCIM?** → [Admin / IT Track](https://docs.lumoauth.dev/tracks/admin) ## Standards used on this page | Plain name | Standard | |---|---| | OAuth 2.0 (the core sign-in and token protocol) | RFC 6749 | | Token introspection | RFC 7662 | | Issuer identifier in the response | RFC 9207 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Admin / IT Track Source: https://docs.lumoauth.dev/tracks/admin End-to-end path for configuring LumoAuth for enterprise customers — SSO, SCIM, custom domains, users and roles. Use this track to configure LumoAuth for your organization — or for one of your customers' organizations. It covers the portal, enterprise integrations, and day-2 operations. ## Prerequisites - Admin access to your LumoAuth organization - An existing identity provider (Okta, Azure AD, Google Workspace, or any SAML/OIDC IdP) if you plan to set up SSO. SSO is single sign-on: your users sign in with the account that they already have. --- ## 1. Set up your organization - [Organization Setup](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-setup) — create and configure the organization - [Organization Portal](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-portal) — tour of the admin dashboard - [Core Concepts](https://docs.lumoauth.dev/user-guide/getting-started/concepts) — organizations, users, roles, clients ### Create an API key with only the scopes that you need {#create-an-api-key-with-only-the-scopes-you-need} Most automation (the CLI, Terraform, scripts) authenticates with an organization API key. Create it under **Applications & developer tools → API keys** (`/orgs/{orgId}/portal/settings/api-keys`, **Create API key**). Tick only the scopes that the job needs, for example `admin:users:read` for a reporting script. A key with a `:read` scope cannot change data; a key with one resource scope cannot reach other resources. Keys never create a browser session and are refused on the portal itself. ![The Create API key form with the key name filled in](https://docs.lumoauth.dev/img/portal/getting-started/api-key-create.png) - [API Keys](https://docs.lumoauth.dev/user-guide/applications/api-keys) — scopes catalogue and rotation - [Credentials and access model](https://docs.lumoauth.dev/user-guide/security/credential-model) — which credential works where ### Two settings to check once | Setting | Default | Where | Why it matters | |---|---|---|---| | `allow_root_admin_login` | off | Not configurable by organizations; changed only by LumoAuth operators at your request (see [Organization settings](https://docs.lumoauth.dev/api-reference/organization#allow_root_admin_login-read-only)) | Lets LumoAuth operators sign in through your hosted login page. Every such login is audited at `critical` severity. | | SCIM policy toggles | creation, updates, deletion and group operations on; no protected attributes; deprovisioning = soft delete | `/orgs/{orgId}/portal/integrations/scim` | Enforced by the SCIM API. Before connecting an IdP, decide which operations it may perform and which attributes (e-mail, name, active) it may overwrite. | ![The Provisioning section of the Inbound SCIM page with the policy toggles](https://docs.lumoauth.dev/img/portal/getting-started/scim-inbound-provisioning.png) ## 2. Enterprise SSO Bring your existing identity provider so end users sign in with their corporate account. - [Enterprise SSO overview](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso) - [SAML as Service Provider](https://docs.lumoauth.dev/saml/sp) — LumoAuth accepts identities from Okta, ADFS, or Azure AD - [SAML as Identity Provider](https://docs.lumoauth.dev/saml/idp) — LumoAuth issues SAML assertions to downstream apps - [SAML Metadata](https://docs.lumoauth.dev/saml/metadata) ## 3. Automated provisioning (SCIM) SCIM 2.0 is the standard user-provisioning protocol used by IdPs like Okta or Azure AD. The IdP uses it to create, update, and deactivate users in LumoAuth automatically when they are added or removed in the IdP. - [SCIM 2.0 overview](https://docs.lumoauth.dev/user-guide/integrations/scim) — generate the provisioning token and set the policy toggles - [SCIM Users API](https://docs.lumoauth.dev/scim/users), [Groups](https://docs.lumoauth.dev/scim/groups), [Bulk](https://docs.lumoauth.dev/scim/bulk) Authenticate your IdP with the **SCIM provisioning token** (or an access token carrying the `scim` scope). HTTP Basic with an admin's password is refused once that admin sets up MFA. ## 4. Users, roles, groups - [Invitations](https://docs.lumoauth.dev/user-guide/user-management/invitations) - [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) - [Groups](https://docs.lumoauth.dev/user-guide/access-control/groups) - [Organizations](https://docs.lumoauth.dev/user-guide/user-management/organizations) — customer organizations inside your organization ## 5. Branding and domains - [Login Page & Branding](https://docs.lumoauth.dev/user-guide/multi-tenancy/branding) - [Custom Domains](https://docs.lumoauth.dev/user-guide/multi-tenancy/custom-domains) - [Email Templates](https://docs.lumoauth.dev/user-guide/integrations/email-templates) --- ## Next track - **Compliance review?** → [Security & Compliance Track](https://docs.lumoauth.dev/tracks/compliance) - **Setting up your developers?** → [Frontend Track](https://docs.lumoauth.dev/tracks/frontend) or [Backend Track](https://docs.lumoauth.dev/tracks/backend) ## Standards used on this page | Plain name | Standard | |---|---| | SCIM 2.0 (user and group provisioning) | RFC 7642, RFC 7643, RFC 7644 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Security & Compliance Track Source: https://docs.lumoauth.dev/tracks/compliance What to read first if you're doing a security review, compliance audit, or GDPR assessment of LumoAuth. Use this track when you are reviewing LumoAuth for procurement, a security audit, or a compliance program (GDPR, SOC 2, FAPI 2.0). It lists the pages that you need and skips the rest. --- ## 1. Start here - [Security Overview](https://docs.lumoauth.dev/user-guide/security/overview) — threat model and defense-in-depth layers - [Credentials and access model](https://docs.lumoauth.dev/user-guide/security/credential-model) — which credential reaches which surface; API keys and tokens never become browser sessions - [Best Practices](https://docs.lumoauth.dev/user-guide/security/best-practices) — the short checklist for integrators and admins - [Security audits](https://docs.lumoauth.dev/developer/security-audits) — review history, including the 2026-09-16 independent review (no cross-organization data access found; same-day remediation; weekly dependency audit in CI) ## 2. Data protection & GDPR - [GDPR features](https://docs.lumoauth.dev/user-guide/compliance/gdpr) — data subject requests, consent tracking, breach reporting - [Data Export](https://docs.lumoauth.dev/user-guide/compliance/data-export) - [Audit Logs](https://docs.lumoauth.dev/user-guide/compliance/audit-logs) — append-only record of every operation - [Analytics](https://docs.lumoauth.dev/user-guide/compliance/analytics) ## 3. Authentication hardening - [Adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa) — risk-based step-up - [Passkeys / WebAuthn](https://docs.lumoauth.dev/user-guide/authentication/passkeys) — phishing-resistant login; user verification is always required - [Rate Limiting](https://docs.lumoauth.dev/user-guide/security/rate-limiting) — shared lockout accounting across form, hosted OAuth and LDAP login - [SCIM provisioning](https://docs.lumoauth.dev/user-guide/integrations/scim) — the API enforces the per-organization policy toggles (creation, updates, deletion, group operations, protected attributes) ## 4. High-assurance OAuth - [FAPI 2.0 Security Profile](https://docs.lumoauth.dev/oauth/fapi) — profile for financial-grade and regulated deployments - [PAR — Pushed Authorization Requests](https://docs.lumoauth.dev/oauth/par) - [Sender-constrained tokens (DPoP) & mutual TLS (FAPI profile)](https://docs.lumoauth.dev/oauth/fapi) ## 5. AI agent security - [AAuth Protocol](https://docs.lumoauth.dev/agents/aauth) — cryptographic agent identity - [Chain of Agency](https://docs.lumoauth.dev/agents/delegation) — auditable delegation via token exchange - [JIT Permissions](https://docs.lumoauth.dev/agents/jit) — human-in-the-loop for sensitive operations --- ## Standards implemented LumoAuth implements the following IETF and OpenID standards. The [standards reference](https://docs.lumoauth.dev/reference/standards) lists every specification with a link to the official text. The table at the end of this page maps the names below to their specification numbers. - **OAuth 2.0** — The core sign-in and token protocol that defines the `authorize` and `token` endpoints and the standard grant types. - **OpenID Connect 1.0** — An authentication layer on top of OAuth 2.0. Adds ID tokens and the `userinfo` endpoint. - **SAML 2.0** — An XML-based single sign-on protocol used widely in enterprise environments. - **SCIM 2.0** — The standard API for provisioning users and groups from an identity provider (one part defines the user and group schema, the other the HTTP protocol). - **PKCE** — A one-time secret that protects the sign-in code: an intercepted code cannot be exchanged for tokens. Required for public clients (SPAs, mobile apps). - **Sender-constrained tokens (DPoP)** — Binds an access token to a client-held key, so a stolen token cannot be replayed from another host. - **Pushed authorization requests (PAR)** — The client sends authorization parameters over a back-channel POST instead of a front-channel URL, avoiding URL-length limits and request tampering. - **Rich authorization requests** — Lets the client request fine-grained, structured permissions (e.g., "transfer €500 to IBAN X") instead of coarse scope strings. - **Token exchange** — Allows a service to exchange one token for another — the building block for delegation and impersonation. - **Device authorization** — The flow used by CLIs, smart TVs, and other input-constrained devices: the device shows a code, the user enters it on a second device. - **CIBA** — Client-initiated backchannel authentication. The client initiates login, the user authenticates out-of-band (e.g., via a push notification), and the client polls for the result. - **FAPI 2.0** — A security profile from the OpenID Foundation tightening OAuth for financial-grade and regulated use cases. - **Passkeys (WebAuthn / FIDO2)** — Standard for hardware-backed, phishing-resistant authentication (passkeys, security keys). ## Standards used on this page | Plain name | Standard | |---|---| | OAuth 2.0 (the core sign-in and token protocol) | RFC 6749 | | OpenID Connect (OIDC) | OpenID Connect Core 1.0 | | SAML 2.0 | OASIS SAML 2.0 | | SCIM 2.0 (user and group provisioning) | RFC 7642, RFC 7643, RFC 7644 | | Proof Key for Code Exchange (PKCE) | RFC 7636 | | Sender-constrained tokens (DPoP) | RFC 9449 | | Pushed authorization requests (PAR) | RFC 9126 | | Rich authorization requests | RFC 9396 | | Token exchange (delegation) | RFC 8693 | | Device authorization (sign in on another device) | RFC 8628 | | Client-initiated backchannel authentication (CIBA) | OpenID CIBA Core 1.0 | | Financial-grade API (FAPI) 2.0 | FAPI 2.0 Security Profile | | Passkeys (WebAuthn and FIDO2) | W3C Web Authentication, FIDO CTAP | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Add authentication to your React app Source: https://docs.lumoauth.dev/quickstarts/react This guide shows you how to add authentication, user management, and access control to a React application using the @lumoauth/react SDK. Prompt for your AI editor: ```text Set up LumoAuth authentication in a React app. Install the SDK: npm install @lumoauth/react Wrap the app with the provider in main.tsx or App.tsx: import { LumoAuthProvider } from '@lumoauth/react'; Create a sign-in page: import { SignIn } from '@lumoauth/react'; export default function SignInPage() { return ; } Create a sign-up page: import { SignUp } from '@lumoauth/react'; export default function SignUpPage() { return ; } Add a callback route to handle the OAuth redirect: import { AuthCallback } from '@lumoauth/react'; export default function CallbackPage() { return ; } Use conditional rendering to protect content: import { SignedIn, SignedOut, RedirectToSignIn, UserButton } from '@lumoauth/react'; function Layout() { return ( <> ); } Use the Protect component for fine-grained access control: import { Protect } from '@lumoauth/react'; No access

}>
Rules: - Always wrap the app root with LumoAuthProvider - Use SignedIn / SignedOut for layout-level auth gating - Use Protect for permission-based access control - Use useAuth() hook to access signOut, getToken, isSignedIn - Use useUser() hook to get the current user object - The AuthCallback component must be mounted on your redirect URI route Deprecated patterns (do not use): - Do not manually call fetch against /oauth/token; use the built-in auth flow - Do not store tokens in localStorage; the SDK manages token storage Verification: 1. The app compiles without errors 2. Unauthenticated users are redirected to sign-in 3. After sign-in, the user sees the dashboard with UserButton 4. The Protect component hides content when permission is denied ``` This guide shows you how to add authentication, user management, and access control to a React application using the `@lumoauth/react` SDK. --- ## Before you start You need: - A LumoAuth account with an organization ([sign up](https://app.lumoauth.dev)) - A registered OAuth application with a redirect URI (e.g., `http://localhost:5173/auth/callback`) - Node.js 18+ To register the application, open **Applications** in the portal sidebar and click **New application**. Enter an **Application name**, choose **Single-page or mobile app**, enter the **Redirect URI**, and click **Create application**. ![The Create application form with Single-page or mobile app selected and a redirect URI entered](https://docs.lumoauth.dev/img/portal/quickstarts/application-create-spa.png) This app finishes sign-in in the browser, so also add its origin (for example `http://localhost:5173`). Enter it under **Browser CORS origins** on the application's **Configuration** tab and click **Save configuration**. Otherwise the token request is blocked by CORS right after sign-in. ![The Browser CORS origins section on an application's Configuration tab](https://docs.lumoauth.dev/img/portal/quickstarts/application-cors-origins.png) From your organization portal, note your **Organization ID** and **Client ID**. The **Quickstarts** page (under **Applications & developer tools**) has a **React** tab that shows your organization ID and client ID pre-filled, ready to copy. ![The Quickstarts page with the React tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-react.png) The tab's first step lists only OAuth clients of the kind that these snippets need. If none exists, **Create one for this framework** opens the create form with the right type already selected and brings you back. Its last step, **Check it worked**, shows the first sign-in that completes through the selected client. --- ## Install the SDK ```bash npm install @lumoauth/react ``` The routing example below uses [React Router](https://reactrouter.com/). If your app doesn't already have it, install it too: ```bash npm install react-router ``` --- ## Set up the provider Wrap your app with `LumoAuthProvider`. This makes authentication state available throughout your component tree. ```tsx title="src/main.tsx" import ReactDOM from 'react-dom/client'; import { LumoAuthProvider } from '@lumoauth/react'; import App from './App'; ReactDOM.createRoot(document.getElementById('root')!).render( ); ``` --- ## Add sign-in and sign-up pages The SDK provides pre-built components that handle the full authentication flow, including social login buttons when configured in your organization. ```tsx title="src/pages/SignInPage.tsx" import { SignIn } from '@lumoauth/react'; export default function SignInPage() { return (
); } ``` ```tsx title="src/pages/SignUpPage.tsx" import { SignUp } from '@lumoauth/react'; export default function SignUpPage() { return (
); } ``` --- ## Handle the OAuth callback Mount `AuthCallback` on the route matching your redirect URI. It exchanges the authorization code for tokens and redirects the user. ```tsx title="src/pages/CallbackPage.tsx" import { AuthCallback } from '@lumoauth/react'; export default function CallbackPage() { return ( Signing you in...

} error={(err) =>

Something went wrong: {err}

} /> ); } ``` --- ## Protect your routes Use `SignedIn` and `SignedOut` to conditionally render content based on authentication status. ```tsx title="src/App.tsx" import { BrowserRouter, Routes, Route } from 'react-router'; import { SignedIn, SignedOut, RedirectToSignIn, UserButton } from '@lumoauth/react'; import SignInPage from './pages/SignInPage'; import SignUpPage from './pages/SignUpPage'; import CallbackPage from './pages/CallbackPage'; import Dashboard from './pages/Dashboard'; export default function App() { return (
} /> } /> } /> } />
); } ``` --- ## Use hooks for auth state Access the current user, sign-out function, and tokens from any component. ```tsx title="src/pages/Dashboard.tsx" import { useAuth, useUser } from '@lumoauth/react'; export default function Dashboard() { const { signOut, getToken } = useAuth(); const user = useUser(); const callApi = async () => { const token = await getToken(); const res = await fetch('https://api.myapp.com/data', { headers: { Authorization: `Bearer ${token}` }, }); const data = await res.json(); console.log(data); }; return (

Welcome, {user?.displayName}

Email: {user?.email}

); } ``` --- ## Add access control Use the `Protect` component or authorization hooks to protect features by permission, relationship, or policy. ```tsx title="src/components/AdminPanel.tsx" import { Protect, usePermission } from '@lumoauth/react'; export function AdminPanel() { return ( You don't have access to this section.

}>

Admin Panel

Only members with the admin.access permission can see this.

); } export function EditButton() { const { allowed, isLoading } = usePermission('documents.edit'); if (isLoading) return null; if (!allowed) return null; return ; } ``` --- ## What's next? - [Configure social login](https://docs.lumoauth.dev/user-guide/authentication/social-login) in your organization to show social buttons on SignIn/SignUp - [Set up MFA](https://docs.lumoauth.dev/user-guide/authentication/mfa) for additional security - [Define roles and permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) to use with the Protect component - [SDKs & Libraries](https://docs.lumoauth.dev/developer/sdks) - Full SDK API reference --- # Add authentication to your Vue.js app Source: https://docs.lumoauth.dev/quickstarts/vue This guide shows you how to add authentication and access control to a Vue.js application using @lumoauth/client with the Vue Composition API. Prompt for your AI editor: ```text Set up LumoAuth authentication in a Vue.js application using Composition API. Install the SDK: npm install @lumoauth/client Configure .env for Vite: VITE_LUMOAUTH_DOMAIN=https://app.lumoauth.dev VITE_LUMOAUTH_ORG_ID=YOUR_ORG_ID VITE_LUMOAUTH_CLIENT_ID=YOUR_CLIENT_ID Create src/composables/useLumoAuth.ts: import { inject, provide, ref, type InjectionKey } from 'vue'; import { LumoAuth, AuthModule } from '@lumoauth/client'; const LumoAuthKey: InjectionKey = Symbol('LumoAuth'); const AuthModuleKey: InjectionKey = Symbol('AuthModule'); export function provideLumoAuth() { const domain = import.meta.env.VITE_LUMOAUTH_DOMAIN as string; const orgId = import.meta.env.VITE_LUMOAUTH_ORG_ID as string; const clientId = import.meta.env.VITE_LUMOAUTH_CLIENT_ID as string; const authModule = new AuthModule({ baseUrl: domain, orgId, clientId }); const client = new LumoAuth({ baseUrl: domain, orgId, clientId, token: () => sessionStorage.getItem('lumoauth_access_token') || '', }); provide(LumoAuthKey, client); provide(AuthModuleKey, authModule); return { client, authModule }; } export function useLumoAuth() { const client = inject(LumoAuthKey); if (!client) throw new Error('Call provideLumoAuth() in a parent component'); return client; } export function useAuthModule() { const auth = inject(AuthModuleKey); if (!auth) throw new Error('Call provideLumoAuth() in a parent component'); return auth; } Call provideLumoAuth() in App.vue: Trigger PKCE sign-in: import { useAuthModule } from './composables/useLumoAuth'; const auth = useAuthModule(); async function signIn() { const redirectUri = window.location.origin + '/auth/callback'; const { url, codeVerifier, state } = await auth.buildAuthorizationUrl({ redirectUri }); sessionStorage.setItem('lumoauth_pkce_verifier', codeVerifier); sessionStorage.setItem('lumoauth_pkce_state', state); window.location.href = url; } Handle the callback in AuthCallback.vue (mounted at /auth/callback): const params = new URLSearchParams(window.location.search); const code = params.get('code'); const returnedState = params.get('state'); const codeVerifier = sessionStorage.getItem('lumoauth_pkce_verifier'); const savedState = sessionStorage.getItem('lumoauth_pkce_state'); if (!code || returnedState !== savedState || !codeVerifier) { router.push('/'); } else { const tokens = await auth.exchangeCodeForTokens({ code, codeVerifier, redirectUri: window.location.origin + '/auth/callback', }); sessionStorage.setItem('lumoauth_access_token', tokens.access_token); sessionStorage.removeItem('lumoauth_pkce_verifier'); sessionStorage.removeItem('lumoauth_pkce_state'); router.push('/dashboard'); } Check RBAC permissions: const client = useLumoAuth(); const canManageBilling = ref(false); onMounted(async () => { canManageBilling.value = await client.permissions.check('billing.manage'); }); Check Zanzibar (ReBAC): const allowed = await client.zanzibar.check({ object: 'document:doc-123', relation: 'editor', subject: 'user:u-456', }); Sign out: import { useAuthModule } from './composables/useLumoAuth'; const auth = useAuthModule(); async function signOut() { const token = sessionStorage.getItem('lumoauth_access_token'); if (token) auth.revokeToken(token, token).catch(() => {}); sessionStorage.removeItem('lumoauth_access_token'); sessionStorage.removeItem('lumoauth_refresh_token'); window.location.href = '/'; } Rules: - Call provideLumoAuth() once in the root App.vue component - Use useLumoAuth() for permission/authorization checks - Use useAuthModule() for PKCE sign-in and token exchange - Always validate PKCE state parameter in the callback Deprecated patterns (do not use): - Do not manually construct OAuth token requests; use authModule.exchangeCodeForTokens() - Do not store tokens in localStorage; use sessionStorage Verification: 1. The app compiles without errors 2. Clicking sign-in redirects to LumoAuth 3. After sign-in callback, the user is redirected to /dashboard 4. Permission checks return correct booleans 5. Sign-out clears tokens and redirects to home ``` This guide shows you how to add authentication and access control to a Vue.js application using `@lumoauth/client` with the Vue Composition API. --- ## Before you start You need: - A LumoAuth account with an organization ([sign up](https://app.lumoauth.dev)) - A registered OAuth application with a redirect URI (e.g., `http://localhost:5173/auth/callback`) - Vue 3 + Vite To register the application, open **Applications** in the portal sidebar and click **New application**. Enter an **Application name**, choose **Single-page or mobile app**, enter the **Redirect URI**, and click **Create application**. ![The Create application form with Single-page or mobile app selected and a redirect URI entered](https://docs.lumoauth.dev/img/portal/quickstarts/application-create-spa.png) This app finishes sign-in in the browser, so also add its origin (for example `http://localhost:5173`). Enter it under **Browser CORS origins** on the application's **Configuration** tab and click **Save configuration**. Otherwise the token request is blocked by CORS right after sign-in. ![The Browser CORS origins section on an application's Configuration tab](https://docs.lumoauth.dev/img/portal/quickstarts/application-cors-origins.png) --- ## Install the SDK ```bash npm install @lumoauth/client ``` --- ## Environment variables The **Quickstarts** page (under **Applications & developer tools**) has a **Vue** tab that shows your organization ID and client ID pre-filled, ready to copy. ![The Quickstarts page with the Vue tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-vue.png) The tab's first step lists only OAuth clients of the kind that these snippets need. If none exists, **Create one for this framework** opens the create form with the right type already selected and brings you back. Its last step, **Check it worked**, shows the first sign-in that completes through the selected client. ```env VITE_LUMOAUTH_DOMAIN=https://app.lumoauth.dev VITE_LUMOAUTH_ORG_ID=YOUR_ORG_ID VITE_LUMOAUTH_CLIENT_ID=YOUR_CLIENT_ID ``` --- ## Set up the composable Create `src/composables/useLumoAuth.ts`: ```ts import { inject, provide, type InjectionKey } from 'vue'; import { LumoAuth, AuthModule } from '@lumoauth/client'; const LumoAuthKey: InjectionKey = Symbol('LumoAuth'); const AuthModuleKey: InjectionKey = Symbol('AuthModule'); export function provideLumoAuth() { const domain = import.meta.env.VITE_LUMOAUTH_DOMAIN as string; const orgId = import.meta.env.VITE_LUMOAUTH_ORG_ID as string; const clientId = import.meta.env.VITE_LUMOAUTH_CLIENT_ID as string; const authModule = new AuthModule({ baseUrl: domain, orgId, clientId }); const client = new LumoAuth({ baseUrl: domain, orgId, clientId, token: () => sessionStorage.getItem('lumoauth_access_token') || '', }); provide(LumoAuthKey, client); provide(AuthModuleKey, authModule); return { client, authModule }; } export function useLumoAuth() { const client = inject(LumoAuthKey); if (!client) throw new Error('Call provideLumoAuth() in a parent component'); return client; } export function useAuthModule() { const auth = inject(AuthModuleKey); if (!auth) throw new Error('Call provideLumoAuth() in a parent component'); return auth; } ``` Call `provideLumoAuth()` in `App.vue`: ```vue ``` --- ## PKCE sign-in PKCE (a one-time secret that protects the sign-in code) lets a browser app sign in without a client secret. `buildAuthorizationUrl` generates the secret and a `state` value; keep both until the callback. ```ts import { useAuthModule } from './composables/useLumoAuth'; const auth = useAuthModule(); async function signIn() { const redirectUri = `${window.location.origin}/auth/callback`; const { url, codeVerifier, state } = await auth.buildAuthorizationUrl({ redirectUri }); sessionStorage.setItem('lumoauth_pkce_verifier', codeVerifier); sessionStorage.setItem('lumoauth_pkce_state', state); window.location.href = url; } ``` --- ## Callback handler Mount this component at `/auth/callback`: ```vue ``` --- ## Permission checks ```ts import { ref, onMounted } from 'vue'; import { useLumoAuth } from '../composables/useLumoAuth'; const client = useLumoAuth(); const canEdit = ref(false); onMounted(async () => { canEdit.value = await client.permissions.check('documents.edit'); }); ``` --- # Add authentication to your Angular app Source: https://docs.lumoauth.dev/quickstarts/angular This guide shows you how to add authentication and access control to an Angular application using @lumoauth/client as an Angular service. Prompt for your AI editor: ```text Set up LumoAuth authentication in an Angular application. Install the SDK: npm install @lumoauth/client Add to src/environments/environment.ts: export const environment = { production: false, lumoauthDomain: 'https://app.lumoauth.dev', lumoauthOrgId: 'YOUR_ORG_ID', lumoauthClientId: 'YOUR_CLIENT_ID', }; Create src/app/auth/lumoauth.service.ts: import { Injectable, signal } from '@angular/core'; import { LumoAuth, AuthModule } from '@lumoauth/client'; import type { ZanzibarCheckRequest, AbacCheckRequest } from '@lumoauth/client'; import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class LumoAuthService { private readonly authModule: AuthModule; private readonly client: LumoAuth; readonly isSignedIn = signal(false); readonly isLoaded = signal(false); private readonly redirectUri = window.location.origin + '/auth/callback'; constructor() { this.authModule = new AuthModule({ baseUrl: environment.lumoauthDomain, orgId: environment.lumoauthOrgId, clientId: environment.lumoauthClientId, }); this.client = new LumoAuth({ baseUrl: environment.lumoauthDomain, orgId: environment.lumoauthOrgId, clientId: environment.lumoauthClientId, token: () => this.getAccessToken(), }); this.init(); } async signIn(): Promise { const { url, codeVerifier, state } = await this.authModule.buildAuthorizationUrl({ redirectUri: this.redirectUri, }); sessionStorage.setItem('lumoauth_verifier', codeVerifier); sessionStorage.setItem('lumoauth_state', state); window.location.href = url; } async handleCallback(): Promise { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const returnedState = params.get('state'); const codeVerifier = sessionStorage.getItem('lumoauth_verifier'); const savedState = sessionStorage.getItem('lumoauth_state'); if (!code || returnedState !== savedState || !codeVerifier) throw new Error('Invalid OAuth callback'); const tokens = await this.authModule.exchangeCodeForTokens({ code, codeVerifier, redirectUri: this.redirectUri, }); sessionStorage.setItem('lumoauth_tokens', JSON.stringify(tokens)); sessionStorage.removeItem('lumoauth_verifier'); sessionStorage.removeItem('lumoauth_state'); this.isSignedIn.set(true); } async signOut(): Promise { const token = this.getAccessToken(); if (token) this.authModule.revokeToken(token, token).catch(() => {}); sessionStorage.removeItem('lumoauth_tokens'); this.isSignedIn.set(false); } checkPermission(permission: string): Promise { return this.client.permissions.check(permission); } checkZanzibar(request: ZanzibarCheckRequest): Promise { return this.client.zanzibar.check(request); } checkAbac(request: AbacCheckRequest): Promise { return this.client.abac.isAllowed(request.resourceType, request.action, request.resourceId, request.context); } getAccessToken(): string { try { const stored = sessionStorage.getItem('lumoauth_tokens'); if (stored) return JSON.parse(stored).access_token || ''; } catch {} return ''; } private init(): void { this.isSignedIn.set(!!this.getAccessToken()); this.isLoaded.set(true); } } Create the auth callback component at src/app/auth/callback/callback.component.ts: import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { LumoAuthService } from '../lumoauth.service'; @Component({ selector: 'app-callback', standalone: true, template: '

Completing sign-in…

' }) export class CallbackComponent implements OnInit { constructor(private auth: LumoAuthService, private router: Router) {} async ngOnInit() { await this.auth.handleCallback(); this.router.navigate(['/dashboard']); } } Register the callback route in app.routes.ts: { path: 'auth/callback', component: CallbackComponent } Create an auth guard: import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { LumoAuthService } from './auth/lumoauth.service'; export const authGuard: CanActivateFn = () => { const auth = inject(LumoAuthService); if (auth.isSignedIn()) return true; inject(Router).navigate(['/sign-in']); return false; }; Protect routes: { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] } Use permission checks in components: import { Component, signal, OnInit } from '@angular/core'; import { LumoAuthService } from '../auth/lumoauth.service'; @Component({ selector: 'app-admin', standalone: true, template: '@if (canAccess()) {
Admin tools
}' }) export class AdminComponent implements OnInit { canAccess = signal(false); constructor(private auth: LumoAuthService) {} async ngOnInit() { this.canAccess.set(await this.auth.checkPermission('admin.dashboard.view')); } } Rules: - LumoAuthService is providedIn root — one instance per app - Always validate PKCE state in handleCallback() - Use Angular signals (signal()) for reactive auth state (Angular 17+) - Use the built-in @if control flow (no imports) instead of *ngIf in standalone templates (Angular 17+) - Use authGuard with canActivate for route-level protection Deprecated patterns (do not use): - Do not validate JWTs manually; use the permissions API - Do not hardcode credentials; use environment.ts Verification: 1. App compiles without errors 2. Navigating to a guarded route redirects to /sign-in when unauthenticated 3. Sign-in flow completes and lands on /dashboard 4. checkPermission() returns correct booleans 5. Sign-out clears session and updates isSignedIn signal ``` This guide shows you how to add authentication and access control to an Angular application using `@lumoauth/client` as an Angular service. --- ## Before you start You need: - A LumoAuth account with an organization ([sign up](https://app.lumoauth.dev)) - A registered OAuth application with a redirect URI (e.g., `http://localhost:4200/auth/callback`) - Angular 17+ (uses standalone components and signals). To scaffold a new app: `npx @angular/cli@17 new my-app --standalone --routing` (the steps below rely on the default standalone setup with `app.routes.ts`). To register the application, open **Applications** in the portal sidebar and click **New application**. Enter an **Application name**, choose **Single-page or mobile app**, enter the **Redirect URI**, and click **Create application**. ![The Create application form with Single-page or mobile app selected and a redirect URI entered](https://docs.lumoauth.dev/img/portal/quickstarts/application-create-spa.png) This app finishes sign-in in the browser, so also add its origin (for example `http://localhost:4200`) under **Browser CORS origins** on the application's **Configuration** tab. Then click **Save configuration**. Otherwise the token request is blocked by CORS right after sign-in. ![The Browser CORS origins section on an application's Configuration tab](https://docs.lumoauth.dev/img/portal/quickstarts/application-cors-origins.png) --- ## Install the SDK ```bash npm install @lumoauth/client ``` --- ## Environment variables The **Quickstarts** page (under **Applications & developer tools**) has an **Angular** tab that shows the domain, organization ID and client ID pre-filled, ready to copy. ![The Quickstarts page with the Angular tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-angular.png) The tab's first step lists only OAuth clients of the kind that these snippets need. If none exists, **Create one for this framework** opens the create form with the right type preselected and brings you back. Its last step, **Check it worked**, shows the first sign-in that completes through the selected client. **`src/environments/environment.ts`:** ```ts export const environment = { production: false, lumoauthDomain: 'https://app.lumoauth.dev', lumoauthOrgId: 'YOUR_ORG_ID', lumoauthClientId: 'YOUR_CLIENT_ID', }; ``` --- ## Create the LumoAuth service **`src/app/auth/lumoauth.service.ts`:** ```ts import { Injectable, signal } from '@angular/core'; import { LumoAuth, AuthModule } from '@lumoauth/client'; import type { ZanzibarCheckRequest, AbacCheckRequest } from '@lumoauth/client'; import { environment } from '../../environments/environment'; @Injectable({ providedIn: 'root' }) export class LumoAuthService { private readonly authModule: AuthModule; private readonly client: LumoAuth; readonly isSignedIn = signal(false); readonly isLoaded = signal(false); private readonly redirectUri = `${window.location.origin}/auth/callback`; constructor() { this.authModule = new AuthModule({ baseUrl: environment.lumoauthDomain, orgId: environment.lumoauthOrgId, clientId: environment.lumoauthClientId, }); this.client = new LumoAuth({ baseUrl: environment.lumoauthDomain, orgId: environment.lumoauthOrgId, clientId: environment.lumoauthClientId, token: () => this.getAccessToken(), }); this.init(); } async signIn(): Promise { const { url, codeVerifier, state } = await this.authModule.buildAuthorizationUrl({ redirectUri: this.redirectUri, }); sessionStorage.setItem('lumoauth_verifier', codeVerifier); sessionStorage.setItem('lumoauth_state', state); window.location.href = url; } async handleCallback(): Promise { const params = new URLSearchParams(window.location.search); const code = params.get('code'); const returnedState = params.get('state'); const codeVerifier = sessionStorage.getItem('lumoauth_verifier'); const savedState = sessionStorage.getItem('lumoauth_state'); if (!code || returnedState !== savedState || !codeVerifier) { throw new Error('Invalid OAuth callback'); } const tokens = await this.authModule.exchangeCodeForTokens({ code, codeVerifier, redirectUri: this.redirectUri, }); sessionStorage.setItem('lumoauth_tokens', JSON.stringify(tokens)); sessionStorage.removeItem('lumoauth_verifier'); sessionStorage.removeItem('lumoauth_state'); this.isSignedIn.set(true); } async signOut(): Promise { const token = this.getAccessToken(); if (token) this.authModule.revokeToken(token, token).catch(() => {}); sessionStorage.removeItem('lumoauth_tokens'); this.isSignedIn.set(false); } checkPermission(permission: string): Promise { return this.client.permissions.check(permission); } checkZanzibar(request: ZanzibarCheckRequest): Promise { return this.client.zanzibar.check(request); } checkAbac(request: AbacCheckRequest): Promise { return this.client.abac.isAllowed( request.resourceType, request.action, request.resourceId, request.context ); } getAccessToken(): string { try { const stored = sessionStorage.getItem('lumoauth_tokens'); if (stored) return JSON.parse(stored).access_token || ''; } catch {} return ''; } private init(): void { this.isSignedIn.set(!!this.getAccessToken()); this.isLoaded.set(true); } } ``` --- ## Auth callback component **`src/app/auth/callback/callback.component.ts`:** ```ts import { Component, OnInit } from '@angular/core'; import { Router } from '@angular/router'; import { LumoAuthService } from '../lumoauth.service'; @Component({ selector: 'app-callback', standalone: true, template: '

Completing sign-in…

', }) export class CallbackComponent implements OnInit { constructor(private auth: LumoAuthService, private router: Router) {} async ngOnInit() { await this.auth.handleCallback(); this.router.navigate(['/dashboard']); } } ``` Register the route in `app.routes.ts`: ```ts { path: 'auth/callback', component: CallbackComponent } ``` --- ## Auth guard ```ts import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; import { LumoAuthService } from './auth/lumoauth.service'; export const authGuard: CanActivateFn = () => { const auth = inject(LumoAuthService); if (auth.isSignedIn()) return true; inject(Router).navigate(['/sign-in']); return false; }; ``` Apply to routes: ```ts { path: 'dashboard', component: DashboardComponent, canActivate: [authGuard] } ``` --- ## Permission checks in components ```ts import { Component, signal, OnInit } from '@angular/core'; import { LumoAuthService } from '../auth/lumoauth.service'; @Component({ selector: 'app-admin', standalone: true, // Angular 17+ built-in control flow (@if) needs no imports. // Replace the
with your own component, adding it to `imports`. template: `@if (canAccess()) {
Admin tools
}`, }) export class AdminComponent implements OnInit { canAccess = signal(false); constructor(private auth: LumoAuthService) {} async ngOnInit() { this.canAccess.set(await this.auth.checkPermission('admin.dashboard.view')); } } ``` --- # Add authentication to your Express app Source: https://docs.lumoauth.dev/quickstarts/node This guide shows you how to protect an Express API with LumoAuth using the @lumoauth/client TypeScript SDK. The SDK runs in Node as well as in browsers and checks permissions as the signed-in user whose bearer token… Prompt for your AI editor: ```text Set up LumoAuth authentication in a Node.js Express app. Install the SDK: npm install @lumoauth/client express Initialize the auth module (public client, PKCE): import { AuthModule } from '@lumoauth/client'; const auth = new AuthModule({ baseUrl: 'https://app.lumoauth.dev', orgId: 'YOUR_ORG_ID', clientId: 'YOUR_CLIENT_ID', }); Create an auth middleware that validates the Bearer token and builds a per-request client as that user: import { LumoAuth } from '@lumoauth/client'; async function requireAuth(req, res, next) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'Missing token' }); try { const token = header.slice(7); req.user = await auth.getUserInfo(token); req.lumo = new LumoAuth({ baseUrl: 'https://app.lumoauth.dev', token }); next(); } catch { res.status(401).json({ error: 'Invalid token' }); } } Create a permission middleware (checks run as the signed-in user): function requirePermission(permission) { return async (req, res, next) => { const allowed = await req.lumo.permissions.check(permission); if (!allowed) return res.status(403).json({ error: 'Forbidden' }); next(); }; } Use in routes: app.get('/api/profile', requireAuth, (req, res) => { res.json(req.user); }); app.delete('/api/users/:id', requireAuth, requirePermission('users.delete'), (req, res) => { res.json({ deleted: req.params.id }); }); Start the PKCE login flow from your backend: app.get('/auth/login', async (req, res) => { const { url, codeVerifier, state } = await auth.buildAuthorizationUrl({ redirectUri: 'http://localhost:3000/auth/callback', scope: 'openid profile email', }); req.session.codeVerifier = codeVerifier; req.session.state = state; res.redirect(url); }); app.get('/auth/callback', async (req, res) => { if (req.query.state !== req.session.state) return res.status(400).send('Invalid state'); const tokens = await auth.exchangeCodeForTokens({ code: req.query.code, codeVerifier: req.session.codeVerifier, redirectUri: 'http://localhost:3000/auth/callback', }); req.session.accessToken = tokens.access_token; res.redirect('/dashboard'); }); Rules: - Always validate Bearer tokens on protected routes - Build a per-request LumoAuth client with the caller's token so permission checks run as that user - Store PKCE code verifiers server-side in the session, never expose to the client - Use environment variables for clientId and orgId in production - Use @lumoauth/backend with an organization API key (LUMOAUTH_SECRET_KEY) only for admin operations or checks made with the server's own credential - Prefer @lumoauth/express if the app serves HTML pages; it mounts the login routes and requireAuth() for you Deprecated patterns (do not use): - Do not validate JWTs manually; use auth.getUserInfo() or the permissions API - Do not hardcode credentials in source code Verification: 1. The server starts without errors 2. GET /api/profile returns 401 without a token 3. GET /api/profile returns user info with a valid token 4. Protected routes return 403 when the user lacks the required permission ``` This guide shows you how to protect an Express API with LumoAuth using the `@lumoauth/client` TypeScript SDK. The SDK runs in Node as well as in browsers and checks permissions **as the signed-in user** whose bearer token your API receives. You'll validate access tokens, check permissions, and optionally add a server-side login flow. Two related packages cover the other cases. [`@lumoauth/express`](https://docs.lumoauth.dev/quickstarts/express) mounts the login, callback and logout routes and a `requireAuth()` gate for apps that serve HTML pages. [`@lumoauth/backend`](https://docs.lumoauth.dev/developer/sdks#javascript--typescript-sdk) runs checks and admin operations with your organization's own API key (`LUMOAUTH_SECRET_KEY`) rather than a user's token. --- ## Before you start You need: - A LumoAuth account with an organization ([sign up](https://app.lumoauth.dev)) - A registered OAuth application configured as a public client (PKCE) with a Client ID - Node.js 18+ To register the application, open **Applications** in the portal sidebar and click **New application**. Enter an **Application name**, choose **Single-page or mobile app**, enter the **Redirect URI**, and click **Create application**. ![The Create application form with Single-page or mobile app selected and a redirect URI entered](https://docs.lumoauth.dev/img/portal/quickstarts/application-create-spa.png) From your organization portal, note your **Organization ID** and **Client ID**. This guide uses the PKCE flow for a public client, so no Client Secret is required. The Client ID is on the application's **Credentials** tab. ![An application's Credentials tab showing the Client ID](https://docs.lumoauth.dev/img/portal/quickstarts/application-credentials.png) --- ## Install dependencies ```bash npm install @lumoauth/client express ``` ```bash npm install -D typescript @types/express tsx ``` --- ## Initialize the client `AuthModule` handles the OAuth flows for your public client (PKCE, a one-time secret that protects the sign-in code, so no client secret is needed). `userClient(token)` builds a `LumoAuth` client that runs every check as the user who owns that token. ```typescript title="src/lumo.ts" import { AuthModule, LumoAuth } from '@lumoauth/client'; const baseUrl = process.env.LUMOAUTH_URL || 'https://app.lumoauth.dev'; export const auth = new AuthModule({ baseUrl, orgId: process.env.LUMOAUTH_ORG_ID || 'acme-corp', clientId: process.env.LUMOAUTH_CLIENT_ID || 'your-client-id', }); export function userClient(token: string) { return new LumoAuth({ baseUrl, token }); } ``` --- ## Create auth middleware Validate the Bearer token on incoming requests. Then attach the user to the request object, plus a client that carries that user's token. ```typescript title="src/middleware/auth.ts" import type { Request, Response, NextFunction } from 'express'; import { auth, userClient } from '../lumo'; export async function requireAuth(req: Request, res: Response, next: NextFunction) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) { return res.status(401).json({ error: 'Missing or invalid Authorization header' }); } try { const token = header.slice(7); const user = await auth.getUserInfo(token); (req as any).user = user; (req as any).lumo = userClient(token); next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); } } ``` --- ## Create permission middleware Protect routes by RBAC permission using the LumoAuth permissions API. The check runs with the caller's own token, so the answer is about that user. ```typescript title="src/middleware/permissions.ts" import type { Request, Response, NextFunction } from 'express'; import { LumoAuthApiError } from '@lumoauth/client'; export function requirePermission(permission: string) { return async (req: Request, res: Response, next: NextFunction) => { try { const allowed = await (req as any).lumo.permissions.check(permission); if (!allowed) { return res.status(403).json({ error: 'Forbidden', permission }); } next(); } catch (err) { if (err instanceof LumoAuthApiError) { return res.status(err.statusCode ?? 502).json({ error: err.code }); } next(err); } }; } ``` --- ## Set up routes ```typescript title="src/index.ts" import express from 'express'; import { requireAuth } from './middleware/auth'; import { requirePermission } from './middleware/permissions'; const app = express(); app.use(express.json()); // Public route app.get('/api/health', (req, res) => { res.json({ status: 'ok' }); }); // Authenticated route app.get('/api/profile', requireAuth, (req, res) => { res.json((req as any).user); }); // Permission-protected route app.get( '/api/admin/users', requireAuth, requirePermission('users.list'), (req, res) => { res.json({ users: [] }); } ); app.delete( '/api/admin/users/:id', requireAuth, requirePermission('users.delete'), (req, res) => { res.json({ deleted: req.params.id }); } ); const port = process.env.PORT || 3000; app.listen(port, () => console.log(`Server running on port ${port}`)); ``` --- ## Add Zanzibar checks (optional) For relationship-based access control, use the Zanzibar module: ```typescript title="src/routes/documents.ts" import { Router } from 'express'; import { requireAuth } from '../middleware/auth'; const router = Router(); router.get('/api/documents/:id', requireAuth, async (req, res) => { const canView = await (req as any).lumo.zanzibar.check({ object: `document:${req.params.id}`, relation: 'viewer', subject: `user:${(req as any).user.sub}`, }); if (!canView) { return res.status(403).json({ error: 'Not authorized to view this document' }); } res.json({ id: req.params.id, content: '...' }); }); export default router; ``` --- ## Add a server-side login flow (optional) If your Express app serves HTML pages, you can start PKCE login from the server. ([`@lumoauth/express`](https://docs.lumoauth.dev/quickstarts/express) does exactly this for you; the routes below show what it mounts.) The login and callback routes store the PKCE `codeVerifier` in the user's session, so you need [`express-session`](https://www.npmjs.com/package/express-session): ```bash npm install express-session npm install -D @types/express-session ``` Mount the session middleware before your auth router in `src/index.ts`: ```typescript title="src/index.ts" import session from 'express-session'; import authRouter from './routes/auth'; app.use( session({ secret: process.env.SESSION_SECRET || 'change-me-in-production', resave: false, saveUninitialized: false, }) ); app.use(authRouter); ``` Then define the login and callback routes: ```typescript title="src/routes/auth.ts" import { Router } from 'express'; import { auth } from '../lumo'; const router = Router(); router.get('/auth/login', async (req, res) => { const { url, codeVerifier, state } = await auth.buildAuthorizationUrl({ redirectUri: 'http://localhost:3000/auth/callback', scope: 'openid profile email', }); // Store in session (requires express-session — see install + setup below) (req as any).session.codeVerifier = codeVerifier; (req as any).session.state = state; res.redirect(url); }); router.get('/auth/callback', async (req, res) => { if (req.query.state !== (req as any).session.state) { return res.status(400).send('Invalid state'); } const tokens = await auth.exchangeCodeForTokens({ code: req.query.code as string, codeVerifier: (req as any).session.codeVerifier, redirectUri: 'http://localhost:3000/auth/callback', }); (req as any).session.accessToken = tokens.access_token; res.redirect('/dashboard'); }); export default router; ``` --- ## Run the server ```bash npx tsx src/index.ts ``` Test with curl: ```bash # Health check curl http://localhost:3000/api/health # Authenticated request curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ http://localhost:3000/api/profile # Permission-protected request curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \ http://localhost:3000/api/admin/users ``` --- ## What's next? - [Configure RBAC](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) to define roles and permissions for your API - [Set up Zanzibar](https://docs.lumoauth.dev/user-guide/access-control/zanzibar) for relationship-based access control - [Enable webhooks](https://docs.lumoauth.dev/user-guide/integrations/webhooks) to sync user events with your backend - [SDKs & Libraries](https://docs.lumoauth.dev/developer/sdks) - Full SDK API reference --- # Express.js Quickstart Source: https://docs.lumoauth.dev/quickstarts/express Add LumoAuth to an Express.js app in five minutes. Add LumoAuth to an Express app using the official [`@lumoauth/express`](https://docs.lumoauth.dev/developer/sdks#expressjs-middleware) middleware. Three routes (`/auth/login`, `/auth/callback`, `/auth/logout`) and a `requireAuth()` gate — that's the entire integration. Sign-in uses the authorization code flow with PKCE (a one-time secret that protects the sign-in code), and the tokens are stored only in the server-side session. ## Prerequisites - Node.js 20+ - An OAuth client registered in your LumoAuth organization with redirect URI `http://localhost:3000/auth/callback` and grant types `authorization_code,refresh_token`. Register the client in the portal under **Applications → New application**: enter an **Application name**, choose **Web application**, enter the **Redirect URI**, and click **Create application**. The client secret is shown once, on the page that follows. ![The Create application form with Web application selected and a redirect URI entered](https://docs.lumoauth.dev/img/portal/quickstarts/application-create-web.png) The **Quickstarts** page (under **Applications & developer tools**) has an **Express.js** tab that shows the `.env` values for your organization and client pre-filled, ready to copy. ![The Quickstarts page with the Express.js tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-express.png) The tab's first step lists only OAuth clients of the kind that these snippets need. If none exists, **Create one for this framework** opens the create form with the right type preselected and brings you back. Its last step, **Check it worked**, shows the first sign-in that completes through the selected client. Skip the by-hand setup with the CLI: ```bash lumo login --org your-org-slug lumo init --framework express --dir ./my-app cd ./my-app && npm install && npm start ``` ## Manual install ```bash npm install @lumoauth/express express express-session dotenv ``` `.env`: ```env LUMO_BASE_URL=https://app.lumoauth.dev LUMO_ORG_ID=your-org-slug LUMO_CLIENT_ID=your-oauth-client-id LUMO_CLIENT_SECRET=your-oauth-client-secret SESSION_SECRET=change-me-to-a-random-32-byte-string PORT=3000 ``` `server.js`: ```js import "dotenv/config"; import express from "express"; import session from "express-session"; import { lumoAuthMiddleware, requireAuth } from "@lumoauth/express"; const app = express(); // Session middleware MUST be mounted before lumoAuthMiddleware. app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", }, })); // Wires GET /auth/login, GET /auth/callback, GET|POST /auth/logout. // Populates req.user from the session for every other request. app.use(lumoAuthMiddleware({ baseUrl: process.env.LUMO_BASE_URL, organization: process.env.LUMO_ORG_ID, clientId: process.env.LUMO_CLIENT_ID, clientSecret: process.env.LUMO_CLIENT_SECRET, // omit for public clients prefix: "/auth", // default postLoginRedirect: "/", postLogoutRedirect: "/", })); app.get("/", (req, res) => { if (!req.user) return res.redirect("/auth/login"); res.send(`

Hi ${req.user.email}

Sign out`); }); // Gate any handler with requireAuth(). 401 if not signed in. app.get("/api/me", requireAuth(), (req, res) => res.json(req.user)); app.listen(process.env.PORT || 3000, () => { console.log(`http://localhost:${process.env.PORT || 3000}`); }); ``` Visit `http://localhost:3000` — you'll be redirected to LumoAuth, sign in, and come back with `req.user` populated. ## Scope and permission gating `requireAuth()` accepts options for finer-grained access control: ```js // Require specific scopes (403 if any are missing). app.get("/admin", requireAuth({ scopes: ["admin"] }), (req, res) => { res.json({ ok: true }); }); // Programmatic check (e.g. RBAC / Zanzibar / DB lookup). app.get("/billing", requireAuth({ authorize: async (user, req) => { return user.email?.endsWith("@acme.com") ?? false; }, }), (req, res) => { res.json({ ok: true }); }); ``` ## Forwarding webhooks to localhost While you're developing, run [`lumo tunnel`](https://docs.lumoauth.dev/cli/tunnel) in another terminal to forward live webhook events from your organization to your local Express handler: ```bash lumo tunnel --to http://localhost:3000/webhooks ``` ## Production checklist - [ ] Replace the in-memory session store with Redis / Postgres / MongoDB-backed session — the default loses sessions on restart. - [ ] Set `cookie.secure: true` (the snippet above already does this in production via `NODE_ENV`). - [ ] Set `app.set('trust proxy', true)` if behind a reverse proxy so `req.protocol` correctly reports `https`. - [ ] Rotate `SESSION_SECRET` periodically; consider stacking secrets via the array form for graceful key rotation. - [ ] Confirm the OAuth client's redirect URI matches your callback URL in production exactly. ## Reference - [`@lumoauth/express` API reference](https://docs.lumoauth.dev/developer/sdks#expressjs-middleware) - [Token Exchange & confidential clients](https://docs.lumoauth.dev/oauth/clients) - [Webhooks](https://docs.lumoauth.dev/api-reference/webhooks) --- # Add authentication to your Python app Source: https://docs.lumoauth.dev/quickstarts/python This guide shows you how to authenticate a Python backend service or AI agent with LumoAuth using the lumoauth Python SDK. You'll set up client credentials authentication, use the Ask API for authorization, and… Prompt for your AI editor: ```text Set up LumoAuth authentication in a Python application using the lumoauth SDK. Install the SDK: pip install lumoauth Initialize the agent client: from lumoauth import LumoAuthAgent agent = LumoAuthAgent( base_url="https://app.lumoauth.dev", org_id="YOUR_ORG_ID", client_id="YOUR_CLIENT_ID", client_secret="YOUR_CLIENT_SECRET", ) agent.authenticate(scopes=["read:users"]) Or use environment variables: LUMOAUTH_URL=https://app.lumoauth.dev LUMOAUTH_ORG_ID=YOUR_ORG_ID AGENT_CLIENT_ID=YOUR_CLIENT_ID AGENT_CLIENT_SECRET=YOUR_CLIENT_SECRET Use the Ask API for authorization checks: if agent.is_allowed("read_document", context={"document_id": "doc-123"}): print("Allowed") result = agent.ask("delete_user", context={"user_id": "u-456"}) # { 'allowed': False, 'reason': 'insufficient_permissions', ... } Get agent identity and capabilities: info = agent.get_agent_info() if agent.has_capability("web_search"): perform_search() Use delegation chains to act on behalf of users: from lumoauth import DelegationChain chain = DelegationChain(agent, redirect_uri="https://myapp.com/consent") consent_url = chain.get_consent_url("session-1", scopes=["profile", "documents"]) # Redirect user to consent_url chain.handle_consent_callback("session-1", authorization_code) delegated_token = chain.exchange("session-1", scopes=["read:documents"]) response = chain.request("session-1", "GET", "/api/v1/documents") For MCP server integration: mcp_token = agent.get_mcp_token("https://mcp.example.com/mcp") Rules: - Always call agent.authenticate() before making API calls - Use agent.ensure_authenticated() to auto-refresh expired tokens - Use environment variables for credentials in production - Use the Ask API (agent.ask / agent.is_allowed) for lightweight authorization checks Deprecated patterns (do not use): - Do not manually construct OAuth token requests; use the SDK methods - Do not store tokens in files; the SDK manages token lifecycle Verification: 1. agent.authenticate() succeeds without errors 2. agent.get_agent_info() returns the agent's identity 3. agent.is_allowed("some_action") returns a boolean 4. Delegation chain can generate a consent URL ``` This guide shows you how to authenticate a Python backend service or AI agent with LumoAuth using the `lumoauth` Python SDK. You'll set up client credentials authentication, use the Ask API for authorization, and optionally set up delegation chains. --- ## Before you start You need: - A LumoAuth account with an organization ([sign up](https://app.lumoauth.dev)) - An Agent or OAuth application registered in your organization with Client ID and Client Secret - Python 3.9+ From your organization portal, note your **Organization ID**, **Client ID**, and **Client Secret**. To register an OAuth application for this, open **Applications → New application** and choose **Machine to machine**. The client secret is shown once, on the page that opens after **Create application**. The **Quickstarts** page (under **Applications & developer tools**) has a **Python** tab that shows the organization ID and client ID pre-filled, ready to copy. ![The Quickstarts page with the Python tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-python.png) The tab's first step lists only OAuth clients of the kind that these snippets need. If none exists, **Create one for this framework** opens the create form with the right type already selected and brings you back. Its last step, **Check it worked**, shows the first sign-in that completes through the selected client. --- ## Install the SDK ```bash pip install lumoauth ``` For cryptographic agent identity (AAuth), install with the extra: ```bash pip install 'lumoauth[aauth]' ``` --- ## Authenticate your agent The SDK uses the OAuth 2.0 client credentials flow for server-to-server authentication. ```python title="agent.py" from lumoauth import LumoAuthAgent agent = LumoAuthAgent( base_url="https://app.lumoauth.dev", org_id="acme-corp", client_id="your-client-id", client_secret="your-client-secret", ) # Authenticate and get an access token agent.authenticate(scopes=["read:users", "write:documents"]) print(f"Authenticated. Scopes: {agent.token_scopes}") ``` You can also use environment variables instead of passing credentials directly: ```bash title=".env" LUMOAUTH_URL=https://app.lumoauth.dev LUMOAUTH_ORG_ID=acme-corp AGENT_CLIENT_ID=your-client-id AGENT_CLIENT_SECRET=your-client-secret ``` ```python agent = LumoAuthAgent() # reads from environment agent.authenticate() ``` --- ## Use the Ask API The Ask API provides lightweight, up-front authorization checks. It is designed for LLM tool-calling workflows where you need a quick allow/deny decision before executing an action. ```python title="ask_api.py" from lumoauth import LumoAuthAgent agent = LumoAuthAgent() agent.authenticate() # Simple boolean check if agent.is_allowed("read_document", context={"document_id": "doc-123"}): print("Access granted - reading document") else: print("Access denied") # Detailed response with audit trail result = agent.ask("delete_user", context={"user_id": "u-456"}) print(result) # { # 'allowed': False, # 'action': 'delete_user', # 'reason': 'insufficient_permissions', # 'audit_id': 'aud_abc123', # 'context': {'user_id': 'u-456'} # } ``` --- ## Check agent identity and capabilities ```python title="capabilities.py" from lumoauth import LumoAuthAgent agent = LumoAuthAgent() agent.authenticate() # Get full agent identity info = agent.get_agent_info() print(f"Agent: {info['agent_id']}") print(f"Capabilities: {info['capabilities']}") # Check for specific capability if agent.has_capability("web_search"): print("Agent can perform web searches") # Monitor budget budget = agent.get_budget_status() print(f"Budget policy: {budget}") if agent.is_budget_exhausted(): print("Daily token budget reached - pausing operations") ``` --- ## Act on behalf of users with delegation chains Use token exchange to perform actions on behalf of a user. Token exchange swaps the user's consent for a token that records both the agent and the user that it acts for. This is useful for AI agents that need user-scoped permissions. ```python title="delegation.py" from lumoauth import LumoAuthAgent, DelegationChain agent = LumoAuthAgent() agent.authenticate() chain = DelegationChain(agent, redirect_uri="https://myapp.com/consent") # Step 1: Generate a consent URL for the user consent_url = chain.get_consent_url( "session-1", scopes=["profile", "documents"], ) print(f"Redirect user to: {consent_url}") # Step 2: After user grants consent, handle the callback chain.handle_consent_callback("session-1", authorization_code="CODE_FROM_CALLBACK") # Step 3: Exchange for a delegated token delegated_token = chain.exchange("session-1", scopes=["read:documents"]) # Step 4: Make API calls on behalf of the user response = chain.request("session-1", "GET", "/api/v1/documents") print(response.json()) # Clean up when done chain.revoke("session-1") ``` --- ## Integrate with MCP servers Exchange your agent token for a token scoped to an MCP server: ```python title="mcp.py" from lumoauth import LumoAuthAgent agent = LumoAuthAgent() agent.authenticate() mcp_token = agent.get_mcp_token("https://mcp.example.com/mcp") # Use mcp_token to authenticate with the MCP server ``` --- ## Use in a FastAPI app Install the SDK with the FastAPI extra (this also installs `fastapi`): ```bash pip install 'lumoauth[fastapi]' ``` ```python title="app.py" from fastapi import FastAPI, Depends, HTTPException from lumoauth import LumoAuthAgent app = FastAPI() agent = LumoAuthAgent() agent.authenticate() def check_permission(action: str): def dependency(): agent.ensure_authenticated() if not agent.is_allowed(action): raise HTTPException(status_code=403, detail="Forbidden") return Depends(dependency) @app.get("/api/documents", dependencies=[check_permission("read_documents")]) async def list_documents(): return {"documents": []} @app.delete("/api/documents/{doc_id}", dependencies=[check_permission("delete_documents")]) async def delete_document(doc_id: str): return {"deleted": doc_id} ``` --- ## What's next? - [Configure agent identity](https://docs.lumoauth.dev/user-guide/ai-agents/overview) in the LumoAuth organization portal - [Set up ABAC policies](https://docs.lumoauth.dev/user-guide/access-control/abac) for attribute-based authorization - [Enable audit logs](https://docs.lumoauth.dev/user-guide/compliance/audit-logs) to track agent actions - [SDKs & Libraries](https://docs.lumoauth.dev/developer/sdks) - Full SDK API reference ## Standards used on this page | Plain name | Standard | |---|---| | Token exchange (delegation) | RFC 8693 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # FastAPI Quickstart Source: https://docs.lumoauth.dev/quickstarts/fastapi Add LumoAuth to a FastAPI app in five minutes. Connect LumoAuth to a FastAPI app using the official [`lumoauth.fastapi`](https://docs.lumoauth.dev/developer/sdks#fastapi-router) router. One `include_router(...)` mounts /login, /callback, /logout; the `get_current_user` dependency is everything else. ## Prerequisites - Python 3.10+ - An OAuth client registered with redirect URI `http://localhost:8000/auth/callback`. Register the client in the portal under **Applications → New application**: enter an **Application name**, choose **Web application**, enter the **Redirect URI**, and click **Create application**. The client secret is shown once, on the page that opens next. ![The Create application form with Web application selected and a redirect URI entered](https://docs.lumoauth.dev/img/portal/quickstarts/application-create-web.png) The **Quickstarts** page (under **Applications & developer tools**) has a **FastAPI** tab that shows the `.env` values for your organization and client pre-filled, ready to copy. ![The Quickstarts page with the FastAPI tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-fastapi.png) The tab's first step lists only OAuth clients of the kind that these snippets need. If none exists, **Create one for this framework** opens the create form with the right type already selected and brings you back. Its last step, **Check it worked**, shows the first sign-in that completes through the selected client. Or scaffold with the CLI: ```bash lumo login --org your-org-slug lumo init --framework fastapi --dir ./my-app cd ./my-app && python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt && uvicorn main:app --reload --port 3000 ``` ## Manual install ```bash pip install 'lumoauth[fastapi]' uvicorn[standard] python-dotenv itsdangerous ``` `.env`: ```env LUMO_BASE_URL=https://app.lumoauth.dev LUMO_ORG_ID=your-org-slug LUMO_CLIENT_ID=your-oauth-client-id LUMO_CLIENT_SECRET=your-oauth-client-secret SESSION_SECRET=change-me-to-a-random-32-byte-string ``` `main.py`: ```python import os from dotenv import load_dotenv from fastapi import FastAPI, Depends from fastapi.responses import RedirectResponse, HTMLResponse from starlette.middleware.sessions import SessionMiddleware from lumoauth.fastapi import ( lumo_auth_router, get_current_user, require_auth, User, ) load_dotenv() app = FastAPI() # SessionMiddleware MUST be mounted before lumo_auth_router. app.add_middleware(SessionMiddleware, secret_key=os.environ["SESSION_SECRET"]) app.include_router( lumo_auth_router( base_url=os.environ["LUMO_BASE_URL"], organization=os.environ["LUMO_ORG_ID"], client_id=os.environ["LUMO_CLIENT_ID"], client_secret=os.environ.get("LUMO_CLIENT_SECRET"), # omit for public callback_path="/auth/callback", ), prefix="/auth", ) @app.get("/", response_class=HTMLResponse) def home(user: User | None = Depends(get_current_user)): if user is None: return RedirectResponse("/auth/login") return f"

Hi {user.email}

Sign out" @app.get("/api/me") def me(user: User = Depends(require_auth())): return user.model_dump() ``` Run with `uvicorn main:app --reload`, visit `http://localhost:8000` — you'll be redirected to LumoAuth, sign in, and return authenticated. ## Optional vs required auth ```python # Optional — works for both anonymous and authenticated callers. @app.get("/dashboard") def dashboard(user: User | None = Depends(get_current_user)): if user is None: return RedirectResponse("/auth/login") return {"name": user.name} # Required — 401 if no session. @app.get("/api/me") def me(user: User = Depends(require_auth())): return user.model_dump() # Required + scope check — 403 if any required scope is missing. @app.get("/api/admin") def admin(user: User = Depends(require_auth(scopes=["admin"]))): return {"ok": True} ``` ## Reading custom claims The `User` model uses Pydantic v2 with `extra="allow"`, so any custom claims that your organization adds to `/userinfo` (org-specific traits, custom scopes, etc.) come through as attributes: ```python @app.get("/api/me") def me(user: User = Depends(require_auth())): return { "email": user.email, "name": user.name, # Standard claims are typed "department": getattr(user, "department", None), # Custom claims fall through model_dump() "all_claims": user.model_dump(), } ``` ## Reference - [`lumoauth.fastapi` API reference](https://docs.lumoauth.dev/developer/sdks#fastapi-router) - [Webhooks](https://docs.lumoauth.dev/api-reference/webhooks) — pair with [`lumo tunnel`](https://docs.lumoauth.dev/cli/tunnel) to forward events to your local FastAPI handler during development. - [The `lumoauth` Python SDK proper](https://docs.lumoauth.dev/developer/sdks#python-sdk) — for AAuth / agent flows / RBAC checks from inside route handlers. --- # Go Quickstart Source: https://docs.lumoauth.dev/quickstarts/go Add LumoAuth to a Go web app in five minutes. Connect LumoAuth to a Go web app using the official [`lumo-auth-go`](https://docs.lumoauth.dev/developer/sdks#go-sdk) SDK. The `WebAuth.Router()` mounts /login, /callback, /logout; `WebAuth.Middleware` adds the signed-in user to the request context; `lumoauth.UserFromContext(ctx)` reads it back out. ## Prerequisites - Go 1.22+ - An OAuth client registered with redirect URI `http://localhost:3000/auth/callback`. Register the client in the portal under **Applications → New application**: enter an **Application name**, choose **Web application**, enter the **Redirect URI**, and click **Create application**. The client secret is shown once, on the page that opens next. ![The Create application form with Web application selected and a redirect URI entered](https://docs.lumoauth.dev/img/portal/quickstarts/application-create-web.png) The **Quickstarts** page (under **Applications & developer tools**) has a **Go** tab that shows the `.env` values for your organization and client pre-filled, ready to copy. ![The Quickstarts page with the Go tab selected, showing pre-filled environment variables](https://docs.lumoauth.dev/img/portal/quickstarts/quickstart-go.png) The tab's first step lists only OAuth clients of the kind that these snippets need. If none exists, **Create one for this framework** opens the create form with the right type already selected and brings you back. Its last step, **Check it worked**, shows the first sign-in that completes through the selected client. Or scaffold with the CLI: ```bash lumo login --org your-org-slug lumo init --framework go --dir ./my-app cd ./my-app && go mod tidy && go run . ``` ## Manual install ```bash go get github.com/lumoauth/lumo-auth-go \ github.com/go-chi/chi/v5 \ github.com/joho/godotenv ``` `.env`: ```env LUMO_BASE_URL=https://app.lumoauth.dev LUMO_ORG_ID=your-org-slug LUMO_CLIENT_ID=your-oauth-client-id LUMO_CLIENT_SECRET=your-oauth-client-secret SESSION_SECRET=change-me-to-a-random-32-byte-string PORT=3000 ``` `main.go`: ```go package main import ( "fmt" "log" "net/http" "os" "github.com/go-chi/chi/v5" "github.com/joho/godotenv" lumoauth "github.com/lumoauth/lumo-auth-go" ) func main() { _ = godotenv.Load() auth, err := lumoauth.NewWebAuth(lumoauth.WebConfig{ BaseURL: os.Getenv("LUMO_BASE_URL"), Organization: os.Getenv("LUMO_ORG_ID"), ClientID: os.Getenv("LUMO_CLIENT_ID"), ClientSecret: os.Getenv("LUMO_CLIENT_SECRET"), SessionSecret: os.Getenv("SESSION_SECRET"), CallbackPath: "/auth/callback", PostLoginRedirect: "/", // Session cookie defaults to Secure: true, so browsers won't send // it over plain HTTP. Opt out for localhost dev; remove this line // (or use lumoauth.Bool(true)) in production over HTTPS. CookieSecure: lumoauth.Bool(false), }) if err != nil { log.Fatal(err) } r := chi.NewRouter() r.Mount("/auth", auth.Router()) // Middleware reads the signed-in user from the session into the // request context. UserFromContext returns nil for anonymous requests. r.Group(func(r chi.Router) { r.Use(auth.Middleware) r.Get("/", func(w http.ResponseWriter, req *http.Request) { user := lumoauth.UserFromContext(req.Context()) if user == nil { http.Redirect(w, req, "/auth/login", http.StatusFound) return } fmt.Fprintf(w, "

Hi %s

Sign out", user.Email) }) // Gate with RequireAuth — 401 if no user. r.With(auth.RequireAuth).Get("/api/me", func(w http.ResponseWriter, req *http.Request) { user := lumoauth.UserFromContext(req.Context()) fmt.Fprintf(w, "%s\n", user.Email) }) }) port := os.Getenv("PORT") if port == "" { port = "3000" } log.Printf("listening on http://localhost:%s", port) log.Fatal(http.ListenAndServe(":"+port, r)) } ``` Run with `go run .`, visit `http://localhost:3000` — you'll be redirected to LumoAuth, sign in, and return with the user in context. ## net/http (no chi) The router and middleware are standard `http.Handler` types — the chi example above is for convenient grouping, but plain `net/http` works as well: ```go mux := http.NewServeMux() // Strip /auth prefix and delegate to auth.Router() on each subpath. mux.Handle("/auth/", http.StripPrefix("/auth", auth.Router())) mux.Handle("/", auth.Middleware(http.HandlerFunc(homeHandler))) mux.Handle("/api/me", auth.Middleware(auth.RequireAuth(http.HandlerFunc(meHandler)))) http.ListenAndServe(":3000", mux) ``` ## Behind a reverse proxy The middleware uses `X-Forwarded-Proto` and `X-Forwarded-Host` when it constructs the absolute callback URL. You can therefore run the Go process on plain HTTP behind a TLS-terminating proxy, and the redirect URI that it sends to LumoAuth still resolves to your public `https://...` URL. ## Production checklist - [ ] `SessionSecret` is ≥32 bytes of cryptographic randomness. - [ ] The session cookie's `Secure` flag is on. The SDK defaults `CookieSecure` to `true`; set `CookieSecure: lumoauth.Bool(false)` only for plain-http local dev, and leave it unset (or `true`) in production. - [ ] Rotate `SessionSecret` periodically — gorilla/sessions accepts multiple keys for graceful rotation. - [ ] OAuth client's redirect URI exactly matches the production URL. - [ ] If running behind a proxy, configure it to set `X-Forwarded-Proto: https`. ## Reference - [Go SDK API reference](https://docs.lumoauth.dev/developer/sdks#go-sdk) - [chi documentation](https://go-chi.io/) - [`lumo init --framework go`](https://docs.lumoauth.dev/cli/init) — scaffold a complete starter project. --- # C# / ASP.NET Core Quickstart Source: https://docs.lumoauth.dev/quickstarts/aspnetcore Add LumoAuth sign-in, bearer-token APIs and permission checks to an ASP.NET Core app in five minutes. Add LumoAuth to an ASP.NET Core 8 app with the official [`LumoAuth.AspNetCore`](https://docs.lumoauth.dev/developer/sdks#c--net) package. It registers the SDK from `appsettings.json`, signs browsers in through OpenID Connect, resolves LumoAuth bearer tokens on API requests, and enforces permissions server-side with `[RequirePermission]`. ## Prerequisites - .NET 8 SDK or newer - An OAuth client registered with redirect URI `https://localhost:5001/signin-lumoauth` (the port `dotnet run` prints). Register the client in the portal under **Applications → New application**: enter an **Application name**, choose **Web application**, enter the **Redirect URI**, and click **Create application**. The client secret is shown once, on the page that follows. ## Install ```bash dotnet new web -o lumo-demo && cd lumo-demo dotnet add package LumoAuth.AspNetCore ``` `appsettings.json`: ```json { "LumoAuth": { "BaseUrl": "https://app.lumoauth.dev", "OrgId": "your-org-slug", "ApiKey": "lmk_...", "Login": { "ClientId": "lumo_...", "ClientSecret": "..." } } } ``` Keep the secrets out of source control: `dotnet user-secrets set "LumoAuth:ApiKey" "lmk_..."` in development, environment variables (`LUMOAUTH_API_KEY`, `LumoAuth__Login__ClientSecret`) in production. Every `LumoAuth:*` key has an environment-variable fallback. ## Use it ```csharp title="Program.cs" using System.Security.Claims; using LumoAuth.AspNetCore; using LumoAuth.AspNetCore.Authentication; using LumoAuth.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; var builder = WebApplication.CreateBuilder(args); builder.Services.AddLumoAuth(builder.Configuration); // LumoAuthClient + ILumoAuthorization + [Require*] policies builder.Services.AddLumoAuthWebAppAuthentication(builder.Configuration) // cookie session + OpenID Connect sign-in (LumoAuth:Login) .AddLumoAuthBearer(); // and LumoAuth bearer tokens on the same app builder.Services.AddAuthorization(); builder.Services.AddHealthChecks().AddLumoAuth(); var app = builder.Build(); app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/", () => "home"); // Browser: an unauthenticated visit redirects to LumoAuth and comes back signed in. app.MapGet("/dashboard", (ClaimsPrincipal user) => $"Hello {user.FindFirst("name")?.Value ?? user.Identity!.Name}") .RequireAuthorization(); // API: Authorization: Bearer app.MapGet("/api/me", (ClaimsPrincipal user) => new { sub = user.GetLumoAuthSubject(), roles = user.FindAll("roles").Select(c => c.Value) }) .RequireAuthorization(new Microsoft.AspNetCore.Authorization.AuthorizeAttribute { AuthenticationSchemes = LumoAuthDefaults.BearerScheme }); // Permission checks run live against LumoAuth, as the caller. app.MapPost("/invoices/{id}/approve", (string id) => Results.Ok($"approved {id}")) .RequirePermission("invoice.approve"); app.MapGet("/documents/{id}", (string id) => Results.Ok($"document {id}")) .RequireRelation("viewer", "document:{id}"); app.MapDelete("/documents/{id}", async (string id, ILumoAuthorization authz) => await authz.IsOwnerAsync($"document:{id}") ? Results.NoContent() : Results.Forbid()); app.MapGet("/logout", (HttpContext context) => context.SignOutAsync()); app.MapHealthChecks("/health"); app.Run(); ``` In MVC controllers the same guards are attributes: ```csharp [RequirePermission("document.edit")] public IActionResult Edit(string id) { ... } [RequirePermission("document.edit", "document.publish", Mode = RequirementMode.Any)] public IActionResult Publish(string id) { ... } [RequireAbac("document", "delete", "{id}")] public IActionResult Delete(string id) { ... } ``` ## Run it ```bash dotnet run ``` - `https://localhost:5001/dashboard` redirects to LumoAuth, then shows a greeting. - `curl -H "Authorization: Bearer $TOKEN" https://localhost:5001/api/me` returns the token's subject and roles. - `curl -X POST -H "Authorization: Bearer $TOKEN" https://localhost:5001/invoices/42/approve` is 200 when the user holds `invoice.approve`, 403 otherwise. - `https://localhost:5001/health` reports `Healthy` while the instance answers. ## Without ASP.NET Core The `LumoAuth` package alone serves workers, console apps and agents: ```csharp var lumo = LumoAuthClient.FromEnvironment(); // LUMOAUTH_URL, LUMOAUTH_ORG_ID, LUMOAUTH_API_KEY bool ok = await lumo.Permissions.CheckAsync("document.edit", CheckOptions.ForUser(userId)); var agent = LumoAgent.FromEnvironment(); // AGENT_CLIENT_ID, AGENT_CLIENT_SECRET if (await agent.IsAllowedAsync("send_email", new { to = "cfo@acme.com" })) { ... } ``` For sign-in outside the OpenID Connect handler, `LumoAuth.Web.AuthorizationCodeFlow` runs the authorization-code + PKCE flow against any session store. ## Next steps - [SDK overview](https://docs.lumoauth.dev/developer/sdks#c--net): every namespace, the error taxonomy, the raw-request fallback - [AI agent + MCP in five minutes](https://docs.lumoauth.dev/quickstarts/ai-agent-mcp-5min) with `LumoAgent`, `JitContext` and approvals - [Agent security](https://docs.lumoauth.dev/quickstarts/ai-agent-security): AAuth keys, signed HTTP requests and proof-of-possession verification ## Standards used on this page | Plain name | Standard | |---|---| | Signed HTTP requests (HTTP message signatures) | RFC 9421 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Add authentication to your React Native app Source: https://docs.lumoauth.dev/quickstarts/react-native This guide shows you how to add authentication and access control to a React Native (Expo) application using @lumoauth/client and the PKCE flow. PKCE is a one-time secret that protects the sign-in code, so a mobile app… Prompt for your AI editor: ```text Set up LumoAuth authentication in a React Native / Expo application. Install the SDK and Expo dependencies: npm install @lumoauth/client npx expo install expo-auth-session expo-secure-store expo-crypto expo-web-browser Configure environment variables (.env with EXPO_PUBLIC_ prefix): EXPO_PUBLIC_LUMOAUTH_DOMAIN=https://app.lumoauth.dev EXPO_PUBLIC_LUMOAUTH_ORG_ID=YOUR_ORG_ID EXPO_PUBLIC_LUMOAUTH_CLIENT_ID=YOUR_CLIENT_ID Register a custom URI scheme in app.json: { "expo": { "scheme": "myapp" } } The redirect URI will be: myapp://auth/callback Create hooks/useLumoAuth.ts: import { useState, useEffect, useCallback } from 'react'; import * as SecureStore from 'expo-secure-store'; import * as WebBrowser from 'expo-web-browser'; import { AuthModule, LumoAuth } from '@lumoauth/client'; WebBrowser.maybeCompleteAuthSession(); const DOMAIN = process.env.EXPO_PUBLIC_LUMOAUTH_DOMAIN!; const ORG_ID = process.env.EXPO_PUBLIC_LUMOAUTH_ORG_ID!; const CLIENT_ID = process.env.EXPO_PUBLIC_LUMOAUTH_CLIENT_ID!; const REDIRECT_URI = 'myapp://auth/callback'; const authModule = new AuthModule({ baseUrl: DOMAIN, orgId: ORG_ID, clientId: CLIENT_ID }); export function useLumoAuth() { const [accessToken, setAccessToken] = useState(null); const [isLoaded, setIsLoaded] = useState(false); useEffect(() => { SecureStore.getItemAsync('lumoauth_access_token').then(token => { setAccessToken(token); setIsLoaded(true); }); }, []); const signIn = useCallback(async () => { const { url, codeVerifier, state } = await authModule.buildAuthorizationUrl({ redirectUri: REDIRECT_URI, }); await SecureStore.setItemAsync('lumoauth_verifier', codeVerifier); await SecureStore.setItemAsync('lumoauth_state', state); const result = await WebBrowser.openAuthSessionAsync(url, REDIRECT_URI); if (result.type !== 'success') return; const params = new URL(result.url).searchParams; const code = params.get('code'); const returnedState = params.get('state'); const savedState = await SecureStore.getItemAsync('lumoauth_state'); const storedVerifier = await SecureStore.getItemAsync('lumoauth_verifier'); if (!code || returnedState !== savedState || !storedVerifier) { throw new Error('Invalid OAuth callback'); } const tokens = await authModule.exchangeCodeForTokens({ code, codeVerifier: storedVerifier, redirectUri: REDIRECT_URI, }); await SecureStore.setItemAsync('lumoauth_access_token', tokens.access_token); if (tokens.refresh_token) { await SecureStore.setItemAsync('lumoauth_refresh_token', tokens.refresh_token); } await SecureStore.deleteItemAsync('lumoauth_verifier'); await SecureStore.deleteItemAsync('lumoauth_state'); setAccessToken(tokens.access_token); }, []); const signOut = useCallback(async () => { if (accessToken) authModule.revokeToken(accessToken, accessToken).catch(() => {}); await SecureStore.deleteItemAsync('lumoauth_access_token'); await SecureStore.deleteItemAsync('lumoauth_refresh_token'); setAccessToken(null); }, [accessToken]); const getClient = useCallback(() => new LumoAuth({ baseUrl: DOMAIN, orgId: ORG_ID, clientId: CLIENT_ID, token: () => SecureStore.getItemAsync('lumoauth_access_token').then(t => t || ''), }), [] ); return { isSignedIn: !!accessToken, isLoaded, accessToken, signIn, signOut, getClient }; } Use in a component: import { View, Button, ActivityIndicator } from 'react-native'; import { useLumoAuth } from '../hooks/useLumoAuth'; export default function HomeScreen() { const { isSignedIn, isLoaded, signIn, signOut, getClient } = useLumoAuth(); if (!isLoaded) return ; async function checkPermission() { const client = getClient(); const allowed = await client.permissions.check('documents.edit'); console.log('allowed:', allowed); } return isSignedIn ? (
); } return (
{ e.preventDefault(); sendMagicLink(email); }}> setEmail(e.target.value)} placeholder="you@example.com" required /> {error &&

{error}

}
); } ``` #### `useEmailFirst()` Implements the email-first check step. The server answers `exists: true` for every address, so `onNewUser` is only reached if the request fails; send new users to sign-up from a separate link rather than from this check. ```tsx import { useEmailFirst } from '@lumoauth/react'; function EmailStep({ onKnownUser, onNewUser, }: { onKnownUser: (email: string) => void; onNewUser: (email: string) => void; }) { const [email, setEmail] = useState(''); const { checkEmail, isLoading } = useEmailFirst(); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); const exists = await checkEmail(email); if (exists) { onKnownUser(email); } else { onNewUser(email); } } return (
setEmail(e.target.value)} placeholder="you@example.com" required />
); } ``` --- ## Related Guides - [Email & Password Authentication](https://docs.lumoauth.dev/user-guide/authentication/email-password) — traditional credential-based login - [Social Login](https://docs.lumoauth.dev/user-guide/authentication/social-login) — OAuth-based login via external providers - [Multi-Factor Authentication](https://docs.lumoauth.dev/user-guide/authentication/mfa) — add a second factor to any login method - [SDKs & Libraries](https://docs.lumoauth.dev/developer/sdks) — TypeScript, React, and Python SDK reference --- # Social Login Source: https://docs.lumoauth.dev/user-guide/authentication/social-login Social login lets users sign in with an existing account from an external identity provider such as Google, GitHub, or Microsoft. LumoAuth handles the OAuth / OpenID Connect (OIDC) handshake with the provider, creates… Social login lets users sign in with an existing account from an external identity provider such as Google, GitHub, or Microsoft. LumoAuth handles the OAuth / OpenID Connect (OIDC) handshake with the provider, creates or links a local user account, and then issues its own tokens to your application. This reduces registration friction and improves conversion. --- ## Supported Providers | Provider | Protocol | Data Returned | |----------|----------|----------| | **Google** | OAuth 2.0 / OIDC | Email, profile, avatar | | **GitHub** | OAuth 2.0 | Email, profile, organization membership | | **Microsoft** | OAuth 2.0 / OIDC | Email, profile, Azure AD integration | | **Facebook** | OAuth 2.0 | Email, profile, friends list | | **Apple** | OAuth 2.0 / OIDC | Email (private relay), name | | **LinkedIn** | OAuth 2.0 | Email, profile, professional info | The **Provider type** list in the portal also offers **X**, **Discord**, **Twitch** and **Slack**. The list shows each provider's key capitalised, so GitHub appears as **Github** and LinkedIn as **Linkedin**. Any other OpenID Connect provider is added as an OIDC identity provider under **Enterprise SSO (OIDC)**, see [Enterprise SSO](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso#openid-connect-oidc-federation). Google and Microsoft providers have an **Additional configuration** section (**Hosted domain** for Google, **Tenant ID** for Microsoft). The Google hosted domain is enforced, not just hinted. The verified ID token's `hd` claim must equal it, and the account's e-mail must be in that domain. Otherwise the sign-in is refused and no account is provisioned. --- ## How It Works 1. **User clicks a social login button** on your login page. 2. **Redirect to provider.** LumoAuth redirects to the provider's authorization page. 3. **User authenticates** at the provider. 4. **Provider callback.** The provider redirects back to LumoAuth with an authorization code. 5. **Account linking.** LumoAuth creates or links a local user account. 6. **Token issuance.** LumoAuth issues its own OAuth tokens to your application. ### Account Linking Behavior When a user authenticates via social login: - **New user, provider verified the email:** a LumoAuth account is created and signed in (JIT provisioning). - **New user, provider did not verify the email** (`email_verified` missing or false): the account is created **inactive** and the user is sent a verification link ("Almost there — we sent a link to your email address. Verify it, then sign in again."). Until the link is used the account cannot sign in, and it is deleted after 48 hours if never verified. See [Unverified provider emails](#unverified-provider-emails). - **Existing user (same email):** linked only when the provider verified the email, the existing account's email is verified, and the account has no local password, other provider or other sign-in source. Otherwise the sign-in is refused with "An account with this email already exists. Please sign in with your original method to link this provider." - **Multiple social accounts:** a user can link multiple social providers to one account from the **Sign in with other accounts** section of **Sign-in & security** in their account. ### Unverified provider emails Some providers (or a custom OIDC provider) may return an address that the user has not proven they own. Such an address is only a claim, so LumoAuth does not let it become a working account until the mailbox owner verifies it: - Opening the verification link **in the same browser** that started the social sign-up keeps the social link; the user then signs in with the provider again. - Opening it **in another browser** verifies the email but drops the social link, because the person who signed in at the provider may not own the mailbox. - Verifying the email or completing a password reset on such an account removes everything attached while the email was unproven. That includes the social link, MFA (multi-factor authentication) factors, passkeys and push devices, plus sessions and OAuth tokens. This is audited as `account.pre_verification_bindings_dropped`. Sign-in with Google, Microsoft or GitHub on LumoAuth's own `/login` and `/register` pages follows the same rule. For providers that return a verified ID token, the ID-token claims take precedence: the userinfo response can only fill in profile fields that the ID token lacks, never `sub`, `iss`, `aud`, `email` or `email_verified`. --- ## Configuration ### Organization-Level Social Login Social login providers are managed from the **Integrations** page (**Settings → Integrations**): ``` /orgs/{orgId}/portal/integrations ``` (The older `/orgs/{orgId}/portal/social-login` path now redirects here.) In the **Social login** section, click **Add provider** to register a new provider. ![The Social login section of the Integrations page with the Add provider button](https://docs.lumoauth.dev/img/portal/authentication/integrations-social-login.png) On the **Add social login provider** form, pick the **Provider type** (each provider can be added once per organization). You will need: 1. **Client ID** — from the provider's developer console. 2. **Client secret** — from the provider's developer console. 3. **Requested scopes** — what data to request (email, profile, etc.). Filled in with the provider's defaults when you pick one. The form also has an optional **Display name** for the "Sign in with…" button, an **Enable immediately** switch, **Default roles** and **Enable for OAuth apps**. ![The Add social login provider form with Google selected and its callback URL](https://docs.lumoauth.dev/img/portal/authentication/social-provider-add.png) The **callback URL** (the redirect URI that you register with the provider) is generated by LumoAuth from the organization's route. It is shown on the provider form once a provider type is selected. It cannot be changed; the Admin API ignores a `callbackUrl` in the request body. ### Per-Application Social Login You can also configure social login on a per-application basis at: ``` /orgs/{orgId}/portal/applications/oidc/{clientId}/social-login ``` To get there, open the application under **Applications**, go to the **Security** tab and click **Configure social login** in the **Authentication** section. This lets different OAuth clients within the same organization show different social login buttons. ![The OAuth app social login page of an application](https://docs.lumoauth.dev/img/portal/authentication/app-social-login.png) --- ## Setting Up Google Login ### 1. Create Google OAuth Credentials 1. Go to the [Google Cloud Console](https://console.cloud.google.com). 2. Create a new project or select an existing one. 3. Navigate to **APIs & Services → Credentials**. 4. Click **Create Credentials → OAuth client ID**. 5. Select **Web application**. 6. Add the authorized redirect URI: ``` https://your-domain.com/orgs/{orgId}/auth/social/google/callback ``` 7. Copy the **Client ID** and **Client Secret**. ### 2. Configure in LumoAuth 1. Go to `/orgs/{orgId}/portal/integrations`. 2. In the **Social login** section, click **Add provider** and choose **Google** as the **Provider type**. 3. Enter the Client ID and Client secret. 4. Leave **Enable immediately** on and click **Add provider**. --- ## Setting Up GitHub Login ### 1. Create GitHub OAuth App 1. Go to [GitHub Developer Settings](https://github.com/settings/developers). 2. Click **New OAuth App**. 3. Set the authorization callback URL: ``` https://your-domain.com/orgs/{orgId}/auth/social/github/callback ``` 4. Copy the **Client ID** and **Client Secret**. ### 2. Configure in LumoAuth 1. Go to `/orgs/{orgId}/portal/integrations`. 2. In the **Social login** section, click **Add provider** and choose **Github** as the **Provider type**. 3. Enter the credentials and click **Add provider**. --- ## Setting Up Microsoft Login ### 1. Register in Azure AD 1. Go to the [Azure Portal](https://portal.azure.com) → **App registrations**. 2. Click **New registration**. 3. Add the redirect URI: ``` https://your-domain.com/orgs/{orgId}/auth/social/microsoft/callback ``` 4. Under **Certificates & secrets**, create a new client secret. 5. Note the **Application (client) ID** and **Client Secret**. ### 2. Configure in LumoAuth 1. Go to `/orgs/{orgId}/portal/integrations`. 2. In the **Social login** section, click **Add provider** and choose **Microsoft** as the **Provider type**. 3. Enter the credentials and click **Add provider**. --- ## Social Login URLs Each social provider has organization-specific authentication URLs: ``` Initiate: /orgs/{orgId}/auth/social/{provider} Callback: /orgs/{orgId}/auth/social/{provider}/callback Link: /orgs/{orgId}/auth/social/{provider}/link (link a provider to the signed-in user; the provider email must match the account's email) Unlink: /orgs/{orgId}/auth/social/{provider}/unlink (POST) ``` Where `{provider}` is: `google`, `github`, `microsoft`, `facebook`, `apple`, `linkedin`, `x`, `discord`, `twitch`, `slack`. --- ## User Experience When social login is enabled, the login page displays: 1. **Social login buttons** — one button per enabled provider. 2. **Email/password form** — standard credential login (if enabled). 3. **Separator** — visual "or" divider between social and credential login. Users who sign up via social login can later: - Set a password (for email/password login as well). - Link additional social accounts. - Remove linked social accounts (from the settings page of their account). --- ## Account Settings Users can manage their linked social accounts on the **Sign-in & security** page of their account. The **Sign in with other accounts** section has **Link** / **Unlink** buttons; it is shown when the user has a linked account or the organization has a provider that they can link. The page is at: ``` /account/security ``` ![The Sign-in & security page of a user's account](https://docs.lumoauth.dev/img/portal/authentication/account-security.png) From here they can: - View all linked social accounts. - Link new social providers. - Unlink social providers (if they have another login method). --- ## Just-In-Time (JIT) Provisioning When a user authenticates via social login for the first time, LumoAuth automatically: 1. Creates a new user account in the organization. 2. Populates the user profile with data from the social provider. 3. Links the social account to the new user. 4. Assigns the provider's **default roles**, if any are configured. If a user with the same email already exists, the social account is linked to the existing user instead of creating a duplicate. ### Default roles Each provider can carry a list of roles. Every account that the provider provisions receives those roles. Set them on the provider form (portal) or with `defaultRoles` in the [Social Providers API](https://docs.lumoauth.dev/api-reference/social-providers). Because anyone who can sign in through the provider gets these roles, they follow the [no privilege gain](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions#no-privilege-gain) rule: - A **system role** or a role that **confers administrator authority** is refused. That means a role holding permissions such as `users.create`, `roles.edit`, `idps.create`, `settings.manage`, `jit.approve` (full list in the roles guide). The portal shows *Role "X" is a system or administrative role and cannot be used as a social-login default role*; the API returns `400`. - The administrator saving the form must **hold every permission in the role**. Otherwise: *Cannot use role "X" as a default role: it contains permissions you do not hold*. - A rejected request leaves the provider's existing default roles untouched. - At sign-in time LumoAuth checks again and skips any privileged role that somehow ended up in the list, logging a warning. To give a social sign-up administrative rights, provision the account first and then assign the role in the portal. ### Sign-in checks and sessions A social sign-in goes through the same account checks as a password sign-in. A soft-deleted, blocked, inactive or temporarily locked user cannot get in through Google or GitHub either, and sees the same generic error. If the account has MFA enabled, the second factor is still required. Each social sign-in creates a regular session that appears at `/account/sessions` and in the session list of the Admin API. Idle and absolute timeouts, *revoke all sessions* and administrator revocation apply to it exactly as to any other session. See [Sessions](https://docs.lumoauth.dev/user-guide/user-management/sessions). --- ## Security Considerations - **State parameter.** LumoAuth uses the OAuth `state` parameter to prevent CSRF attacks. - **PKCE.** When supported by the provider, PKCE is used for additional security. - **Token storage.** Social provider tokens are encrypted in the database. - **Scope minimization.** Only request the minimum scopes needed. - **Linking checks first.** The link flow resolves the provider identity without creating any account. It is refused when that identity already belongs to another user. It is also refused when the provider returns an email that differs from the signed-in account's email ("This social account uses a different email address than yours, so it cannot be linked."). Providers that return no email at all, such as X, can be linked by a signed-in user. - **No provider text on the page.** An `error` / `error_description` returned by the provider is logged, not shown; users see a generic sign-in error. --- ## Troubleshooting - **"Role ... cannot be used as a social-login default role" when saving a provider.** The role is a system role or grants an administrative permission. Pick a role limited to application permissions, or assign the administrator role to the user afterwards. - **"it contains permissions you do not hold".** You can only hand out what you have. Ask an administrator with the full permission set to save the provider, or trim the role. - **A deactivated user could sign in with Google before, and now cannot.** Expected: social sign-in now runs the standard pre-authentication checks. Reactivate the user if this is unintended. - **"Almost there — we sent a link to your email address".** The provider did not verify the address. The user opens the link (ideally in the same browser), then signs in with the provider again. Links can be re-sent from `/verify-email/resend`. - **Linking a provider fails with "different email address".** When the provider returns an email, it must match the signed-in account's email. Change one of them, or sign in with the provider as a separate user. - **Social sessions were missing from the sessions page.** They are listed now; sessions created before this change do not have a record and simply expire. --- ## Related Guides - [Authentication Overview](https://docs.lumoauth.dev/user-guide/authentication/overview) — all authentication methods - [Enterprise SSO](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso) — SAML and OIDC for corporate IdPs - [Multi-Factor Authentication](https://docs.lumoauth.dev/user-guide/authentication/mfa) — add MFA on top of social login - [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) — the no-privilege-gain rule behind default roles --- # Multi-Factor Authentication (MFA) Source: https://docs.lumoauth.dev/user-guide/authentication/mfa MFA (multi-factor authentication) asks users for a second proof of identity after their first sign-in step. LumoAuth supports passkeys (sign-in with a device biometric or security key), push approval in the LumoAuth… MFA (multi-factor authentication) asks users for a second proof of identity after their first sign-in step. LumoAuth supports **passkeys** (sign-in with a device biometric or security key), **push approval in the LumoAuth app**, **authenticator apps (TOTP, time-based one-time codes)**, **text-message codes** and **email codes**. Single-use **recovery codes** cover the case where a user loses their factors. Each factor has a strength tier. Your organization's MFA policy determines who must use MFA, which factors are allowed and how strong they must be. The model is "recommend, don't enumerate": LumoAuth proposes one best factor for the user's device, starts the user's default factor automatically at sign-in, and keeps the alternatives one click away. For risk scoring of individual sign-ins, see [Adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa). --- ## Factors and strength tiers Every enrolled factor is an **authenticator** with a type, a display name, a state and a tier. Tier 1 is strongest. | Tier | Label | Factor type | What the user does | `amr` values | |---|---|---|---|---| | **T1** | Strongest | `passkey` | Confirms with fingerprint, face, screen lock or security key. Phishing-resistant. | `hwk`, `user` | | **T2** | Strong | `push` | Taps the matching number in the LumoAuth app, unlocked with biometrics or passcode. | `swk`, `user` | | **T3** | Standard | `totp` | Types the 6-digit code from an authenticator app (Google Authenticator, 1Password, …). | `otp` | | **T4** | Basic | `sms_otp` | Types a 6-digit code received by text message. | `sms` | | **T4** | Basic | `email_otp` | Types a 6-digit code received by email. | `otp` | | Recovery | Recovery | `recovery_code` | Types one of 10 single-use codes saved at enrollment. Recovery only. | `otp` | Rules that follow from the tiers: - **Email codes do not count as a second factor** unless the organization turns on `email_otp_counts_as_mfa`. An inbox is often protected by the same password. - **Recovery codes never satisfy a sign-in policy on their own.** Using one starts [account recovery](#recovery). - **A passkey counts as both factors** (passwordless sign-in) while `passwordless_passkey` is on, which is the default. - When several factors qualify, LumoAuth challenges the user's **default** factor. If the default is weaker than the required tier, it picks the strongest of the user's factors that meets the tier. - A new factor becomes the default automatically when it is stronger than the current default (for example, adding a passkey to an account that used TOTP). Authenticators are addressed by a reference of the form `{type}:{id}`, for example `totp:12`, `passkey:7` or `push:5`. The [MFA API](https://docs.lumoauth.dev/api-reference/mfa) uses these references as ids. --- ## Requirement modes The organization's **MFA requirement** (policy key `requirement`) determines who is asked to enroll and whether sign-in can finish without a factor. | Mode | Users without a factor | Users with a factor | |---|---|---| | **Off** (`off`) | Never prompted. | Not challenged at sign-in, even if they set one up. | | **Optional** (`optional`) | Shown **Secure your account** after sign-in, with **Remind me later** (re-prompted after `remind_interval_days`). | Challenged at sign-in unless the device is trusted or adaptive MFA grants a low-risk skip. Medium and high risk always challenge. | | **Risk-based (recommended)** (`risk_based`, default) | Not prompted at sign-in. They can add a factor any time from **Sign-in & security**. A risky sign-in follows the adaptive-auth setting **Risky sign-ins by users without MFA** (`no_mfa_action`: **Allow**, **Prompt for MFA setup**, **Email verification** or **Block access**). | Same as **Optional**. | | **Required** (`required`) | Must enroll. During the grace period they can choose **Remind me later**; after it, sign-in does not complete until a factor is enrolled. | Same as **Optional**. Cannot remove their last factor. | :::note Optional vs. risk-based The two modes differ only for users **without** a factor. **Optional** invites them to enroll after every sign-in (with a weekly **Remind me later**). **Risk-based** leaves them alone unless a sign-in is scored as risky; then `no_mfa_action` applies. Users **with** a factor are challenged the same way in both modes. Organizations that existed before the MFA policy and did not require MFA are mapped to **Risk-based**. ### Grace period and "Remind me later" - With **Optional** or **Required**, **Remind me later** hides the prompt for `remind_interval_days` (default **7**, range 1–90). Each deferral is recorded on the user and audited as `mfa.enroll.deferred`. - With **Required**, each user gets `grace_period_days` (default **14**, range 0–90) counted from the later of their account creation and the moment when the organization switched to **Required**. The screen shows the days left ("You have **5 days** left to set it up"). With a grace period of 0, sign-in is blocked immediately for users without a factor. - `required_factor_count` = **2** makes the backup step mandatory: users with one factor are sent back to **Secure your account** after sign-in until they add a second one. ### How LumoAuth decides at sign-in Password, magic-link, social, passkey and push sign-ins run the same decision after the first step succeeds: 1. **Recovery in progress.** A user who recovered their account must enroll a new factor before anything else. 2. **Passkey sign-in.** A passkey with user verification counts as both factors when `passwordless_passkey` is on and passkeys are allowed. 3. **Push sign-in.** A number-matched push approval from the LumoAuth app counts as MFA. 4. **High risk.** A high or critical [adaptive risk](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa) level revokes all of the user's trusted devices. If the user holds a passkey or push device, the challenge then requires one of those (tier 2 or stronger). 5. **Policy off, or no factor yet.** No challenge; the enrollment rules above apply (and, for a risky sign-in, `no_mfa_action`). 6. **Trusted device.** A valid trusted-device cookie skips the challenge, unless risk is medium or high. 7. **Adaptive low-risk skip.** Adaptive MFA may skip the challenge for a low-risk sign-in, unless risk is medium or high. 8. Otherwise the user is sent to the **MFA challenge**. --- ## Secure your account (enrollment) After sign-in, users who must or are invited to enroll (see [Requirement modes](#requirement-modes)) are sent to the **Secure your account** screen. The same screens open from **Sign-in & security → Add a sign-in method**: ``` /auth/secure-account ``` ![The enrollment screen with the recommended factor and the Other ways list expanded](https://docs.lumoauth.dev/img/portal/authentication/secure-account.png) The screen shows **one recommended factor** as the primary action with a one-line benefit, and every other allowed factor under a collapsed **Other ways** list. Factors that the organization does not allow or cannot deliver (no SMS provider configured) are never shown. ### Which factor is recommended 1. **Passkey**, when the browser reports a platform authenticator (Touch ID, Face ID, Windows Hello, Android screen lock). If it does not, the page promotes the next option as **Recommended for this device**. 2. **LumoAuth app (push)**, when the user is on a phone or already has the LumoAuth app enrolled on another device. 3. **Authenticator app (TOTP)**. 4. **LumoAuth app (push)** on desktop, when not already recommended. 5. **Text message**, only after the options above. 6. **Email code**, last, and only when the organization allows it. ### The screens | Step | Screen | Notes | |---|---|---| | 1 | **Secure your account** | Recommended factor + **Other ways**. A passkey is created right here through the OS prompt. **Remind me later** when the policy allows deferral. | | 2 | Factor setup | One screen per factor, with verification on the same screen (not needed for passkeys). | | 3 | **Add a backup method** | Offered once, recommending the next-best factor of a different type. **Skip for now** unless the organization requires two factors. | | 4 | **Save your recovery codes** | Ten codes with **Copy**, **Download** (`.txt`) and **Print**. The user ticks **I've saved these codes** to continue; they never retype a code. Skipped if the user already holds a set. | | 5 | **Your account is protected** | Summary, then the user continues to where they were going. | A passkey user therefore sees: Secure your account → Add a backup method → recovery codes → done. An authenticator-app user sees one extra screen (the TOTP setup). ### Setting up each factor | Factor | Route | What happens | |---|---|---| | Passkey | (on `/auth/secure-account`) | **Create a passkey** opens the OS prompt. Cancelling returns to the same screen without an error, with **Try again** and the other ways still available. The passkey is named after the device and browser, for example "Mac – Chrome"; users can rename it later. See [Passkeys](https://docs.lumoauth.dev/user-guide/authentication/passkeys). | | LumoAuth app | `/auth/secure-account/push` | Desktop shows a QR code; phones get an **Open the LumoAuth app** button. The app then receives a **test notification** with a number that the user must tap; the page continues on its own once it is approved. See [Push Authentication](https://docs.lumoauth.dev/user-guide/authentication/push-auth). | | Authenticator app | `/auth/secure-account/totp` | QR code, **Can't scan?** (the key in groups of four) and, on phones, **Open in authenticator app** as the primary button. The user types the current code on the same screen. After three wrong codes the page shows a hint to check the phone's automatic date and time. | | Text message | `/auth/secure-account/sms` | Country preset from the browser language; the number is validated before anything is sent. A 6-digit code arrives by text and is entered on the same screen. See [SMS & email](https://docs.lumoauth.dev/integrations/sms-and-email). | | Email code | `/auth/secure-account/email` | Only when email codes are allowed. A 6-digit code is sent to the account's email address. | A setup that is started but never verified expires after **15 minutes** and is discarded. Adding a factor needs a recent sign-in (within 15 minutes) for a user's first factor, and a fresh verification with an existing factor for every later one (see [Step-up](#step-up-for-sensitive-actions)). The older enrollment URLs (`/auth/mfa-enrollment`, `/auth/mfa-setup`, `/auth/mfa-enrollment/sms`) redirect into this flow. --- ## Signing in with a second factor The challenge page is at: ``` /login/mfa-challenge ``` - **Auto-start.** The page starts the user's factor immediately: sends the push, sends the text or email, prepares the passkey prompt, or focuses the code field. The factor last used successfully on this browser is preferred over the default (remembered in the `lumo_mfa_last` cookie). - **Try another way** lists the user's other factors, strongest first, by display name (for example "Text •••• 4821"), plus **Use a recovery code** when the user has codes left. - **Push.** The page shows a two-digit number; the user taps the same number in the LumoAuth app. The page updates on its own when the request is approved. **Resend** sends a new request with a new number, and **Use a code from the app instead** accepts the app's offline 6-digit code. Push requests for a second factor expire after **60 seconds**. - **Trust this device for N days** appears when the organization allows trusted devices (see [Trusted devices](#trusted-devices)). - **I have a temporary access code from my admin** accepts a code issued by an administrator (see [Admin-assisted recovery](#admin-assisted-recovery)). ### Limits | Limit | Default | |---|---| | Wrong answers per challenge | 5, then the user must request a new code or try another way | | Failed MFA attempts per account | 10 in 15 minutes locks MFA for **15 minutes** ("Verification paused") and emails the user | | Push requests per user | 3 in 5 minutes; after that another factor is required | | Denied push requests | 2 denials in 10 minutes suspend push for the user for **30 minutes** | | Code submissions on the hosted challenge page and the [challenge API](https://docs.lumoauth.dev/api-reference/mfa#challenges) | 10 per 15 minutes per user, one shared budget; **Resend** does not count. The API answers `429 rate_limited` with `Retry-After` | | Text-message code | 6 digits, valid 5 minutes, 5 attempts; a new code replaces the old one | | Email code | 6 digits, valid 10 minutes, 5 attempts | Error messages do not say whether a code was wrong or expired; they tell the user what to do next. ### Upgrade nudge and security check-up After a sign-in with an authenticator app, text or email code, users who hold no passkey or push device see a one-click offer to add a passkey at `/auth/mfa-upgrade` ("so you don't need codes next time"). The offer appears only if the organization allows passkeys. Dismissing it hides it for `nudge_interval_days` (default **30**). Thirty days after a user's first enrollment, the same page asks them once to confirm that their sign-in methods and recovery codes are still available. ### Tokens Tokens issued after the challenge carry `amr` (the first-factor method plus the factor used plus `mfa`) and an `acr` value for the strongest tier used. Applications can require a tier with `acr_values`. See [ACR and AMR](https://docs.lumoauth.dev/oauth/authorize#authentication-context-acr-and-amr). --- ## Step-up for sensitive actions Some actions need a **fresh** verification, within `step_up_max_age_seconds` (default **600**, 10 minutes), even when the user is already signed in: - adding a sign-in method when the user already has one, - removing a sign-in method (with a **different** method where the user has one), - generating new recovery codes, - changing the password on **Sign-in & security** or the email address on **Personal info** (when the user has a factor; step-up comes before the current password is checked), - deleting the account (users without a password, such as social, SAML or passkey-only accounts, confirm with step-up alone), - issuing a temporary access code (the administrator's own verification), - downloading a personal data export or data-portability file. In the browser the user is sent to: ``` /auth/step-up?target=/account/security ``` The step-up page starts the user's default factor. If the default is weaker than the organization's **minimum strength for sensitive actions** (`min_tier_step_up`, default T3 — authenticator app or stronger), it starts their strongest factor instead. **Try another way** only lists factors that are strong enough. It returns to `target` once the verification succeeds. A session that already passed MFA within the window is not asked again, under two conditions. **The factor used at that moment** must have been at least that strong (a stronger factor used earlier in the session does not count), and the verification must belong to the signed-in user. A trusted device, an adaptive low-risk skip and a temporary access code satisfy the sign-in, never step-up. **Users without a second factor** cannot step up. For these actions their session must have signed in within the last 10 minutes; otherwise they are asked to sign in again. API callers send the id of an approved **step-up** challenge (not a sign-in challenge) in the `X-MFA-Challenge` header instead. An application can ask for a stronger factor with `min_tier`, but never a weaker one than the policy. See [MFA API → Step-up](https://docs.lumoauth.dev/api-reference/mfa#step-up-for-api-callers). For a push step-up, an application can pass a description of the action (for example "Approve transfer of $500 to •••• 1122"); the LumoAuth app shows it on the approval screen. --- ## Trusted devices After passing the challenge, users can tick **Trust this device for N days**. The next sign-ins from that browser skip the challenge until the trust expires. - N is the organization's `trusted_device_days` (default **30**, range 0–180). **0** turns the option off. - Trust is a signed, `HttpOnly`, `Secure`, `SameSite=Lax` cookie named `lumo_td`, bound to the user and backed by a server-side record that the user can revoke. - Trust is revoked automatically when the user changes their password, removes a sign-in method, recovers their account, or signs in at **high** risk. - Medium or high risk always challenges, even on a trusted device, and sensitive actions always ask again. Users see and revoke trusted devices under **Sign-in & security** (**Forget**, **Forget all**) or through the [API](https://docs.lumoauth.dev/api-reference/mfa#trusted-devices). --- ## Sign-in & security Users manage their factors themselves at: ``` /account/security ``` ![The Sign-in & security page with the Add a sign-in method menu open](https://docs.lumoauth.dev/img/portal/authentication/account-security-add-method.png) The page shows a status (**Your account is protected**, **Add a backup method** or **Your account is at risk**) and every sign-in method with its tier, when it was added and when it was last used. Users can add methods, rename them, choose the default and remove them. It also covers recovery codes, **Trusted browsers** and **Sign out of all other devices**. See [Account self-service](https://docs.lumoauth.dev/user-guide/user-management/account-self-service#sign-in--security). --- ## Recovery When a user cannot use their usual factor, the options are tried in this order: 1. **Another enrolled factor** — **Try another way** on the challenge page. 2. **A recovery code** — **Use a recovery code**. 3. **Admin-assisted recovery** — a temporary access code from an administrator. ### Recovery codes - 10 codes, each 10 characters from an alphabet without look-alikes (no `0`, `O`, `1`, `I` or `L`), shown as `XXXXX-XXXXX`. - Each works once. Codes are compared case-insensitively and with or without the hyphen, and stored only as bcrypt hashes. - Generating a new set invalidates the old one. When 2 or fewer remain, the account page asks the user to generate a new set. - Codes issued before September 2026 (`XXXX-XXXX-XXXX-XXXX`) keep working until the user generates a new set. ### What happens after a recovery Signing in with a recovery code or a temporary access code is treated as an account recovery. LumoAuth then: - signs out every other session and revokes all trusted devices, - emails the user that the account was recovered, - restricts the session to setting up a new sign-in method and saving **new** recovery codes; normal access resumes after that, - audits `mfa.recovery.started`, and `mfa.recovery.completed` once the new factor and recovery codes are in place. ### Admin-assisted recovery Administrators **cannot switch MFA off** for a user. Instead they issue a **temporary access code**: 1. Open the user in the organization portal (`/orgs/{orgId}/portal/users`), section **Two-step verification**. 2. In the form for issuing a temporary access code, enter a **Reason** (required, audited), choose **Valid for** (**1 hour**, **4 hours** or **24 hours**) and whether it is **Single use**, then **Issue code**. 3. Confirm your own identity with MFA if asked. 4. Give the code (12 characters) to the user over a channel that you trust. It is shown once. ![The Two-step verification section of a user's page with the temporary access code form](https://docs.lumoauth.dev/img/portal/authentication/user-two-step-verification.png) The user signs in with their password, picks **I have a temporary access code from my admin** on the challenge page and enters the code. The session can only enroll new factors. Only one live code exists per user; issuing another revokes the previous one. Issuing a code is audited as `mfa.tap.issued` and the user is emailed. From the same section, an administrator can **Remove** a single lost authenticator (for example a stolen phone). The user is notified, their trusted devices are revoked, and the organization's policy still applies at their next sign-in. The same actions are available in the [Admin MFA API](https://docs.lumoauth.dev/api-reference/mfa#admin-endpoints). The old `POST /admin/users/{userId}/mfa/reset` endpoint returns `410 Gone`. --- ## Configuring the MFA policy Navigate to **Security → Sign-in settings** at: ``` /orgs/{orgId}/portal/settings/authentication ``` ![The MFA policy section with enrollment coverage and the Requirement options](https://docs.lumoauth.dev/img/portal/authentication/auth-settings-mfa-policy.png) The **MFA policy** section shows enrollment coverage (enrolled users, users at T1–T2, users in and past the grace period, and how many chose "Remind me later") and the policy settings. Changes take effect at each user's next sign-in; existing sessions are not ended. Every change is audited as `mfa.policy.changed`. The same policy is available as JSON through `GET`/`PUT /orgs/{orgId}/api/v1/admin/policies/mfa` ([reference](https://docs.lumoauth.dev/api-reference/mfa#mfa-policy)). ### Requiring MFA for one application **Applications → your app → Security → Authentication → Require multi-factor authentication** (saved with **Save auth settings**) makes `/authorize` issue a code for that application only when the session passed a second factor for this user. A verified factor, a trusted device or an adaptive low-risk skip all count. Otherwise the user is sent to step-up, which offers enrollment when they have no factor yet, and then returns to `/authorize`. With `prompt=none` the application gets `interaction_required` instead. When the organization policy is **Required** the switch is on and locked. Which factors count is set by the organization's MFA policy. ![The Authentication section on an application's Security tab with Require multi-factor authentication](https://docs.lumoauth.dev/img/portal/authentication/app-security-authentication.png) The older per-application **Enable MFA** and **Enterprise SSO** switches were removed: they were never enforced. Factors and identity providers are organization settings. ### Policy reference | Key | Portal label | Values | Default | |---|---|---|---| | `requirement` | Requirement | `off`, `optional`, `risk_based`, `required` | `risk_based` | | `allowed_factors` | Allowed factors | Any of `passkey`, `push`, `totp`, `sms_otp`, `email_otp` | `passkey`, `push`, `totp`, `sms_otp` | | `min_tier_login` | Minimum strength at sign-in | 1–4 (factors weaker than this do not count at sign-in) | 4 | | `min_tier_step_up` | Minimum strength for sensitive actions | 1–4 | 3 | | `passwordless_passkey` | A passkey counts as both factors | `true` / `false` | `true` | | `required_factor_count` | Factors each user must have | 1 or 2 | 1 | | `grace_period_days` | Enrollment grace period (days) | 0–90 | 14 | | `remind_interval_days` | Re-prompt after "Remind me later" (days) | 1–90 | 7 | | `trusted_device_days` | "Trust this device" period (days) | 0–180 (0 disables) | 30 | | `email_otp_counts_as_mfa` | Email codes count as a second factor | `true` / `false` | `false` | | `sms_block_voip` | Block internet (VoIP) phone numbers | `true` / `false` | `false` | | `sms_country_allowlist` | SMS: only these countries | ISO 3166 alpha-2 codes; empty = all | `[]` | | `sms_country_denylist` | SMS: never these countries | ISO 3166 alpha-2 codes | `[]` | | `nudge_interval_days` | (API only) | 1–365 | 30 | | `step_up_max_age_seconds` | (API only) | 60–3600 | 600 | Values outside a range are clamped to it; unknown factor types are dropped, and an empty `allowed_factors` falls back to the default. Text messages can only be allowed once an [SMS provider](https://docs.lumoauth.dev/integrations/sms-and-email) is configured; factors that the organization cannot deliver are removed from the effective list. Push is included in every plan. The read-only `required_since` key records when the requirement last switched to **Required**. :::note Legacy keys Organizations created before this policy existed used `mfa_required` and `mfa_sms_enabled` in their authentication settings. Until an MFA policy is saved, LumoAuth derives one from them (`mfa_required: true` → **Required**; `mfa_sms_enabled: true` → text messages allowed). Saving the policy writes `mfa_policy` and keeps `mfa_required` and `mfa_sms_enabled` in sync for older integrations. Writing `mfa_required` or `mfa_sms_enabled` through the API for authentication settings is translated into a policy change (`mfa_required: true` → **Required**, `false` → **Risk-based**; `mfa_sms_enabled` adds or removes `sms_otp`). The same page keeps the adaptive-auth setting **Risky sign-ins by users without MFA** (`no_mfa_action`: **Allow**, **Prompt for MFA setup**, **Email verification** or **Block access**). It applies when [adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa) requires a challenge for a user who has no factor, and is how **Risk-based** handles users who have not enrolled. --- ## Notifications Users are emailed when: - a sign-in method is added or removed (other enrolled phones also get a push), - recovery codes are generated or used, - MFA is locked after repeated failures, - they report a push request with **Deny – this wasn't me**, - an administrator issues them a temporary access code, - their account is recovered. Most of these emails include a **This wasn't me** link (valid 7 days) to `/auth/not-me`. Confirming it signs out every session and revokes all trusted devices; a user who has no sign-in method left must set one up at the next sign-in. All MFA activity is written to the audit log and delivered to webhooks as `mfa.*` events. See [Audit logs](https://docs.lumoauth.dev/user-guide/compliance/audit-logs#mfa-events). --- ## What MFA does not include - Codes are delivered by text message or email only; there is no voice-call delivery, and the type of phone line comes from numbering-plan data rather than a live carrier lookup. - There is no SMS-pumping detection beyond the rate limits, and one SMS provider per organization, so no failover between providers. - Account recovery is through recovery codes or an administrator-issued temporary access code; there is no self-service recovery by email plus phone. - A temporary access code is issued by one administrator; there is no two-administrator approval. - Changing a phone number and viewing API credentials do not trigger step-up. - There are no "new device sign-in" notification emails. - The MFA policy is organization-wide: there are no per-group or per-application overrides (an application can only [require MFA](#requiring-mfa-for-one-application)). - Push approvals are handled by the LumoAuth app only; there is no way to approve pushes from your own app, and no account transfer between phones. - MFA events reach a SIEM (security information and event management system) through the [log sinks](https://docs.lumoauth.dev/integrations/siem-log-export) like every other audit event; there is no separate stream of MFA events. --- ## Best practices 1. Keep the default **Risk-based** requirement and turn on [Adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa), or use **Required** with a grace period for workforce organizations. 2. Leave passkeys and push allowed: they are the strongest factors and the fastest to use. 3. Raise **Minimum strength for sensitive actions** to T2 or T1 if your users hold passkeys or the LumoAuth app. 4. Keep email codes as a recovery aid, not a second factor. 5. Point helpdesk staff at temporary access codes; there is no way to switch MFA off for a user. 6. Watch coverage in the portal and the `mfa.push.reported_fraud` and `mfa.locked` events. --- ## Related Guides - [Adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa) — risk scoring and how it interacts with trusted devices - [Passkeys & WebAuthn](https://docs.lumoauth.dev/user-guide/authentication/passkeys) — the strongest factor, and passwordless sign-in - [Push Authentication](https://docs.lumoauth.dev/user-guide/authentication/push-auth) — the LumoAuth app, number matching and "this wasn't me" - [MFA API](https://docs.lumoauth.dev/api-reference/mfa) — build your own enrollment and challenge UI - [SMS & email](https://docs.lumoauth.dev/integrations/sms-and-email) — SMS providers and code delivery - [Audit Logs](https://docs.lumoauth.dev/user-guide/compliance/audit-logs) — MFA events --- # Adaptive MFA Source: https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa Adaptive MFA (multi-factor authentication) evaluates signals about each login attempt and triggers MFA only when the computed risk score is elevated. The signals are device fingerprint, IP reputation, new country,… Adaptive MFA (multi-factor authentication) evaluates signals about each login attempt and triggers MFA only when the computed risk score is elevated. The signals are device fingerprint, IP reputation, new country, geo-velocity, ASN/network anomaly, login velocity, recent failures, and user-behavior analytics. Trusted users log in without friction, suspicious activity gets a challenge, and high-risk activity is blocked entirely. --- ## How Adaptive MFA Works ### Risk Assessment Engine Each login attempt is evaluated by LumoAuth's risk engine, which produces a **risk score from 0 to 100**. The score is mapped to a level using the organization's thresholds, and each level has an action (defaults shown): | Risk Score (default thresholds) | Level | Default action | Configurable? | |------------|-------|--------|--------| | **0–49** | Low | Permit (MFA can be skipped when Adaptive MFA is on) | — | | **50–79** | Medium | MFA challenge | always `mfa` | | **80–94** | High | MFA challenge (`high_risk_action`) | `mfa`, `block`, `allow` | | **95–100** | Critical | **Login refused** (`critical_risk_action`) | `block`, `mfa`, `allow` | The medium and high thresholds are set on the **Adaptive security** page. The critical threshold and the two action settings are not shown there; they keep their stored values unless you change them through the Admin API. The `block` action is evaluated **before** the login completes — no session, remember-me cookie, or token is ever created for a refused attempt. By default only the *critical* band blocks, so for organizations that relied on the previous "high = MFA" behaviour nothing changes unless they opt in to `high_risk_action: block`. Blocking is enforced for the organization's login form, the global `/login` form, passkey (WebAuthn, the browser standard behind passkeys) sign-in and inbound SAML federation. Some logins are established automatically after a redirect from an upstream identity provider (social login, inbound OIDC, OAuth re-authentication). These cannot be refused once the upstream provider has already authenticated the user. For those flows a `block` outcome is downgraded to a mandatory MFA challenge, and the audit entry carries `decision: block_unenforced`. ### Risk Signals The risk engine analyzes multiple signals, each of which can be enabled or disabled per organization (see [Choose Which Risk Signals Are Active](#choose-which-risk-signals-are-active)): | Signal | What It Detects | Example | |--------|----------------|---------| | **Device Fingerprinting** | User is logging in from an unrecognized device or changed browser/OS fingerprint | First login from a new laptop | | **IP Reputation** | Known malicious, proxy, VPN, or Tor exit node IPs | Login from a known VPN | | **New Country** | Login from a country that the user has not used before | User normally in the US, now in Japan | | **Geo-Velocity** | Impossible travel detection | Paris login 10 minutes after a Sydney login | | **ASN Anomaly** | Login from an unexpected network/ASN | Sudden switch to a hosting-provider ASN | | **Login Velocity** | Unusually frequent login attempts | A burst of logins in a short window | | **Failed-Login Detection** | Recent failed login attempts | Multiple wrong passwords before a successful login | | **User Behavior Analytics (UBA)** | Deviation from normal user behavior | Unusual navigation patterns or access requests | ### Decision Flow ```mermaid flowchart TD Login["Login Attempt"] Login --> Collect["Collect Risk Signals\n(device, IP, geo, behavior, etc.)"] Collect --> Score["Calculate Risk Score (0-100)"] Score --> D{"Risk Level?"} D -->|"< medium (50)"| Allow["✅ Allow\n(no MFA)"] D -->|"medium / high (50–94)"| MFA["🔐 MFA Challenge"] D -->|"critical (95+)"| Block["🚫 Refuse login\n(before session is created)"] style Allow fill:#e8f5e9,stroke:#4caf50 style MFA fill:#fff3e0,stroke:#ff9800 style Block fill:#ffebee,stroke:#f44336 ``` --- ## Configuration ### Enable Adaptive MFA Navigate to **Security → Adaptive security** at: ``` /orgs/{orgId}/portal/settings/adaptive-auth ``` In the **Security engine** section, turn on **Enable the adaptive security engine**, then click **Save changes**. While the engine is disabled, the page title carries an **Engine off** badge. ![The Adaptive security page with the Security engine switch and the risk thresholds](https://docs.lumoauth.dev/img/portal/authentication/adaptive-security.png) One action is set separately: what happens on a risky sign-in by a user who has no second factor (**Risky sign-ins by users without MFA**). You set it in the **MFA policy** section of **Security → Sign-in settings** (`/orgs/{orgId}/portal/settings/authentication`). ### Settings The page is divided into **Security engine**, **Risk thresholds**, **Adaptive MFA**, **Detection signals**, **Network guard**, **AI risk assessment** and **Trusted IP ranges**. | Setting | Field | Description | Default | |---------|-------|-------------|---------| | **Enable the adaptive security engine** | `enabled` | Turn on risk-based authentication (the page shows an **Engine active** / **Engine off** badge) | Off | | **Medium risk, MFA challenge** | `medium_risk_threshold` | Score at/above which an MFA challenge is triggered | 50 | | **High risk, block and force MFA** | `high_risk_threshold` | Score at/above which `high_risk_action` applies | 80 | | Critical risk threshold | `critical_risk_threshold` | Score at/above which `critical_risk_action` applies. Not shown on the portal page; saving the page keeps the stored value. | 95 | | High risk action | `high_risk_action` | `mfa` (challenge), `block` (refuse login) or `allow`. Not shown on the portal page. | `mfa` | | Critical risk action | `critical_risk_action` | `block` (refuse login), `mfa` or `allow`. Not shown on the portal page. | `block` | | **Risky sign-ins by users without MFA** | `no_mfa_action` (Sign-in settings → MFA policy) | What to do when an MFA challenge is required but the user has no second factor: `allow`, `prompt`, `email`, or `block` (refuse login) | `allow` | | **Skip MFA on trusted sign-ins** | `adaptive_mfa_enabled` | Let users who already have MFA skip the second factor on sign-ins scored as low risk | Off | | **MFA skip threshold** | `adaptive_mfa_threshold` | Sessions scored below this skip the prompt. MFA is always enforced at or above the medium-risk threshold. | 30 | | **Trusted IP ranges** | `trusted_ip_ranges` | IPs/CIDRs that are always treated as low risk | None | | **Block datacenter and VPN ranges** / **Block Tor exit nodes** (Network guard) | `block_vpn`, `block_tor` | Hard-block logins from a Tor exit node (`block_tor`) or a VPN, proxy or hosting/datacenter IP (`block_vpn`). The block is applied **before** any scoring and regardless of the risk thresholds. Only flags reported by an [IP-intelligence provider](https://docs.lumoauth.dev/integrations/risk-and-bot-protection) (your own or the managed Fraud Shield feed) count, never an IP-prefix guess. Without a provider, these toggles cannot be relied on. Trusted IP ranges are exempt. | Tor: On; datacenter/VPN: Off | ### Choose Which Risk Signals Are Active You do not assign numeric weights; instead you enable or disable individual risk signals. LumoAuth's risk engine (AI-assisted via `RiskAssessmentService`) scores each login over the **enabled** signals. Toggle these in the **Detection signals** section of the Adaptive security page: ![The Detection signals section with one switch per signal](https://docs.lumoauth.dev/img/portal/authentication/adaptive-detection-signals.png) | Signal | Portal label | Field | |--------|--------------|-------| | Geo-velocity / impossible travel | **Impossible travel** | `enable_geo_velocity` | | New country | **New country** | `enable_new_country` | | Device fingerprinting | **Device fingerprinting** | `enable_device_fingerprinting` | | IP reputation | **IP reputation** | `enable_ip_reputation` | | ASN anomaly | **ISP / ASN anomaly** | `enable_asn_anomaly` | | User behavior analytics (UBA) | **Login time patterns** | `enable_uba` | | Login velocity | **Login velocity** | `enable_login_velocity` | | Failed-login detection | **Failed login detection** | `enable_failed_login_detection` | For example, if your users frequently travel, disable `enable_new_country` and rely more on `enable_device_fingerprinting` and `enable_ip_reputation`. Tune the **Medium** and **High** thresholds to control how aggressively challenges and blocks are applied. ### AI Risk Data Sharing In the **AI risk assessment** section, when AI-assisted scoring is enabled (**Send login metadata to the AI provider**, `ai_risk_enabled`), you control exactly what context is shared with the model. The options are precise geolocation (**Share precise geolocation**, `ai_risk_share_precise_geo`), raw IP (**Share raw IP address**, `ai_risk_share_raw_ip`), raw email (**Share raw email address**, `ai_risk_share_raw_email`), and user-agent (**Share user agent**, `ai_risk_share_user_agent`). The user-agent switch is on by default; the others are off. Enable only what your privacy policy permits. ![The AI risk assessment section with its data-sharing switches](https://docs.lumoauth.dev/img/portal/authentication/adaptive-ai-risk-assessment.png) --- ## Risk Events and Fraud Detection ### Event Types LumoAuth generates security events for each adaptive authentication decision: | Event | Description | |-------|-------------| | `adaptive_auth.decision` | One entry per evaluated login with `decision` (`permit`, `challenge`, `block`, `block_unenforced`), `risk_score`, `risk_level`, the contributing `signals` and, for refusals, the `reason` | | `adaptive_auth.error` | Risk scoring failed (the login proceeded without a risk decision — fail open) | | `adaptive_auth.block` / `adaptive_auth.challenge` | A sign-in was refused / challenged (alongside the `decision` row) | | `adaptive_auth.ai_assessment` | The AI risk assessment ran for a sign-in | | `adaptive_auth.ai_quota_exceeded` / `adaptive_auth.ai_risk_assessment_disabled_by_tenant` | The AI assessment was skipped because the quota was spent or the organization switched it off | Individual signals (impossible travel, new device, VPN, failed-attempt bursts) are not separate events; they appear in the `signals` list of the `adaptive_auth.decision` row. ### Viewing Risk Events All adaptive auth events appear in the audit log (**Security → Audit logs**): ``` /orgs/{orgId}/portal/audit-logs ``` ![The Audit logs page with the event type filter](https://docs.lumoauth.dev/img/portal/authentication/audit-logs.png) Filter by event type to see: - Risk scores for recent logins. - Which signals triggered elevated risk. - Blocked login attempts. - MFA challenge outcomes. --- ## User-Facing Behavior ### Low-Risk Login The user experiences a normal login flow — they enter credentials and are immediately authenticated. No MFA prompt is shown. ### Medium-Risk Login (MFA Challenge) After entering credentials, the user is redirected to the MFA challenge: 1. The MFA challenge page is displayed and the user's default factor starts automatically (push, passkey, authenticator app, text or email code). 2. The user completes it, or picks **Try another way**. 3. On successful verification, login proceeds. 4. A **trusted device does not skip this challenge**: at medium risk the user is always challenged, even when the browser has a valid "Trust this device" cookie. The user may tick **Trust this device** again. ### High-Risk Login (not refused) 1. All of the user's trusted devices are revoked before the challenge, so every browser asks again on its next sign-in. 2. If the user holds a passkey or a push device enrolled with the LumoAuth app, the challenge requires one of them (tier 2 or stronger). Authenticator-app, text and email codes are not offered. Users without such a factor are challenged with the factors that they have. 3. On success, login proceeds. --- ## Adaptive MFA and Trusted Devices Adaptive risk and the MFA policy's [trusted devices](https://docs.lumoauth.dev/user-guide/authentication/mfa#trusted-devices) are evaluated together after the first sign-in step: | Risk level | Trusted device cookie | Result | |---|---|---| | Low | Valid | Challenge skipped (trusted device) | | Low | None or invalid | Challenge skipped if `adaptive_mfa_enabled` is on and the score is below `adaptive_mfa_threshold`; otherwise challenged | | Medium | Any | Challenged — trust is ignored | | High / Critical (not refused) | Any | All trusted devices revoked; challenged, with passkey or push required when the user has one | | High / Critical (`block` action) | Any | Login refused before a session is created | Trust is also revoked when the user changes their password, removes a factor or recovers their account. Sensitive actions ([step-up](https://docs.lumoauth.dev/user-guide/authentication/mfa#step-up-for-sensitive-actions)) always require a fresh verification, whatever the risk level. Users without any factor are never challenged; for them, the **Risky sign-ins by users without MFA** setting above determines what happens on a risky sign-in. ### Blocked Login (Critical Risk, or High Risk with `block`) 1. The login is refused before any session is created; the credentials are not accepted even though they were correct. 2. A deliberately generic message is shown on the login page — it does not reveal which signal (IP, device, location) caused the refusal. 3. The refusal is written to the audit trail as `adaptive_auth.decision` with `decision: block` (a Tor/VPN network block also writes `security.ip_blocked` with the blocking reason). It is also recorded as an automated decision under GDPR Art. 22, so the user can contest it from **Account → Automated decisions**. Every block decision raises a **high** [security alert](https://docs.lumoauth.dev/integrations/alerting); challenge and allow decisions do not. 4. The user can try again from a known device or network, or contact an administrator, who can add a trusted IP range or lower the enforcement level. --- ## Impossible Travel Detection LumoAuth detects when a user's login locations indicate physically impossible travel. **Example:** User logs in from New York at 2:00 PM, then from London at 2:30 PM. Since it is impossible to travel 3,400 miles in 30 minutes, this triggers a high-risk alert. ### How It Works 1. Each login records the user's approximate location (from the IP address). 2. The system calculates the distance between consecutive logins. 3. Given the time difference, it determines if the implied travel speed is realistic. 4. If the implied speed is unrealistic, the signal is added to the risk score. ### Thresholds The thresholds are fixed and cannot be changed: an implied speed above **1,000 km/h** over more than 100 km adds **+80** (impossible travel); above **500 km/h** adds **+40** (very fast travel). The comparison is always against the user's previous sign-in attempt. The elapsed time is floored at six minutes, so two quick sign-ins from nearby places do not trigger it. Switch the signal on or off under **Detection signals** on the Adaptive security page. --- ## Trusted IP Ranges You can define IP ranges that are always considered low-risk. This is useful for: - Office IP ranges. - VPN exit nodes used by your organization. - Known network ranges of your customers. Logins from trusted IPs bypass adaptive risk scoring (other security measures like rate limiting still apply). Add them in the **Trusted IP ranges** section of the Adaptive security page: enter an address or CIDR range, click **Add**, then **Save changes**. ![The Trusted IP ranges section with the address field and Add button](https://docs.lumoauth.dev/img/portal/authentication/adaptive-trusted-ip-ranges.png) --- ## Integration with Webhooks Adaptive auth events can be sent to external systems via webhooks: ```json { "event": "adaptive_auth.decision", "decision": "block", "timestamp": "2026-03-18T10:30:00Z", "user_id": "usr_abc123", "risk_score": 85, "signals": { "new_device": true, "impossible_travel": true, "ip_reputation": "suspicious" }, "action_taken": "blocked", "ip_address": "203.0.113.42", "location": { "country": "Unknown", "city": "Unknown" } } ``` Configure webhooks in the **Webhooks** section of **Settings → Integrations** (**Add webhook**): ``` /orgs/{orgId}/portal/integrations ``` ![The Webhooks section of the Integrations page](https://docs.lumoauth.dev/img/portal/authentication/integrations-webhooks.png) --- ## Best Practices 1. Start with medium thresholds and adjust based on your user base. 2. Keep **Trust this device** enabled in the [MFA policy](https://docs.lumoauth.dev/user-guide/authentication/mfa#trusted-devices) to reduce prompts for returning users; they are still challenged at elevated risk. 3. Monitor audit logs regularly to tune your thresholds and which signals are enabled. 4. Set up webhook alerts for high-risk events. 5. Combine with the MFA policy — keep the requirement at **Risk-based** and let users enroll passkeys or the LumoAuth app, which high-risk sign-ins require. 6. Test with real users before enforcing: enable the engine with the high-risk action left at MFA, read the `adaptive_auth.decision` rows in the audit log, and only then opt in to `high_risk_action: block`. 7. Whitelist office IPs if your users have stable network environments. --- ## Adaptive MFA vs. Always-On MFA | Aspect | Always-On MFA | Adaptive MFA | |--------|---------------|-------------| | **User Experience** | MFA every login | MFA only when risk is elevated | | **Security** | Consistent protection | Dynamic, context-aware protection | | **Friction** | High | Low for normal logins | | **Coverage** | Same for all logins | Stronger for suspicious logins | | **Best For** | High-security environments | Consumer apps, B2B SaaS | --- ## Related Guides - [Multi-Factor Authentication](https://docs.lumoauth.dev/user-guide/authentication/mfa) — MFA methods and enrollment - [Audit Logs](https://docs.lumoauth.dev/user-guide/compliance/audit-logs) — view adaptive auth events - [Webhooks](https://docs.lumoauth.dev/user-guide/integrations/webhooks) — get notified about risk events - [Security Overview](https://docs.lumoauth.dev/user-guide/security/overview) — overall security architecture --- # Passkeys & WebAuthn Source: https://docs.lumoauth.dev/user-guide/authentication/passkeys Passkeys provide passwordless authentication using the FIDO2/WebAuthn standard (WebAuthn is the browser API behind passkeys; FIDO2 covers the authenticators that hold them). The user authenticates with a biometric… Passkeys provide passwordless authentication using the FIDO2/WebAuthn standard (WebAuthn is the browser API behind passkeys; FIDO2 covers the authenticators that hold them). The user authenticates with a biometric (fingerprint, face) or hardware security key, and your server never handles a shared secret. The private key never leaves the device; LumoAuth stores only the public key and verifies a signed challenge at login. --- ## What Are Passkeys? Passkeys are cryptographic credentials stored on the user's device. They use public-key cryptography — the private key never leaves the device, and the public key is stored in LumoAuth. Authentication happens via a challenge-response, which makes passkeys: - **Phishing-resistant** — bound to the specific origin (domain). - **Free of shared secrets** — nothing reusable to steal from the server. - **Biometric-backed** — require fingerprint, face, or PIN to use. - **Cross-device** — can sync across devices via platform credential managers. --- ## Supported Credential Types | Type | Examples | Use Case | |------|---------|----------| | **Platform Authenticators** | Touch ID, Face ID, Windows Hello | Built into the device | | **Roaming Authenticators** | YubiKey, Titan Security Key | USB/NFC hardware keys | | **Synced Passkeys** | iCloud Keychain, Google Password Manager, 1Password | Cross-device passkeys | --- ## How It Works ### Registration (Enrollment) 1. The user starts from **Secure your account** after sign-in, or from **Sign-in & security → Add a sign-in method**. On **Secure your account**, a passkey is the recommended first factor whenever the browser reports Touch ID, Face ID, Windows Hello or an Android screen lock. 2. They click **Create a passkey**. 3. The browser prompts for biometric verification, a screen lock or a security key. 4. A new public-private key pair is generated on the device; the private key never leaves it. 5. LumoAuth verifies the attestation and stores the public key. 6. The passkey is named after the device and browser, for example "Mac – Chrome" or "iPhone – Safari". Users can rename it later. Passkeys are created as **discoverable credentials** (`residentKey: required`), and the request lists the user's existing passkeys in `excludeCredentials` so the same authenticator is not registered twice. ### Authentication 1. User enters their email (or clicks **Sign in with a passkey**). 2. LumoAuth sends a cryptographic challenge to the browser. 3. Browser prompts for biometric verification or PIN. 4. The private key on the device signs the challenge and marks it *user verified*. 5. LumoAuth verifies the signature with the stored public key and rejects the assertion if the user was not verified. 6. Login is complete — no password needed. ### User verification is required A passkey is a complete sign-in on its own, so LumoAuth asks for **user verification** (UV) on every registration and every sign-in. UV means the authenticator confirmed *who* is present (biometric or PIN), not just *that* something was present (a touch, called user presence or UP). Concretely: - Registration uses `userVerification: "required"`, so a credential can only be created with a PIN or biometric. A security key without a PIN prompts the user to set one. - Sign-in uses `userVerification: "required"`, and the server checks the UV flag in the signed assertion. An assertion produced by a bare touch is rejected. - Clients cannot downgrade this; the request options are generated server-side. - The user lookup for a passkey is scoped to the organization whose login page or custom domain the ceremony runs on. A credential registered in one organization therefore cannot sign in to another. What users see: Touch ID, Face ID, Windows Hello, Android biometrics or their password-manager prompt as usual; on a hardware key, a PIN prompt followed by a touch. **Info: Standard** **WebAuthn Level 2 (W3C) / FIDO2.** The `userVerification` option distinguishes *user presence* (UP: someone touched the authenticator) from *user verification* (UV: the authenticator checked a PIN or biometric). When a passkey replaces the password, only UV proves that the account owner is the one signing in. The relying party must therefore request `required` and check the UV flag in the assertion's authenticator data. [WebAuthn: user verification](https://www.w3.org/TR/webauthn-2/#user-verification) --- ## Configuration ### Enable Passkeys Navigate to **Security → Sign-in settings** at: ``` /orgs/{orgId}/portal/settings/authentication ``` In the **Identity methods** section, turn on **Passkeys & biometrics** and click **Save changes**. Two sub-options appear below the switch: **Permit platform-bound biometrics (Windows Hello, macOS Touch ID)** and **Require FIDO-certified authenticators**. ![The Identity methods section with the Passkeys & biometrics switch and its sub-options](https://docs.lumoauth.dev/img/portal/authentication/auth-settings-identity-methods.png) ### Settings | Setting | Description | |---------|-------------| | **Passkeys & biometrics** (`passkey_enabled`) | Users can register passkeys in account settings and sign in with them. When it is **Off**, passkey sign-in is refused at the WebAuthn endpoints ("Passkey sign-in is not enabled for your organization. Sign in another way."), not just hidden on the login page. The account page also rejects new passkeys (`403 passkeys_disabled`) unless the [MFA policy](https://docs.lumoauth.dev/user-guide/authentication/mfa) (multi-factor authentication) allows passkeys as a factor. | | **Require FIDO-certified authenticators** | An authenticator may register only if its AAGUID appears in the [FIDO Metadata Service](https://docs.lumoauth.dev/integrations/fido-mds) cache with a certification and no compromise report. Applies to every registration path: the account page and the [MFA API](https://docs.lumoauth.dev/api-reference/mfa) (`400` with "This authenticator is not FIDO certified…"). | Passkeys used as a **second factor** are governed by the MFA policy's allowed factors, not by **Passkeys & biometrics**. Turning passkey sign-in off therefore does not lock out users who verify with a passkey as their second step. User verification is not a setting: it is always required, for every organization. --- ## WebAuthn Routes LumoAuth exposes two distinct sets of WebAuthn endpoints — one for **logging in** with an existing passkey, and one for **enrolling** a passkey from account settings. Both follow the WebAuthn specification and are driven by the portal's `navigator.credentials` calls; you normally do not call them directly. **Login (passwordless sign-in):** ``` POST /login/webauthn/options → Generate authentication options (challenge) POST /login/webauthn/result → Verify the signed assertion and sign the user in ``` **Account enrollment (add/remove a passkey):** ``` POST /account/security/passkey/options → Generate registration options POST /account/security/passkey/register → Complete registration DELETE /account/security/passkey/{id} → Remove an enrolled passkey ``` There is also a passkey login endpoint for the OAuth flow, used during the authorization flow: ``` GET /orgs/{orgId}/api/v1/oauth/passkey-login ``` --- ## User Experience ### Enrolling a Passkey From **Secure your account** (`/auth/secure-account`) or the account's security page (`/account/security` → **Add a sign-in method**): ![The Sign-in & security page with the Add a sign-in method menu offering Passkey](https://docs.lumoauth.dev/img/portal/authentication/account-security-add-method.png) 1. Click **Create a passkey** (from **Add a sign-in method**, choose **Passkey**). 2. Complete the biometric, screen-lock or security-key prompt. 3. The passkey appears in **Sign-in & security** with its default name, the **Strongest** tier label and the date it was added. If the user cancels the OS prompt, the screen stays where it was, without an error, and shows **Try again** next to the other ways to secure the account. A user who already holds a sign-in method must confirm it is them before adding a passkey (see [step-up](https://docs.lumoauth.dev/user-guide/authentication/mfa#step-up-for-sensitive-actions)). ### Logging In with a Passkey On the login page: 1. Click **Sign in with a passkey** (or start typing an email). 2. The browser shows available passkeys. 3. Select a passkey and verify with biometrics. 4. Login proceeds — no password or MFA needed. ### Managing Passkeys Users manage passkeys under **Sign-in & security** (`/account/security`), together with their other sign-in methods: ![The Sign-in & security page listing the user's sign-in methods under Two-step verification](https://docs.lumoauth.dev/img/portal/authentication/account-security.png) - See each passkey with its name, when it was added and when it was last used. - **Rename** a passkey or **Make default**. - **Remove…** a passkey. This needs a fresh verification with a *different* sign-in method when the user has one. It is refused when it is the user's last method and the organization requires MFA. - Add another passkey. --- ## Passkeys and the MFA policy Passkeys are the strongest factor (tier 1, "Strongest") in the [MFA policy](https://docs.lumoauth.dev/user-guide/authentication/mfa#factors-and-strength-tiers) and the first recommendation during enrollment. - **Passwordless (default).** With the policy key `passwordless_passkey` on (portal: *A passkey counts as both factors*), signing in with a passkey that performed user verification satisfies both factors: no password and no second prompt. Tokens carry `amr: ["hwk", "user", "mfa"]` and `acr: urn:lumoauth:acr:mfa:phr`. - **Passwordless off.** A passkey sign-in counts as the first step only, and the user is asked for a second factor like any other sign-in. - **As a second factor.** After a password, the MFA challenge can use a passkey: the browser shows its passkey prompt and the user confirms with biometrics. Assertions are restricted to the user's own passkeys. - **Step-up.** Applications can demand a phishing-resistant sign-in with `acr_values=urn:lumoauth:acr:mfa:phr` (or `phr`); see [ACR and AMR](https://docs.lumoauth.dev/oauth/authorize#authentication-context-acr-and-amr). If the organization removes `passkey` from `allowed_factors`, passkeys are not offered for enrollment and do not count at sign-in. --- ## Security Benefits | Feature | Passwords | Passkeys | |---------|-----------|----------| | Phishing risk | High — can be entered on fake sites | None — bound to origin | | Server breach | Hashed passwords can be cracked | Only public keys stored | | Replay attacks | Possible | Impossible — challenge-response | | User effort | Remember complex passwords | Touch fingerprint sensor | | Cross-site | Same password often reused | Unique per site | --- ## Browser Support Passkeys are supported in: - **Chrome** 108+ (Windows, macOS, Android) - **Safari** 16+ (macOS, iOS) - **Firefox** 122+ (Windows, macOS, Linux) - **Edge** 108+ (Windows, macOS) For older browsers, passkey options will not appear and users can fall back to password + MFA authentication. --- ## Troubleshooting - **"Sign-in failed" right after a successful touch on a security key.** The key has no PIN, so it could not perform user verification. Set a PIN on the key (in the browser's or OS security-key settings) and try again; registration of a new key will ask for one automatically. - **Registration prompt asks to create a PIN.** Expected on keys without one; user verification is mandatory. - **A passkey works on one organization's login page but not another's.** Passkeys are tied to the organization in which they were registered. Register a separate passkey per organization. - **Older automation or custom clients that sent `userVerification: "discouraged"`.** The server ignores client preferences; update the client to allow the UV prompt. --- ## Related Guides - [Authentication Overview](https://docs.lumoauth.dev/user-guide/authentication/overview) — all authentication methods - [Multi-Factor Authentication](https://docs.lumoauth.dev/user-guide/authentication/mfa) — factor tiers, policy and recovery - [Email & Password](https://docs.lumoauth.dev/user-guide/authentication/email-password) — traditional fallback ## Standards used on this page | Plain name | Standard | |---|---| | Passkeys (WebAuthn and FIDO2) | W3C Web Authentication and FIDO Alliance CTAP | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Push Authentication Source: https://docs.lumoauth.dev/user-guide/authentication/push-auth Push authentication lets users approve sign-ins from the LumoAuth app on their phone instead of typing a code. LumoAuth sends a notification, and the app shows who is signing in and from where. The user taps the number… Push authentication lets users approve sign-ins from the LumoAuth app on their phone instead of typing a code. LumoAuth sends a notification, and the app shows who is signing in and from where. The user taps the **number shown on the sign-in screen** and confirms with biometrics or their passcode. A denial rejects the sign-in and records an audit event. Push is a **tier 2 ("Strong")** factor in the [MFA policy](https://docs.lumoauth.dev/user-guide/authentication/mfa#factors-and-strength-tiers) (multi-factor authentication). It works as: - a **second factor** after a password (the MFA challenge), - a **step-up** factor for sensitive actions, where the app can show what is being approved, - a **passwordless** sign-in (email → push). --- ## Prerequisites - The user has the LumoAuth app on their phone and has enrolled it for their account. - `push` is in the MFA policy's allowed factors (it is by default). Push is included in every plan, the free Developer plan included. --- ## Number matching Every sign-in, step-up and enrollment-test push is **number-matched**. This is mandatory and cannot be turned off. 1. The sign-in page shows a two-digit number (10–99). 2. The app shows three numbers; the user taps the one that matches the screen. 3. The app signs the chosen number with the device key. A wrong number counts as a **denial**. Number matching stops "MFA fatigue" attacks. An attacker who triggers pushes cannot get them approved by a user who taps without looking, because the user never sees the attacker's screen. :::caution Update the app Approvals without a number are refused with `number_match_required`, so app versions released before September 2026 can no longer approve sign-in requests. Ask users to update the LumoAuth app. Agent approvals (Ask-to-Approve / CIBA) have no sign-in screen and are not number-matched. --- ## How It Works ```mermaid sequenceDiagram participant Browser participant LumoAuth participant App as LumoAuth app Browser->>LumoAuth: Sign in (password, or email for passwordless) LumoAuth->>App: Push notification LumoAuth-->>Browser: Show number, e.g. 42 App->>LumoAuth: Fetch request details (signed) App->>LumoAuth: Approve with number 42 (biometric + signature) LumoAuth-->>Browser: Sign-in continues ``` The approval screen in the app shows: - the organization name and, when known, the application name, - the approximate location, browser and operating system of the sign-in, and the time, - for step-up, the action being approved when the application provides one (for example "Approve transfer of $500 to •••• 1122"), - the number choices. Approving requires the phone's biometrics or passcode. As a second factor or step-up, a push expires after **60 seconds**. The sign-in page offers **Resend** (a new push with a new number) and **Use a code from the app instead**, which accepts the 6-digit offline code that the app generates for the account. Passwordless push sign-in requests expire after 120 seconds. ### "Deny – this wasn't me" If the user did not start the sign-in, they tap **Deny – this wasn't me**. LumoAuth: - denies the request, - audits `mfa.push.denied` and `mfa.push.reported_fraud` (with the initiating IP, location and user agent) and delivers them to webhooks, - marks the account and emails the user that the attempt was blocked and that someone may know their password. A plain **Deny** only denies the request and audits `mfa.push.denied`. ### Limits | Limit | Default | |---|---| | Push requests per user (passwordless sign-in, second factor and step-up) | 3 per 5 minutes; after that the user must use another factor | | Denied requests | 2 denials within 10 minutes suspend push for the user for **30 minutes** ("Push sign-in is paused on your account") | | Passwordless push sign-in requests | Also rate-limited per email address and per client IP. Over the per-user budget or while push is suspended, the sign-in page is shown unchanged, but no push is sent. | | Lifting a suspension | Adding a phone again from the user's signed-in Security page lifts the suspension and the spent budget as soon as the new phone approves its test notification (audited as `mfa.push.limits_reset`). | --- ## Enrolling a Device Users enroll the app from **Secure your account** (`/auth/secure-account`) or from **Sign-in & security → Add a sign-in method** (`/account/security`). On **Secure your account**, push is recommended on phones and for users who already use the app elsewhere. ![The enrollment screen listing Use the LumoAuth app among the available methods](https://docs.lumoauth.dev/img/portal/authentication/secure-account.png) 1. **Continue** on the LumoAuth app screen (`/auth/secure-account/push`). 2. On a computer, scan the **QR code** with the app. On a phone, tap **Open the LumoAuth app** (a `lumoauth://enroll?d=…` link carrying the same data as the QR code). Users can also type the **activation code** shown under the QR code. 3. The app registers a device key with LumoAuth and receives an **offline code seed**, so it can show 6-digit codes without a network connection. 4. LumoAuth immediately sends a **test notification**. The page shows a number; the user taps it in the app and approves. 5. The page continues on its own once the test is approved. If the test is denied or expires, **Send the test notification again**. The device only becomes usable after the test push is approved. The enrollment QR code, deep link and activation code are single-use and expire after **10 minutes**. A user can have at most **5** active devices; other enrolled phones get a notification when a device is added. Adding a device is a credential-changing action. A user's first sign-in method can be added within 15 minutes of signing in; later ones need a fresh verification with an existing method. Otherwise `POST /account/push-auth/start-enrollment` returns `403` with `"code": "reauthentication_required"` and a `reauth_url`. The request also needs the CSRF token for the `push_auth_start_enrollment` token id (`_token` field or `X-CSRF-Token` header), so it can only be started from the LumoAuth page itself. ### Admin-Initiated Enrollment Organization admins can send a user an activation code by email: 1. Go to **Users** (`/orgs/{orgId}/portal/users`). 2. Open the user. 3. In the **Push authentication** section, click **Send activation code**. ![The Push authentication section of a user's page with the Send activation code button](https://docs.lumoauth.dev/img/portal/authentication/user-push-activation.png) --- ## Managing Enrolled Devices Devices appear as sign-in methods under **Sign-in & security** (`/account/security`): ![The Sign-in & security page listing the user's sign-in methods under Two-step verification](https://docs.lumoauth.dev/img/portal/authentication/account-security.png) | Action | Description | |--------|-------------| | **View** | Device name, **Strong** tier label, date added, last used time and place. A device still waiting for its test push shows **Waiting for test approval**. | | **Rename** / **Make default** | Change the display name, or make push the factor started automatically at sign-in. | | **Remove…** | Revoke the device. Needs a fresh verification with a different sign-in method where one exists; refused when it is the last method and MFA is required. Removing a device also revokes the user's trusted devices. | To move to a new phone, enroll the new phone first, then remove the old one. --- ## Enforcing Push Authentication You can require an enrolled device for everyone: 1. Go to **Security → Sign-in settings** (`/orgs/{orgId}/portal/settings/authentication`). 2. In the **Identity methods** section, enable **Require push authentication** and click **Save changes**. ![The Identity methods section with the Require push authentication switch](https://docs.lumoauth.dev/img/portal/authentication/auth-settings-identity-methods.png) 3. Users without an active device are sent to the **Push authentication required** page (`/push-auth/enrollment-required`) after sign-in and cannot continue until they finish. The switch is available on every plan. The same section has **Name shown in push notifications**: what a sign-in or approval request is said to be for on the phone, for example your product's name. Left empty, notifications use the organization name. Once enrolled, push becomes the user's default factor unless they already have a passkey, so the MFA challenge sends a push. For a general "everyone must use MFA" rule, prefer setting the [MFA policy](https://docs.lumoauth.dev/user-guide/authentication/mfa#requirement-modes) requirement to **Required**, which lets users choose any allowed factor. --- ## Security Considerations | Concern | Mitigation | |---------|-----------| | Push fatigue / accidental approval | Mandatory number matching; 3 pushes per 5 minutes; push suspended after 2 denials; **Deny – this wasn't me** alerts the user and the organization | | Device compromise | Approvals need the device key and the phone's biometrics or passcode; users and admins can remove a device | | Hijacked browser session used to enroll a rogue device | Enrollment needs a recent sign-in or fresh MFA plus a CSRF token, and ends with a test push that the user must approve | | Anonymous login requests cancelling pending approvals | Starting a push login for an email address only replaces that user's earlier pending *login* pushes. Agent and CIBA approval requests are never touched by an anonymous caller. | | Offline phone | **Use a code from the app instead** accepts the app's offline code; the user can also **Try another way** | | Lost device | The user signs in with another factor or a recovery code and removes the device; an administrator can remove it or issue a [temporary access code](https://docs.lumoauth.dev/user-guide/authentication/mfa#admin-assisted-recovery) | --- ## Admin: Lost Device Administrators cannot switch MFA off for a user. When a user loses their phone: 1. Go to `/orgs/{orgId}/portal/users` and open the user. 2. In **Two-step verification**, **Remove** the lost device. The user is notified. 3. If the user has no other way to sign in, issue a temporary access code (**Reason**, **Valid for**, **Single use**, then **Issue code**). They can then sign in once and enroll a new device. ![The Two-step verification section of a user's page with Remove and the temporary access code form](https://docs.lumoauth.dev/img/portal/authentication/user-two-step-verification.png) See [Admin-assisted recovery](https://docs.lumoauth.dev/user-guide/authentication/mfa#admin-assisted-recovery). --- ## Related - [Multi-Factor Authentication](https://docs.lumoauth.dev/user-guide/authentication/mfa) - [Mobile approvals device API](https://docs.lumoauth.dev/agents/mobile-approvals) — the wire protocol that the app uses - [Passkeys & WebAuthn](https://docs.lumoauth.dev/user-guide/authentication/passkeys) - [Sign-in settings](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-setup) --- # Enterprise SSO Source: https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso Enterprise SSO (single sign-on) lets employees log in to LumoAuth with their existing corporate identity provider. LumoAuth supports three protocols: SAML 2.0 (as both Service Provider and Identity Provider), OpenID… Enterprise SSO (single sign-on) lets employees log in to LumoAuth with their existing corporate identity provider. LumoAuth supports three protocols: SAML 2.0 (as both Service Provider and Identity Provider), OpenID Connect (OIDC) federation, and direct LDAP / Active Directory authentication (LDAP is the directory protocol that corporate networks use). All three integrate with LumoAuth's Just-in-Time (JIT) provisioning so user accounts are created automatically on first login. --- ## Supported Protocols **Settings → Integrations** is where you configure Enterprise SSO, social login, LDAP, SCIM (automatic user provisioning) and webhooks: ![The Integrations page with the Enterprise SSO (SAML) and Enterprise SSO (OIDC) sections](https://docs.lumoauth.dev/img/portal/authentication/integrations.png) | Protocol | Use Case | LumoAuth Role | |----------|----------|--------------| | **SAML 2.0** | Enterprise SSO with Okta, Azure AD, PingFederate, etc. | Service Provider (SP) and Identity Provider (IdP) | | **OpenID Connect** | SSO with external OIDC providers | Relying Party (RP) | | **LDAP / Active Directory** | Direct directory authentication | LDAP client with JIT provisioning | --- ## SAML 2.0 ### LumoAuth as Service Provider (SP) When LumoAuth acts as a SAML SP, it delegates authentication to an external SAML IdP (like Okta or Azure AD). **Flow:** 1. User starts single sign-on at `/orgs/{orgId}/saml/sp/login` (your application links or redirects there). 2. LumoAuth generates a SAML AuthnRequest and redirects to the IdP. 3. User authenticates at the IdP. 4. IdP sends a SAML Assertion back to LumoAuth. 5. LumoAuth validates the assertion and creates or links the user account. 6. User is logged in. ### Configuration Configure connections to SAML IdPs in the **Enterprise SSO (SAML)** section of **Settings → Integrations**. The section lists your identity providers, shows the metadata URL of the service provider, and has the **Copy SP metadata URL** and **Add SAML IdP** buttons. (The old `/saml-idps` list URL now redirects here.) Click **Add SAML IdP**. Paste the IdP's metadata URL under **Import from IdP metadata** and click **Fetch from metadata URL** to fill the form in, or enter the values by hand: ![The Add SAML identity provider form with the metadata import and the identity provider fields](https://docs.lumoauth.dev/img/portal/authentication/saml-idp-add.png) **Required Settings:** | Setting | Description | |---------|-------------| | **Display name** | A friendly name for this identity provider | | **Entity ID (issuer)** | The identity provider's entity ID | | **SSO URL** | Single Sign-On URL for SAML requests | | **SLO URL** | Single Logout URL (optional) | | **X.509 certificate (PEM)** | The IdP's signing certificate, for signature verification | | **Certificate fingerprint (SHA-256)** | Must match the certificate; filled automatically when importing from metadata | | **NameID format** | Format for the user identifier (email address, persistent, transient, unspecified) | | **Allowed email domains** | Optional, comma-separated (`acme.com, acme.co.uk`). Only addresses in these domains and their sub-domains can be provisioned or linked through this IdP. **Required to link existing accounts when the organization has more than one SAML IdP.** | ### How SAML users are matched A SAML user is bound to the IdP that issued their NameID, stored as `saml:{idpConfigId}:{nameId}` (NameIDs too long for the column become `saml:{idpConfigId}:sha256:{hex}`). A NameID is only unique within its IdP, so one IdP can no longer assert another IdP's NameID and get into that user's account. On sign-in LumoAuth looks for, in order: 1. The account bound to this IdP and NameID. 2. An existing account with the asserted email, only when the IdP asserts `email_verified` **and** is authoritative for the email's domain. The IdP is authoritative when the domain is in its **Allowed email domains**. With that list empty, LumoAuth falls back to the host of the entity ID (an IdP at `https://idp.acme.com` covers `@acme.com`). This fallback applies only when this is the organization's **only** SAML IdP and no other IdP lists the domain. Accounts that already have a local password, a social provider or a different SAML IdP are never taken over. 3. A new account (just-in-time provisioning), only for domains the IdP is authoritative for; otherwise refused and audited as `saml_jit_domain_mismatch`. #### Accounts created before per-IdP binding Older SAML accounts stored the bare NameID. On upgrade (migration `Version20260928300001`): - **Organizations with one SAML IdP** are migrated automatically. Any bare-NameID row left over is upgraded on the user's next sign-in (`saml_legacy_binding_upgraded`). - **Organizations with several SAML IdPs** keep the bare-NameID rows, because LumoAuth cannot tell which IdP issued them. Those users' sign-ins are refused with "Account already exists but is not linked to this identity provider; please ask your administrator to relink it." and audited as `saml_legacy_binding_ambiguous` (with `idp_config_id` and `user_id`). Set **Allowed email domains** on every IdP first, so LumoAuth can suggest which IdP each legacy user belongs to, then relink them in one of three ways. Relinking keeps the user's NameID and binds it to the IdP that you choose. - **Portal:** open **Integrations → SAML IdP → Edit** for the IdP that issues the users' NameIDs. The **Legacy users** panel is shown only when the organization has more than one SAML IdP and legacy users exist. It lists each user with the IdP whose allowed email domains claim their address; users that only this IdP claims are pre-selected. Choose **Preview relink to …** (the button names the IdP) to see what would change (a dry run), then **Relink N users**. - **Admin API:** `GET /orgs/{orgId}/api/v1/admin/identities/legacy-saml` returns the report (`suggested_idp_id` per user). `POST` the same path with `{"idp_id": 7, "all_matching_domains": true, "dry_run": true}` (or `"user_ids": [...]`) to preview, then again with `"dry_run": false`. See [Federated identities](https://docs.lumoauth.dev/api-reference/users#federated-identities). - **CLI:** `lumo identities legacy-saml`, then `lumo identities legacy-saml --idp 7 --relink --dry-run` and `--relink --yes`. See [`lumo identities`](https://docs.lumoauth.dev/cli/identities). Each relinked user is signed out everywhere, emailed, and audited as `identity.link.created`; the run as a whole is audited as `identity.link.bulk_relinked`. Two kinds of entries are skipped: users holding permissions that you lack, and NameIDs already linked to someone else at that IdP. #### Linking a SAML identity to an existing account You can link a single account to an IdP, for example for a user who signed up with a password before SSO was set up, or whose NameID changed. Open the user in **Users**, go to **Federated identities** and choose **Link SAML identity**. Pick the **Identity provider** and enter the **NameID** that the IdP asserts for the user. The link buttons appear in this section once the organization has a SAML IdP (or an LDAP directory) configured. ![The Federated identities section of a user's page](https://docs.lumoauth.dev/img/portal/authentication/user-federated-identities.png) The same is available as `POST /orgs/{orgId}/api/v1/admin/users/{userId}/identities` and `lumo users identities link --saml-idp --name-id `. The link is refused when another user already holds that NameID at the IdP, or when the account is linked to LDAP or a social provider (unlink that first). See [Relinking federated identities](#relinking-federated-identities). **LumoAuth SP Metadata:** Your SAML IdP needs LumoAuth's SP metadata, available at: ``` /orgs/{orgId}/saml/sp/metadata ``` Or configure manually in your IdP: - **SP Entity ID:** `https://your-domain.com/orgs/{orgId}/saml/sp/metadata` - **ACS URL:** `https://your-domain.com/orgs/{orgId}/saml/sp/acs` - **SLO URL:** `https://your-domain.com/orgs/{orgId}/saml/sp/slo` Single logout messages must be **signed by the IdP**. LumoAuth always requires a signature on `LogoutRequest` and `LogoutResponse` messages at the SLO endpoint, independent of the *Require signed responses* setting. This way nobody who only knows a user's NameID can end their session. Enable "sign logout messages" (Okta: *Sign SAML logout requests*; Entra: certificate-based signing) on the IdP side, or do not configure SLO at all. Details in [SAML SP](https://docs.lumoauth.dev/saml/sp#single-logout-slo). ### LumoAuth as Identity Provider (IdP) LumoAuth can also act as a SAML IdP, letting external applications authenticate users stored in LumoAuth. SAML SP (client) applications are listed alongside your other apps on the Applications page: ``` /orgs/{orgId}/portal/applications ``` To register a new SAML SP (client), click **New application** and choose **SAML 2.0** as the protocol, or open the full SAML form directly at: ``` /orgs/{orgId}/portal/applications/saml/create ``` ![The New SAML application form](https://docs.lumoauth.dev/img/portal/authentication/saml-application-create.png) LumoAuth's IdP metadata for these clients is published at `/orgs/{orgId}/saml/idp/metadata`, with the SSO endpoint at `/orgs/{orgId}/saml/idp/sso` and single logout at `/orgs/{orgId}/saml/idp/slo`. For each SAML client: - **Entity ID (issuer)** — the service provider's entity ID. - **Assertion Consumer Service (ACS) URL** — where to send the SAML assertion. - **NameID format** and **NameID source** — how to identify the user. - **Attribute Statements** — which user attributes to include. --- ## OpenID Connect (OIDC) Federation ### Connect External OIDC Providers LumoAuth can act as an OIDC Relying Party (RP), delegating authentication to external OIDC identity providers. Configure in the **Enterprise SSO (OIDC)** section of **Settings → Integrations** (**Add OIDC IdP**, or one of the vendor presets under **OIDC quick connect**). Register the redirect URI shown there (`/orgs/{orgId}/oidc/rp/callback`) with your identity provider. ![The Add OpenID Connect identity provider form with the provider templates](https://docs.lumoauth.dev/img/portal/authentication/oidc-idp-add.png) **Required Settings:** | Setting | Description | |---------|-------------| | **Display name** | A friendly name for this identity provider | | **Issuer URL** | The OIDC provider's issuer (e.g., `https://accounts.google.com`) | | **Client ID** | OAuth client ID from the external provider | | **Client secret** | OAuth client secret | | **Scopes** | Scopes to request (e.g., `openid profile email`) | | **Endpoints** (optional) | Leave blank to auto-discover from the issuer's `.well-known/openid-configuration` | **OIDC Discovery:** if the provider supports OIDC Discovery, you only need to enter the issuer URL; LumoAuth configures the rest automatically. --- ## LDAP / Active Directory ### Direct LDAP Authentication Connect LumoAuth directly to an LDAP or Active Directory server to authenticate users against the corporate directory. Configure in the **Directory sync (LDAP/AD)** section of **Settings → Integrations** (**Add LDAP/AD directory**): ``` /orgs/{orgId}/portal/integrations ``` ![The Add LDAP directory form with the connection fields and the Test connection button](https://docs.lumoauth.dev/img/portal/authentication/ldap-add.png) **Required Settings:** | Setting | Description | |---------|-------------| | **Display name** | How the directory is listed in the portal | | **LDAP server URL** | Hostname or IP of the LDAP server, with or without the protocol. Must resolve to a **public** address: private network address ranges, loopback, link-local and similar targets are refused when you save. | | **Port** | Connection port (389 for LDAP, 636 for LDAPS) | | **Base DN** | Base Distinguished Name for user searches | | **User search base** | Optional sub-tree under the base DN to search | | **Bind DN** | DN of the service account for LDAP queries | | **Bind password** | Password for the service account. Stored encrypted at rest and never shown again. Leave the field blank when editing to keep it. When you change the server URL, port, bind DN or either TLS switch, the password must be re-entered, so a stored secret is never sent to a new host or over a weaker transport. | | **User search filter** | LDAP filter to find users (e.g., `(sAMAccountName={username})`) | | **Use TLS (STARTTLS)** / **Use SSL (LDAPS)** | STARTTLS or LDAPS. One of them is required. A connection with both switched off is refused on save, on **Test connection** and at sign-in, unless **Allow unencrypted transport** is explicitly ticked. That switch is off by default, and its use is recorded in the audit log. The server certificate is always verified; there is no "skip verification" option. | ### Connection rules These rules are applied on every save and on every sign-in; they cannot be switched off. | Rule | Why | |------|-----| | The host must be a public, resolvable hostname or IP | Prevents LumoAuth from being pointed at internal infrastructure (SSRF). Directories behind a firewall need a public endpoint or a tunnel with a public address. | | TLS certificates are verified (`LDAP_OPT_X_TLS_DEMAND`) | A man-in-the-middle on the directory link would see every user's password. Use a certificate from a CA in the LumoAuth host's trust store. | | Plain `ldap://` without STARTTLS or LDAPS is refused unless **Allow unencrypted transport** is ticked | Without TLS the bind password and every user's password cross the network in clear text. The opt-out exists for directories reached over a private tunnel that is encrypted elsewhere. | | Empty passwords are refused before any bind | The standard for LDAP authentication defines "DN + empty password" as an *unauthenticated* bind, which Active Directory and some OpenLDAP setups answer with success. LumoAuth never performs it. | | The portal **Test connection** button reports generic errors | *Could not connect to the LDAP server.* or *Could not start TLS with the LDAP server (certificate verification is required).* The exact reason is in the application log, not in the browser. | **Info: Standard** **LDAP authentication.** The standard for LDAP authentication defines the *unauthenticated* bind: a DN with an empty password succeeds without proving anything. Any login form that forwards a user's password to LDAP must reject an empty password before calling bind. See the [standards reference](https://docs.lumoauth.dev/reference/standards). ### Attribute Mapping The form's attribute fields name the LDAP attribute to read for each profile field: | Field | Typical LDAP attribute | |---|---| | **Username attribute** | `sAMAccountName` or `uid` | | **Email attribute** | `mail` | | **Name attribute** | `displayName` | | **Given name attribute** | `givenName` | | **Surname attribute** | `sn` | Group membership and phone numbers are not read from the directory. ### JIT Provisioning from LDAP When JIT (Just-In-Time) provisioning is enabled: 1. User attempts to log in with LDAP credentials. 2. LumoAuth authenticates against the LDAP server. 3. If the user does not exist in LumoAuth, an account is automatically created. 4. The user profile is populated from LDAP attributes. ### How LDAP users are matched An LDAP account is linked to its directory entry by **(directory connection, DN)**. An email change in the directory therefore does not create a second account, and the directory's `mail` attribute (which users can often edit themselves) cannot move a user into someone else's account. When no account is linked to the DN yet, LumoAuth may adopt the account with the same email only if nothing else owns it. The sign-in is refused (it fails like a wrong password) and audited as `ldap.account_link_refused` with a `reason` when the account: | `reason` | The account… | |----------|--------------| | `bound_to_different_dn` | is already linked to another DN | | `bound_to_different_directory` | is linked to another LDAP connection | | `federated_account` | is linked to a social provider | | `different_auth_source` | comes from another sign-in source (SAML, SCIM, …) | | `local_password` | has its own local password | | `platform_admin` | is a LumoAuth operator account, which can never be linked to a directory entry | The same rule applies with JIT provisioning off: the user can only sign in to the account linked to their directory entry. To let a refused user in, link the account to the directory entry explicitly. Open the user in **Users → Federated identities → Link LDAP identity**, pick the directory and enter the DN. Leave the DN empty to look the entry up by the user's email, then username. The same is available as `POST /orgs/{orgId}/api/v1/admin/users/{userId}/identities` with `{"type": "ldap", "ldap_config_id": 1, "dn": "..."}` and `lumo users identities link --ldap-config 1 --dn "..."`. The user's local password keeps working unless you also choose **Directory sign-in only** (`ldap_only` / `--ldap-only`). ### Relinking federated identities A federated link determines which external identity signs in as an account, so an administrator changing one could hand the account to someone else. LumoAuth therefore treats link, unlink and relink like a credential change: - You need the `users.edit` permission (`admin:users:write` for API keys), and you cannot change the links of an account that holds permissions that you do not have. - Signed-in administrators must have passed MFA (multi-factor authentication) in the last 10 minutes (the same step-up as issuing a temporary access code). - The user's sessions and tokens are revoked and the user receives an email with a "This wasn't me" link. - Every change is audited: `identity.link.created`, `identity.link.removed`, `identity.link.bulk_relinked`. An account signs in through one federated source at a time. Linking SAML is refused while the account is linked to LDAP or a social provider, and linking LDAP is refused while it is linked to SAML. Unlink the other source first (**Unlink** in the portal, `DELETE /users/{userId}/identities/{type}`, or `lumo users identities unlink --type saml|ldap|social`). Unlinking LDAP also turns off **Directory sign-in only**; the user then needs a password reset to sign in with a local password. --- ## JIT User Provisioning Regardless of the SSO protocol, LumoAuth supports JIT provisioning. ### How It Works When a user authenticates via an external IdP for the first time: 1. LumoAuth receives the user's identity information (email, name, etc.). 2. If no LumoAuth account exists for that user, one is created. 3. The user profile is populated from the IdP's assertion or claims. 4. For social-login providers, the provider's default roles are assigned. 5. The external identity is linked to the LumoAuth account. ### Configuration JIT provisioning settings per IdP connection: | Setting | Description | |---------|-------------| | **Just-in-time provisioning** | Create accounts automatically on first SSO login | | **Attribute mappings** (LDAP) | Map directory attributes to the user's LumoAuth profile (the LDAP form's **Provisioning** section has only **Just-in-time provisioning**). SAML attributes are mapped automatically, see [SAML SP](https://docs.lumoauth.dev/saml/sp#attribute-mapping) | | **Update user attributes on sign-in** (SAML, OIDC) | Sync user profile on every login (not just the first) | On the SAML and OIDC forms these switches are in the **User provisioning** section: ![The Security and User provisioning sections of the Add SAML identity provider form](https://docs.lumoauth.dev/img/portal/authentication/saml-idp-add-security-provisioning.png) ### Roles for provisioned users Provisioned users start with **no roles** unless the connection defines default roles, and default roles can never make someone an administrator: - **Social login providers** have a *Default roles* field. System roles and roles that grant administrator authority are refused when you save, and you must hold every permission in the roles that you pick. See [Social Login](https://docs.lumoauth.dev/user-guide/authentication/social-login#default-roles). - **SAML IdPs** have no default-roles field in the portal today. If a stored configuration carries one, any privileged role in it is skipped at sign-in and an audit event `saml_default_role_skipped` is written. - **OIDC IdPs and LDAP** assign no roles on provisioning. Grant roles afterwards in the portal, through [groups](https://docs.lumoauth.dev/user-guide/access-control/groups), or let your IdP manage job-function roles through [SCIM groups](https://docs.lumoauth.dev/user-guide/integrations/scim). The single rule behind all of this is described in [No privilege gain](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions#no-privilege-gain). --- ## Single Logout LumoAuth supports single logout flows: - **SAML SLO** — when a user logs out of LumoAuth, the SAML logout request is sent to the IdP. Inbound logout messages from the IdP must be signed. - **OIDC Logout** — front-channel and back-channel logout supported. - **Session Termination** — all LumoAuth sessions are invalidated. --- ## Common SSO Configurations ### Okta 1. In Okta Admin → **Applications → Create App Integration → SAML 2.0**. 2. Set **SSO URL** to: `https://your-domain.com/orgs/{orgId}/saml/sp/acs`. 3. Set **Audience URI** to: `https://your-domain.com/orgs/{orgId}/saml/sp/metadata`. 4. Configure attribute statements (email, firstName, lastName). 5. Download the IdP metadata and import into LumoAuth. ### Azure AD 1. In Azure Portal → **Enterprise Applications → Create New**. 2. Set up SAML SSO configuration. 3. Set **Reply URL** to: `https://your-domain.com/orgs/{orgId}/saml/sp/acs`. 4. Set **Identifier** to: `https://your-domain.com/orgs/{orgId}/saml/sp/metadata`. 5. Download the Federation Metadata XML and import into LumoAuth. ### Google Workspace 1. In Google Admin → **Security → SSO with third-party IdP**. 2. Or use OIDC: configure LumoAuth as an OIDC RP with Google's issuer URL. --- ## Related Guides - [Authentication Overview](https://docs.lumoauth.dev/user-guide/authentication/overview) — all authentication methods - [Social Login](https://docs.lumoauth.dev/user-guide/authentication/social-login) — OAuth-based social identity providers - [SAML Applications](https://docs.lumoauth.dev/user-guide/applications/saml) — configure SAML SP applications - [SCIM Provisioning](https://docs.lumoauth.dev/user-guide/integrations/scim) — automated user provisioning from IdPs ## Standards used on this page | Plain name | Standard | |---|---| | SAML 2.0 | OASIS SAML 2.0 | | OpenID Connect (OIDC) | OpenID Connect Core 1.0 | | LDAP authentication | RFC 4513 | | Private network address ranges | RFC 1918 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Device Authorization Flow Source: https://docs.lumoauth.dev/user-guide/authentication/device-flow The Device Authorization Flow is the OAuth flow for devices that cannot run a browser or accept typed credentials — CLI tools, smart TVs, game consoles, IoT devices, and kiosks. The device shows a short user code and a… The Device Authorization Flow is the OAuth flow for devices that cannot run a browser or accept typed credentials — CLI tools, smart TVs, game consoles, IoT devices, and kiosks. The device shows a short user code and a verification URL; the user opens that URL on a second device (phone or laptop), logs in, and types the code. The device polls the token endpoint until the user finishes, then receives tokens. This is the standard OAuth 2.0 device authorization flow used by CLIs, smart TVs, and other input-constrained devices. The device shows a short `user_code`; the user types it on a second device to authorize. --- ## How It Works 1. **Device requests authorization** — calls the device authorization endpoint. 2. **User code displayed** — the device shows a code and a URL to the user. 3. **User opens URL on another device** — typically phone or laptop. 4. **User enters the code and authenticates** — standard login flow on the second device. 5. **Device polls for tokens** — periodically checks if the user has completed authentication. 6. **Tokens issued** — once the user authorizes, the device receives access and refresh tokens. ```mermaid sequenceDiagram participant D as Device (CLI/TV) participant AS as Auth Server participant U as User's Phone D->>AS: 1. POST /device_authorization AS-->>D: device_code + user_code Note over D: 2. Display: "Go to URL,
enter code: ABCD-1234" U->>AS: 3. Opens URL, enters code, logs in D->>AS: 4. Poll POST /token AS-->>D: access_token + refresh_token ``` --- ## Endpoints All endpoints of the device flow are organization-specific: | Endpoint | URL | Purpose | |----------|-----|---------| | **Device Authorization** | `/orgs/{orgId}/api/v1/oauth/device_authorization` | Request device + user codes | | **Device Verification** | `/orgs/{orgId}/api/v1/oauth/device` | User-facing page to enter the code | | **Token** | `/orgs/{orgId}/api/v1/oauth/token` | Poll for tokens (`grant_type=device_code`) | --- ## Step-by-Step Guide ### 1. Request Device Authorization The device calls the authorization endpoint: ```bash curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/oauth/device_authorization \ -d client_id=YOUR_CLIENT_ID \ -d scope="openid profile email" ``` Response: ```json { "device_code": "GmRh...device_code", "user_code": "ABCD-1234", "verification_uri": "https://your-domain.com/orgs/{orgId}/api/v1/oauth/device", "verification_uri_complete": "https://your-domain.com/orgs/{orgId}/api/v1/oauth/device?user_code=ABCD-1234", "expires_in": 600, "interval": 5 } ``` ### 2. Display the Code to the User The device displays: ``` To sign in, visit: https://your-domain.com/orgs/acme-corp/api/v1/oauth/device Enter code: ABCD-1234 ``` Or display a QR code pointing to `verification_uri_complete` for a smoother mobile experience. ### 3. User Authenticates on Their Device The user: 1. Opens the verification URL, which shows the **Link your device** page. 2. Enters the **Device code** (if not in the URL) and submits the form. 3. Logs in with their credentials (or social login). 4. Approves the device on the **Authorize Device** page. ![The Link your device page where the user enters the code shown on the device](https://docs.lumoauth.dev/img/portal/authentication/device-verification.png) The code-entry form is CSRF-protected; a code posted from another site is refused and the form is shown again. ### 4. Device Polls for Tokens The device polls the token endpoint at the specified interval: ```bash curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/oauth/token \ -d grant_type=urn:ietf:params:oauth:grant-type:device_code \ -d device_code=GmRh...device_code \ -d client_id=YOUR_CLIENT_ID ``` **While waiting:** ```json { "error": "authorization_pending", "error_description": "The user has not yet completed authorization" } ``` **After user authorizes:** ```json { "access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "def...", "id_token": "eyJ..." } ``` --- ## Use Cases | Device | Example | |--------|---------| | **CLI Tools** | `lumo login` command that displays a code | | **Smart TVs** | Streaming apps that display a code on screen | | **IoT Devices** | Connected devices without a full browser | | **Kiosks** | Public terminals with limited input capabilities | | **Game Consoles** | Authenticate with a code shown on the TV | --- ## Configuration ### Create a Device Flow Application 1. Go to **Applications** (`/orgs/{orgId}/portal/applications`) and click **New application**. 2. Keep **OpenID Connect** as the protocol and choose **Single-page or mobile app**, which creates a **public client** (no client secret for devices). 3. On the application's **Configuration** tab, configure **Allowed scopes**. 4. The client's grant types must include `urn:ietf:params:oauth:grant-type:device_code`; otherwise the device authorization endpoint answers `unauthorized_client`. The **Grant types** list on the Configuration tab does not offer the device grant, so it cannot be switched on from the portal. ![The Configuration tab of an application with its Grant types](https://docs.lumoauth.dev/img/portal/authentication/app-grant-types.png) ### Device Code Defaults These values are fixed by the server (they cannot be changed per application) and are returned in the device authorization response: | Value | Description | Default | |---------|-------------|---------| | **Code Expiration** (`expires_in`) | How long the device/user code is valid | 10 minutes (600 s) | | **Polling Interval** (`interval`) | Minimum seconds between token polls | 5 seconds | | **User Code Format** | Format of the code shown to users | for example `XXXX-XXXX` | --- ## Related Guides - [Authentication Overview](https://docs.lumoauth.dev/user-guide/authentication/overview) — all authentication methods - [Device Authorization Grant (API reference)](https://docs.lumoauth.dev/oauth/device) — endpoint details and error codes - [OAuth 2.0 & OIDC](https://docs.lumoauth.dev/user-guide/applications/oauth2-oidc) — token management and OAuth flows ## Standards used on this page | Plain name | Standard | |---|---| | Device authorization (sign in on another device) | RFC 8628 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Access Control Source: https://docs.lumoauth.dev/user-guide/access-control/overview LumoAuth supports three authorization models, each suited to a different class of access problem. This overview compares them so you can pick — or combine — the right one for your organization. If you are unsure, read… LumoAuth supports three authorization models, each suited to a different class of access problem. This overview compares them so you can pick — or combine — the right one for your organization. If you are unsure, read [Choosing a model](https://docs.lumoauth.dev/user-guide/access-control/choosing-a-model) first: start with roles. A quick recap of each model: - **RBAC (Role-Based Access Control).** Users are assigned roles; roles hold named permissions. The simplest model — use when access is primarily a function of job title. - **ABAC (Attribute-Based Access Control).** Policies evaluate attributes of the subject (user), resource, action, and environment (time, IP, device). Use when decisions depend on dynamic context. - **Zanzibar / ReBAC (Relationship-Based Access Control).** Access is stored as relation tuples like `document:readme#editor@user:alice` ("alice is an editor of document readme"). The check engine searches the relation graph. Use when access is driven by hierarchy and sharing — folders, teams, ownership. --- ## Authorization Models | Model | Best For | Complexity | |-------|----------|------------| | **[RBAC](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions)** | Simple role-based access | Low | | **[Groups](https://docs.lumoauth.dev/user-guide/access-control/groups)** | Organizing users by team/department | Low | | **[ABAC](https://docs.lumoauth.dev/user-guide/access-control/abac)** | Context-aware, attribute-based decisions (Fine-grained authorization add-on) | Medium | | **[Zanzibar](https://docs.lumoauth.dev/user-guide/access-control/zanzibar)** | Relationship-based access for hierarchical resources (Fine-grained authorization add-on) | High | | **[AI Policy Authoring](https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring)** | Generate policies from natural language | Low (input), High (output) | --- ## How They Work Together ```mermaid flowchart TD Req["User Request"] Req --> RBAC["RBAC\nRole check"] Req --> ABAC["ABAC\nAttribute evaluation"] Req --> Zanzibar["Zanzibar\nRelationship check"] RBAC --> Decision["Access decision\n(a deny rule always wins)"] ABAC --> Decision Zanzibar --> Decision ``` The portal's **How access works** page describes the order in which one decision is made. **Roles and permissions** answer first: does the subject hold the permission directly, through a role, or through a group's role? **Relationships** can also grant it: is the subject connected to this specific object? **Attribute rules** are evaluated last: an *allow* rule can grant what the others did not, and a *deny* rule overrides everything above. You can run one model or layer them: - **RBAC only** — assign users to roles like `admin`, `editor`, `viewer`. - **RBAC + Groups** — assign roles to groups; users inherit permissions via group membership. - **RBAC + ABAC** — the role controls the capability, ABAC adds conditions (for example, "editors can only edit during business hours"). - **Zanzibar** — model relationships directly (for example, "a user can edit a document if they own it or if it is shared with their team"). --- ## Quick Comparison | Feature | RBAC | Groups | ABAC | Zanzibar | |---------|------|--------|------|----------| | Role-based decisions | ✅ | ✅ (via groups) | ❌ | ❌ | | Attribute conditions | ❌ | ❌ | ✅ | ❌ | | Relationship-based | ❌ | ❌ | ❌ | ✅ | | Hierarchical | ❌ | ✅ | ❌ | ✅ | | Context-aware | ❌ | ❌ | ✅ | ❌ | | Scales to many objects | ❌ | ❌ | ❌ | ✅ | --- ## Permission Testing **Test access** evaluates access decisions without affecting production data. **URL:** `/orgs/{orgId}/portal/permissions/tester` The tester lets you: - Select a user (defaults to you) and a permission, or pick one of the **Quick presets**. - Optionally add context as JSON. - See whether the request would be allowed or denied, and through which role, group or direct grant. - Compare the outcome with what you expected before you rely on a role or policy. ![Test access with its Roles & permissions, Attribute rules and Relationships modes](https://docs.lumoauth.dev/img/portal/access-control/permission-tester.png) **Note** There is one tester per model, linked by the tabs at the top of each. **Roles & permissions** (`/orgs/{orgId}/portal/permissions/tester`) covers permission checks for RBAC. **Attribute rules** (`/orgs/{orgId}/portal/abac/tester`) evaluates ABAC policies against a resource type, an action, and user and resource attributes. **Relationships** (`/orgs/{orgId}/portal/zanzibar/tester`) runs a Zanzibar check. --- ## Portal Locations The access control features are all located directly under `/orgs/{orgId}/portal/`. In the sidebar, the **Access control** group holds **How access works**, **Roles & permissions**, **Relationships**, **Attribute rules** and **Test access**. **Users** is under **Identity**, and **Groups** (like **Invitations**) is a tab on the Users page. The **How access works** page (`/orgs/{orgId}/portal/access`) is the starting point. It shows one card per model with live counts (roles and custom permissions, active attribute rules, object types and relationships). It also has a **Test an access decision** button and a **Help me choose** link to [Choosing a model](https://docs.lumoauth.dev/user-guide/access-control/choosing-a-model). Models that need the Fine-grained authorization add-on carry an **Add-on** tag when the organization does not have it. ![The How access works page with a card for roles, attribute rules and relationships](https://docs.lumoauth.dev/img/portal/access-control/access-overview.png) ![The Roles tab of Roles & permissions, with the Access control group open in the sidebar](https://docs.lumoauth.dev/img/portal/access-control/roles.png) ```mermaid graph TD Root["/orgs/{orgId}/portal/"] Root --> O["access — How access works"] Root --> U["users — User management with role assignments"] Root --> R["roles — RBAC role definitions"] Root --> G["groups — Group management (a tab of Users)"] Root --> P["permissions — Permission definitions (a tab of Roles & permissions)"] Root --> Z["zanzibar/ — Zanzibar namespaces and relation tuples"] Root --> AB["abac/ — ABAC policies and attributes"] Root --> PA["policies/author — AI-assisted policy creation"] Root --> PT["permissions/tester — Test RBAC permission checks"] ``` | Feature | URL | |---------|-----| | How access works | `/orgs/{orgId}/portal/access` | | Users | `/orgs/{orgId}/portal/users` | | Roles & permissions (Roles tab) | `/orgs/{orgId}/portal/roles` | | Users → Groups tab | `/orgs/{orgId}/portal/groups` | | Roles & permissions (Permissions tab) | `/orgs/{orgId}/portal/permissions` | | Relationships (ReBAC) | `/orgs/{orgId}/portal/zanzibar/` | | Attribute rules (ABAC) | `/orgs/{orgId}/portal/abac/` | | AI Policy Author | `/orgs/{orgId}/portal/policies/author` | | Test access (permissions) | `/orgs/{orgId}/portal/permissions/tester` | --- ## In This Section | Guide | Description | |-------|-------------| | [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) | Define roles, assign permissions, manage RBAC | | [Groups](https://docs.lumoauth.dev/user-guide/access-control/groups) | Organize users and assign roles to groups | | [ABAC](https://docs.lumoauth.dev/user-guide/access-control/abac) | Policies for attribute-based access control | | [Zanzibar](https://docs.lumoauth.dev/user-guide/access-control/zanzibar) | Relationship-based access control | | [AI Policy Authoring](https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring) | Generate policies from natural language | --- # Choosing an Authorization Model Source: https://docs.lumoauth.dev/user-guide/access-control/choosing-a-model A short decision guide for RBAC, ABAC and Zanzibar/ReBAC. Start with roles and add the others only when roles stop being enough. **Start with roles (RBAC, role-based access control).** Most organizations never need anything else. Add attribute policies or relationships only when you can name a rule that roles cannot express. ## Decision guide Ask these questions in order and stop at the first "yes". | Question | Use | |---|---| | Is access mostly a function of someone's job or team ("admins can manage billing")? | [**Roles and permissions (RBAC)**](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions), with [groups](https://docs.lumoauth.dev/user-guide/access-control/groups) if you manage many users | | Does the decision depend on context, like time of day, IP range, device, or a field on the user or resource ("editors can publish only during business hours")? | [**Attribute rules**](https://docs.lumoauth.dev/user-guide/access-control/abac) on top of roles | | Is access driven by who owns or shares a specific object ("Alice can edit this document because she is in the team that it was shared with")? | [**Relationships (ReBAC, relationship-based access control / Zanzibar)**](https://docs.lumoauth.dev/user-guide/access-control/zanzibar) | ## Signs you have outgrown roles - You are creating one role per customer, project or document. - Role names have started to encode conditions, like `editor-eu-weekdays`. - You need "can this user open this one object" answers for millions of objects. ## How they combine All three models sit behind the same `POST /api/v1/authz/check` call, so you can adopt them one at a time without changing your application code. ABAC (attribute-based access control) and ReBAC are part of the Fine-grained authorization add-on; RBAC is included on every plan. ## In the portal Under **Access control**: - **Roles & permissions** is RBAC; begin on its **Roles** tab. - **Attribute rules** (ABAC) and **Relationships** (ReBAC) are advanced and restricted by plan. Use **Test access** (**Access control → Test access**) to check a decision before you rely on it. ![The permission tester showing which role grants a permission](https://docs.lumoauth.dev/img/portal/access-control/permission-tester.png) See the [overview](https://docs.lumoauth.dev/user-guide/access-control/overview#permission-testing) for where to find each tester. --- # Roles & Permissions (RBAC) Source: https://docs.lumoauth.dev/user-guide/access-control/roles-permissions Role-based access control (RBAC) is the classical authorization model: what you may do follows from the roles that you hold. A permission names a single action on a resource (for example reports.view). A role is a named… Role-based access control (RBAC) is the classical authorization model: what you may do follows from the roles that you hold. A **permission** names a single action on a resource (for example `reports.view`). A **role** is a named set of permissions. Users are assigned roles, and a user's effective permissions are the union of permissions from every role that they hold, directly or through a group. Use RBAC when access is mostly a function of job title and the set of permissions is small enough to list — "admins can manage users, editors can edit content". When decisions depend on per-resource ownership, context, or relationships, combine RBAC with [ABAC (attribute-based access control)](https://docs.lumoauth.dev/user-guide/access-control/abac) or [Zanzibar](https://docs.lumoauth.dev/user-guide/access-control/zanzibar). --- ## Concepts ### Permissions A permission represents a specific action on a resource. | Example Permission | Description | |-------------------|-------------| | `reports.view` | View reports | | `reports.edit` | Create and edit reports | | `reports.delete` | Delete reports | | `invoices.manage` | Full control over invoices | | `tickets.view` | View support tickets | | `tickets.assign` | Assign tickets to people | Custom permission slugs use lowercase letters, digits, dots, hyphens and underscores (`reports.view`); a colon is rejected. The built-in catalogue that guards the portal and Admin API uses the same `resource.action` shape (`users.create`, `roles.edit`, `settings.manage`, `jit.approve`, and so on). Its namespaces are reserved (see [Reserved permission slugs](#reserved-permission-slugs)), so pick resource names from your own application. Both kinds are stored in the same table and can be attached to roles. ### Roles A role is a named collection of permissions. | Role | Permissions | |------|------------| | `viewer` | `reports.view`, `tickets.view` | | `editor` | `reports.view`, `reports.edit`, `invoices.view` | | `admin` | All permissions | | `auditor` | `tickets.view`, `reports.view` | ### Role Assignment Users can hold one or more roles. Effective permissions are the union across all assigned roles. ```mermaid graph TD U["User: alice@acme.com"] U --> E["Role: editor"] U --> A["Role: auditor"] E --> E1["reports.view"] E --> E2["reports.edit"] E --> E3["invoices.view"] A --> A1["tickets.view"] A --> A2["reports.view (duplicate — counted once)"] E1 & E2 & E3 & A1 --> EFF["Effective: reports.view, reports.edit,
invoices.view, tickets.view"] style EFF fill:#e8f5e9,stroke:#4caf50 ``` --- ## Managing Permissions ### Create a Permission 1. In the sidebar, under **Access control**, open **Roles & permissions** and switch to the **Permissions** tab (`/orgs/{orgId}/portal/permissions`). It has two sub-tabs, **Custom permissions** (the ones that you define) and **System permissions** (the built-in catalogue). Each has a count and a search box that matches slug, name or description. Permissions are grouped by resource (for example **Tickets** or **Invoices**); click a group to list its permissions, each with its slug, display name and a row menu. ![The Permissions tab with custom permissions grouped by resource, the first group expanded](https://docs.lumoauth.dev/img/portal/access-control/permissions.png) 2. Click **Create custom permission**. 3. Fill in: | Field | Description | Example | |-------|-------------|---------| | **Resource** | The kind of thing being protected | `report` | | **Action** | What may be done to it | `view` | | **Slug** | Derived from the resource and action as `resource.action`; this is the string that your code checks, and it cannot be changed later | `report.view` | | **Display name** | Required. Human-readable name shown in the role editor and the permission matrix | `View reports` | | **Description** | Optional. What this permission grants | `Open and read reports` | 4. Click **Create Permission**. A new permission is held by nobody until you add it to a role. ![The Create custom permission form with resource, action and the derived slug](https://docs.lumoauth.dev/img/portal/access-control/permission-create.png) ### Via API ```bash curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/admin/permissions \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{ "name": "View reports", "slug": "reports.view", "description": "View reports and their details" }' ``` --- ## Managing Roles ### Create a Role 1. In the sidebar, under **Access control**, open **Roles & permissions** (`/orgs/{orgId}/portal/roles`). The **Roles** tab lists every role with its description, number of permissions and type (**Custom** or **System**), and has a search box for the name or slug. The row menu (**...**) edits or deletes a custom role; system roles only have **View**. ![The Roles tab listing custom roles and the system Tenant Admin role, with the Create role button](https://docs.lumoauth.dev/img/portal/access-control/roles.png) 2. Click **Create role**. When an AI provider is configured, **Draft with AI** next to it opens [AI policy authoring](https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring). 3. Fill in: | Field | Description | Example | |-------|-------------|---------| | **Role name** | Display name. The slug is derived from it and shown under the field | `Editor` (slug `editor`) | | **Description** | Role purpose | `Can view and edit users and applications` | 4. Under **Permissions**, set an access level (**None**, **View**, **Edit** or **Full**) per area, or open an area to tick individual permissions. The **Start from** presets (**Read-only**, **Editor**, **Administrator**) fill in a starting set. 5. Click **Create role**. The **Role summary** beside the form lists the selected areas and their levels as you go, and a role name that is already taken is rejected. ![The Create role form with the role name, description, role summary and permission areas](https://docs.lumoauth.dev/img/portal/access-control/role-create.png) To change a role later, open it from the Roles list, adjust its permissions and click **Save changes**. System roles can only be viewed. ![A custom role opened for editing, with its details, role summary and permission picker](https://docs.lumoauth.dev/img/portal/access-control/role-edit.png) ### Via API ```bash # Create a role curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/admin/roles \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{ "name": "Editor", "slug": "editor", "description": "Can view and edit reports and invoices" }' # Set a role's permissions (PUT replaces the full set; values are permission slugs) curl -X PUT https://your-domain.com/orgs/{orgId}/api/v1/admin/roles/{roleId}/permissions \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{ "permissions": ["reports.view", "reports.edit", "invoices.view"] }' ``` **Tip** Use `PUT .../permissions` to replace the role's full permission set with slug values. To add one or more permissions without removing existing ones, `POST .../permissions` instead with permission **IDs**: `{"permissionId": 12}` or `{"permissionIds": [12, 13]}`. --- ## Assigning Roles to Users ### Via Portal 1. Go to **Users** (`/orgs/{orgId}/portal/users`). 2. Click the user's name to open the user. 3. Open the **Roles** tab. 4. Tick or untick roles (the search box filters them), then click **Save changes**. The **Effective access** panel beside the list shows how many permissions the user ends up with, split into direct grants and permissions from roles. ![The Roles tab of a user, with a checkbox per role and the Effective access panel](https://docs.lumoauth.dev/img/portal/access-control/user-roles.png) ### Via API ```bash # Set a user's roles (PUT replaces the full set; values are role slugs) curl -X PUT https://your-domain.com/orgs/{orgId}/api/v1/admin/users/{userId}/roles \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{ "roles": ["editor", "auditor"] }' # Add a single role without removing existing ones curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/admin/users/{userId}/roles \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{"roleSlug": "editor"}' # List a user's roles curl https://your-domain.com/orgs/{orgId}/api/v1/admin/users/{userId}/roles \ -H "Authorization: Bearer {admin_token}" ``` --- ## Roles in Tokens When a user authenticates, their roles can be included as claims in the access token. The access token is a JWT (JSON Web Token: a signed JSON payload that the client presents on each request). Roles are **opt-in per application**. The `roles` claim appears in the access token and the UserInfo response. It is sent only when **User roles** is ticked and saved under the application's **Security → JWT claims** (JWT access tokens). A client that never enabled it gets no role names. That includes dynamically registered clients and clients identified by a metadata document (CIMD). See [Claims and roles](https://docs.lumoauth.dev/oauth/clients#claims-and-roles). ```json { "sub": "user-uuid", "organization": "acme-corp", "roles": ["editor", "auditor"], "iat": 1706400000, "exp": 1706403600 } ``` The token carries role names only, never the expanded permission list. Your application can verify the token signature and map role names to its own checks, or ask LumoAuth for a permission decision at request time (below). --- ## Checking Permissions ### In Your Application Verify the access token and read its `roles` claim (when the application has **User roles** enabled), then decide locally which roles may do what. ```javascript // Node.js example const token = jwt.verify(accessToken, publicKey); if ((token.roles || []).includes('editor')) { // Allow report edit } else { // Deny access } ``` ### Via the Permission Check API To decide on individual permissions rather than role names, ask LumoAuth at request time. These endpoints evaluate against the **caller's own** access token (send it as the Bearer token), so they need no admin privileges. ```bash # Check a single permission for the current user curl -X POST https://your-domain.com/api/v1/permissions/check \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{"permission": "reports.edit"}' # -> {"allowed": true, "permission": "reports.edit", "context": {}} # Check several at once curl -X POST https://your-domain.com/api/v1/permissions/check-bulk \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{"permissions": ["reports.view", "reports.edit", "reports.delete"]}' # List the current user's effective permissions and roles curl https://your-domain.com/api/v1/permissions/user \ -H "Authorization: Bearer {access_token}" ``` ### Via Permission Tester Use **Test access** at `/orgs/{orgId}/portal/permissions/tester` (**Access control → Test access**, *Roles & permissions* tab) to check whether a user holds a permission. Pick a **User** (it starts on you) and a **Permission**, optionally add context as JSON, and click **Run test**. The **Quick presets** run common checks in one click. The result shows whether access is granted and, under **Where it comes from**, which role, group or direct grant provides it. ![Test access showing an Allowed result and the role that granted it](https://docs.lumoauth.dev/img/portal/access-control/permission-tester.png) --- ## How the admin permissions work The organization portal and the Admin API are governed by the built-in `resource.action` catalogue. There is no single "admin" switch: - **Getting into the portal.** Anyone who holds at least one admin-plane system permission can open the portal; that is any catalogue permission except the platform-only `tenants.*`. Holding `jit.approve` alone, for example, is enough to reach the approval inbox. - **Every page checks its own permission.** Opening **Users** needs `users.view`, saving a user needs `users.edit`, and so on. A page that you cannot use answers `403`, and the sidebar hides the entries that you hold no permission for. - **Every Admin API endpoint declares its permission.** The per-endpoint list is under [Required permissions](https://docs.lumoauth.dev/api-reference/authentication#permission-per-endpoint). API keys are additionally limited to what their **creator** holds. - **`settings.manage` is not "full admin".** It covers authentication and security policy (sign-in, MFA (multi-factor authentication) and session settings, the organization profile over the API, audit-log retention). It also covers API keys, custom domains, directory (LDAP) and HR connections, log shippers, alerting and the other integrations. It does not grant users, roles, clients, webhooks, agents or anything else; give those permissions explicitly. Permissions added in the 2026-09-25 release, seeded for every organization and granted to the built-in Tenant Admin role: | Slug | Grants | |------|--------| | `abac.view` | View attribute definitions and policies for ABAC | | `abac.manage` | Create, edit and delete ABAC attributes and policies, and set the values of user and resource attributes | | `gdpr.manage` | Data subject requests, consent, retention, breaches, and exports of other users' personal data | | `gdpr.dpo` | Duties of the Data Protection Officer: DPIAs, breach sign-off, supervisory-authority reporting | | `billing.manage` | View and change the subscription plan, add-ons and payment details | | `jit.approve` | Approve or deny AI-agent just-in-time requests (now seeded for every organization) | | `authz.check` | Ask authorization questions about other users and agents (now seeded for every organization) | ### Protecting stronger accounts An administrator can only take account-changing actions on users who hold no more than they do. The following actions all require that you hold **every** permission that the target user holds: setting a password, changing the e-mail, activating or deactivating, blocking, deleting, removing an authenticator and issuing a temporary access code. The same rule applies in the portal, the Admin API and to API keys (checked against the key's creator). ### Reserved permission slugs Custom permissions cannot take a system slug (`users.view`, `settings.manage`, …), any slug inside a system namespace (for example `users.impersonate` or `billing.refund`), or a `tenants.` slug. They also cannot take one of the retired slugs that the platform once checked (`agents.manage`, `users.manage`, `zanzibar.manage`, `webhooks.test`, `gdpr.export_other`, `abac.policies.*`, `abac.attributes.*`). This applies to the portal, the Admin API and [AI policy authoring](https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring), which also only attaches permissions that the author already holds. --- ## No privilege gain One rule applies everywhere that a role can be assigned: **nobody can grant more than they hold, and no automated path can create an administrator.** The rule is enforced in the same way whether the assignment is made by a person or by an integration, so there is no hidden bypass to check for. | Where roles are assigned | What is enforced | |--------------------------|------------------| | User editor and `PUT/POST .../users/{userId}/roles` | The acting administrator must hold every permission in the role. Only administrators who already hold a system role can assign it. | | Role editor and `.../roles/{roleId}/permissions` | An administrator cannot attach a permission that they do not hold themselves. | | Groups | Adding a role to a group, or a user to a group, is checked against the acting administrator in the same way. | | SCIM (automatic user provisioning) `/Groups` membership | An IdP can add users to job-function roles, never to a system role or a role that confers administrator authority. Rejected with `403 mutability`. See [SCIM Groups](https://docs.lumoauth.dev/scim/groups#protected-roles). | | Social-login default roles | A provider's default roles cannot be system roles or roles that confer administrator authority, and the administrator who saves them must hold every permission in them. See [Social Login](https://docs.lumoauth.dev/user-guide/authentication/social-login#default-roles). | | SAML JIT provisioning | Any privileged role found in an IdP's stored default-role list is skipped at sign-in and an audit event is written. | A role **confers administrator authority** when it holds any permission that changes who can do what in the organization: | Family | Permissions | |--------|-------------| | Organization settings | `settings.create`, `settings.edit`, `settings.delete`, `settings.manage` | | Users, roles, groups | `users.create`, `users.edit`, `users.delete`, `roles.create`, `roles.edit`, `roles.delete`, `roles.assign-permissions`, `groups.create`, `groups.edit`, `groups.delete` | | Customer organizations | `organizations.members.manage`, `organizations.invitations.manage` | | Identity providers and provisioning | `idps.create`, `idps.edit`, `idps.delete`, `scim.create`, `scim.edit`, `scim.delete`, `scim.manage` | | Signing keys | `signing_keys.create`, `signing_keys.edit`, `signing_keys.rotate`, `signing_keys.revoke`, `signing_keys.delete` | | OAuth clients | `clients.create`, `clients.edit`, `clients.delete`, `clients.rotate-secret` | | AI agents, policies, approvals | `agents.create`, `agents.edit`, `agents.delete`, `policies.create`, `policies.edit`, `policies.delete`, `jit.approve` | | Fine-grained authorization | `zanzibar.create`, `zanzibar.edit`, `zanzibar.delete`, `abac.manage` | | Privacy and billing | `gdpr.manage`, `gdpr.dpo`, `billing.manage` | Read-only permissions (`*.view`, `audit.*`, `analytics.*`, `webhooks.*`, `branding.*`, `abac.view`) and the custom permissions of your own application never make a role administrative, so automated paths can assign them freely. Why this matters: without the rule, an administrator who can only edit identity providers (`idps.edit`) could register a social provider whose default role is *Tenant Admin*. They could then sign in through it with a fresh account and become an administrator. With the rule applied everywhere, the only way to become an administrator is to be made one by someone who already is. --- ## Best Practices | Practice | Description | |----------|-------------| | Principle of least privilege | Assign the minimum permissions needed | | Assign permissions to roles, not users | Keeps user management simple | | Consistent naming | Follow the `resource.action` convention | | Avoid catch-all roles | Prefer focused roles over a single catch-all admin role | | Audit role assignments | Review periodically who holds what | | Keep administrators out of automated paths | Never put a role that confers administrator authority in an IdP-managed group or a social-login default; the platform rejects it anyway | --- ## Related - [Groups](https://docs.lumoauth.dev/user-guide/access-control/groups) — assign roles to groups for bulk management. - [Attribute rules (ABAC)](https://docs.lumoauth.dev/user-guide/access-control/abac) — add attribute and context conditions to access decisions. - [Relationships (ReBAC)](https://docs.lumoauth.dev/user-guide/access-control/zanzibar) — relationship-based access for hierarchical resources. - [Access Control Overview](https://docs.lumoauth.dev/user-guide/access-control/overview) — compare all authorization models. ## Standards used on this page | Plain name | Standard | |---|---| | JSON Web Token (JWT) | RFC 7519 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Groups Source: https://docs.lumoauth.dev/user-guide/access-control/groups Groups let you collect users — by team, department, project, or any criterion — and assign roles to the group rather than to each member. Every member inherits the roles that the group holds, so adding or removing a… Groups let you collect users — by team, department, project, or any criterion — and assign roles to the group rather than to each member. Every member inherits the roles that the group holds, so adding or removing a user from the group changes their effective permissions without touching individual role assignments. Use groups when many users share the same access needs. Groups are a layer on top of [RBAC (role-based access control)](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) — they do not replace roles. --- ## How Groups Work ```mermaid graph TD G["Group: Engineering"] G --> R["Role: editor"] G --> M["Members"] M --> A["alice@acme.com → inherits editor role"] M --> B["bob@acme.com → inherits editor role"] M --> C["carol@acme.com → inherits editor role"] ``` Assign a role to a group and every member receives the role's permissions. Remove a user from the group and they lose those permissions (unless the same role is also assigned directly to them). --- ## Managing Groups ### Create a Group 1. In the sidebar, under **Identity**, open **Users**, then the **Groups** tab (`/orgs/{orgId}/portal/groups`; the other tabs are **Users** and **Invitations**). The list shows each group's name and slug, description, number of users and roles, and type, with a search box for the name or slug. The row menu (**...**) edits or deletes a group; system groups have no row menu and cannot be deleted. ![The Groups tab listing groups with their user and role counts](https://docs.lumoauth.dev/img/portal/access-control/groups.png) 2. Click **Create group**. 3. Fill in: | Field | Description | Example | |-------|-------------|---------| | **Group name** | Display name. The slug is derived from it | `Data Platform` (slug `data_platform`) | | **Description** | Optional. Group purpose | `Data engineers who run the analytics pipelines` | | **Roles** | Optional. Roles that every member inherits; a filter box narrows the list when there are more than six roles | `Developer` | | **Members** | Optional. Users to add now; you can add more later | | 4. Click **Create group**. The checklist beside the form shows which parts are filled in; only the group name is required. ![The Create group form with name, description and the Roles section](https://docs.lumoauth.dev/img/portal/access-control/group-create.png) ### Add Members 1. On the **Groups** page, click the group's name to open it. 2. Scroll to the **Members** section. 3. Tick the users to add, or untick them to remove them. With more than six users, a filter box searches name and email. Then click **Save changes**. The **Members** count shows how many users the group has. ![A group's Roles and Members sections, each a list of checkboxes](https://docs.lumoauth.dev/img/portal/access-control/group-edit-roles-members.png) ### Assign Roles to a Group 1. On the **Groups** page, click the group's name to open it. 2. Scroll to the **Roles** section. 3. Tick the roles that every group member should inherit, then click **Save changes**. In the screenshot, for example, **Engineering** holds **Developer** and **Security** holds **Security Analyst** and **Read-only Auditor**. --- ## API Examples ```bash # Create a group curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/admin/groups \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{ "name": "Engineering", "slug": "engineering", "description": "Engineering team" }' # Add a user to a group (one user, or several via "userIds": [...]) curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/admin/groups/{groupId}/members \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{"userId": "user-uuid"}' # Assign a role to a group curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/admin/groups/{groupId}/roles \ -H "Authorization: Bearer {admin_token}" \ -H "Content-Type: application/json" \ -d '{"roleId": "role-uuid"}' ``` --- ## Permission Resolution A user's effective permissions come from two sources: 1. **Directly assigned roles** — roles attached to the user. 2. **Group-inherited roles** — roles attached to the groups that the user belongs to. ```mermaid graph TD U["User: alice@acme.com"] U --> DR["Direct Role: auditor"] U --> GE["Group: Engineering"] DR --> P1["audit-logs:read, users:read"] GE --> RE["Role: editor"] RE --> P2["users:read, users:write, applications:read"] P1 & P2 --> EFF["Effective: audit-logs:read, users:read,
users:write, applications:read"] style EFF fill:#e8f5e9,stroke:#4caf50 ``` --- ## Use Cases | Scenario | Groups Setup | |----------|-------------| | Team-based access | One group per team (Engineering, Marketing, Sales) | | Project-based access | One group per project with project-specific roles | | Department hierarchy | Groups for departments, sub-groups for teams | | Temporary elevated access | Add user to the group, remove when no longer needed | | Onboarding | Add a new hire to their team's group — they inherit all required roles | --- ## Related - [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) — define roles and permissions. - [ABAC (attribute-based access control)](https://docs.lumoauth.dev/user-guide/access-control/abac) — reference group membership from policy conditions. - [Access Control Overview](https://docs.lumoauth.dev/user-guide/access-control/overview) — compare all authorization models. --- # Attribute-Based Access Control (ABAC) Source: https://docs.lumoauth.dev/user-guide/access-control/abac ABAC evaluates attributes against declarative policies. The attributes belong to the subject (the user), the resource, the action, and the environment (time, IP, device). Unlike RBAC — which answers "does the user have… ABAC evaluates **attributes** against declarative policies. The attributes belong to the subject (the user), the resource, the action, and the environment (time, IP, device). Unlike RBAC — which answers "does the user have role X?" — ABAC answers "do the facts about this request satisfy policy Y?". That lets you express rules like "engineering users can write to internal documents during business hours from trusted IPs" without hard-coding role combinations. Use ABAC when decisions depend on dynamic context. It pairs well with RBAC: role membership controls the capability, and ABAC narrows it down with conditions. **Note: Fine-grained authorization add-on** ABAC is part of the **Fine-grained authorization** add-on ($99/month, available on Pro and Business, together with Zanzibar ReBAC, relationship-based access control). Buy it under **Billing → Add-ons**; on Developer, upgrade to Pro first. Without it (and with billing enforced) the ABAC pages show an upgrade notice, allow policies grant nothing, and new attributes and policies can't be created or edited. Deny policies that you already have **stay enforced** until you disable or delete them. See [ABAC plan behaviour](https://docs.lumoauth.dev/authorization/abac). --- ## How ABAC Works An ABAC policy evaluates four attribute categories: | Category | Examples | |----------|---------| | **Subject** (who) | User role, department, clearance level, group membership | | **Resource** (what) | Document classification, owner, creation date | | **Action** (how) | Read, write, delete, approve | | **Environment** (context) | Time of day, IP address, device type, location | ### Example Policy > "Allow users in the `engineering` group to `write` to `internal` documents during business hours (9 AM – 6 PM) from trusted IP ranges." ``` Subject: user.groups contains "engineering" Resource: resource.classification equals "internal" Action: write Environment: environment.hour gte 9 AND environment.hour lt 18 AND environment.ip matches "^203\.0\.113\." Decision: ALLOW ``` --- ## Managing ABAC Policies ### Create a Policy 1. In the sidebar, under **Access control**, open **Attribute rules** (`/orgs/{orgId}/portal/abac/`). The page shows four counters (**Attribute definitions**, **Policies**, **Active policies** and **Resource attributes**), the five most recently created policies, and the **New attribute**, **Policy tester** and **Create policy** buttons. **Manage policies** opens the full policy list. ![The Attribute rules page with counters for attributes and policies, and the New attribute, Policy tester and Create policy buttons](https://docs.lumoauth.dev/img/portal/access-control/abac-overview.png) The policy list (`/orgs/{orgId}/portal/abac/policies`) shows each policy's name and slug, its resource type and action, its effect (**Allow** or **Deny**), priority and status. The row menu (**...**) edits, deactivates or deletes a policy. Deactivating keeps the policy but stops evaluating it. ![The Access policies list with four policies, their effect, priority and status](https://docs.lumoauth.dev/img/portal/access-control/abac-policies.png) 2. Click **Create policy**. 3. Define the policy: | Field | Description | |-------|-------------| | **Policy name** | Human-readable policy name | | **Description** | Optional. What this policy controls | | **Resource type** | The kind of resource that the policy targets, exactly as your application sends it (`*` for every type) | | **Action** | The action being controlled (`*` for every action) | | **Effect** | **Allow** or **Deny** | | **Priority** | Evaluation order, 0-1000 (higher priority evaluated first) | | **Active** | Only active policies are evaluated | | **When it applies** | The conditions. Each row compares an attribute of the user, the resource or the environment with a value; rows are combined with **AND** or **OR**. With no conditions the policy matches every request for that resource type and action | 4. Click **Create policy**, or **Save & test** to save it and open it in the policy tester. ![The Create access policy form with policy name, description and resource type](https://docs.lumoauth.dev/img/portal/access-control/abac-policy-create.png) Add conditions with **Add condition**, or load a worked example under **Start from an example**. If a condition needs an attribute that is not defined yet, **New attribute** adds one without leaving the form. ![The When it applies section with two conditions on the current hour](https://docs.lumoauth.dev/img/portal/access-control/abac-policy-conditions.png) ### Define Attributes Conditions read **attributes**. Under **Attribute rules**, **New attribute** (`/orgs/{orgId}/portal/abac/attributes/create`) defines one; finish with **Create attribute**. The **Attribute definitions** page (`/orgs/{orgId}/portal/abac/attributes`) lists them, with **All**, **User**, **Resource** and **Environment** tabs, and the row menu edits or deletes one. | Field | Description | |-------|-------------| | **Name** | Display name; the attribute's slug (for example `department`) is derived from it and is what conditions refer to | | **Description** | Optional. What the attribute represents | | **Applies to** | **User**, **Resource** or **Environment** | | **Data type** | `string`, `number`, `boolean`, `array`, `date` or `datetime` | | **Required** | Whether a value must always be present | | **Default value** and **Validation rules** | Optional. Fallback value and validation rules in JSON | ![The Attribute definitions page listing user and resource attributes with their data types](https://docs.lumoauth.dev/img/portal/access-control/abac-attributes.png) Values of user attributes are stored as user traits; values of resource attributes are set through the [ABAC API](https://docs.lumoauth.dev/authorization/abac). ### Policy Evaluation LumoAuth combines policies using the **deny-overrides** algorithm: 1. Collect every policy whose `action` and `resourceType` match the request. 2. Evaluate each matching policy's conditions against the request attributes. 3. If any policy with `decision: DENY` matches, the final decision is **Deny**. 4. Otherwise, if at least one policy with `decision: ALLOW` matches, the final decision is **Allow**. 5. If no policy matches, the default is **Deny** (closed-world assumption). Priority controls the order in which policies are considered, which is especially useful for ensuring that restrictive deny policies are evaluated before broader allows. --- ## Subject Attributes Built-in user attributes available to conditions (the policy form lists them next to the condition builder): | Attribute | Description | Example | |-----------|-------------|---------| | `user.id`, `user.email`, `user.username`, `user.name` | Identity fields | `alice@acme.com` | | `user.givenName`, `user.familyName`, `user.locale`, `user.zoneinfo` | Profile fields | `"Alice"` | | `user.roles` | Assigned role slugs | `["editor", "auditor"]` | | `user.groups` | Group slugs | `["engineering"]` | | `user.permissions` | Effective permission slugs | `["reports.view"]` | | `user.emailVerified` | Email verification status | `true` | | `user.mfaEnabled` | Whether MFA (multi-factor authentication) is active | `true` | | `user.isActive`, `user.blocked` | Account status | `true` | | `user.tenantId`, `user.orgId` | Organization identifiers | | | `user.trait_` | A user trait set by an administrator, an import or a policy (for example `user.trait_plan`) | `"enterprise"` | | `user.self_` | A trait that the user typed in themselves on a progressive-profiling form. Self-asserted values never appear as `user.trait_` or as a custom attribute, so a policy only relies on them when it names `self_` explicitly | `"finance"` | | `user.` | Any attribute that you define, such as `user.department` | `"engineering"` | --- ## Environment Attributes Contextual attributes evaluated at request time, under the `environment` prefix: | Attribute | Description | Example | |-----------|-------------|---------| | `environment.currentTime` | Current time (`H:i:s`) | `14:30:00` | | `environment.currentDate`, `environment.currentDateTime` | Current date / date-time | `2026-01-15` | | `environment.hour` | Current hour (0–23) | `14` | | `environment.dayOfWeek` | Day of the week (1 = Monday … 7 = Sunday) | `1` | | `environment.isWeekend` | Saturday or Sunday | `false` | | `environment.ip`, `environment.userAgent`, `environment.requestMethod`, `environment.requestPath` | Request details, when the check supplies them | `203.0.113.7` | The caller passes anything else that you need (a country, a risk score, a device flag) in the `environment` object of the check request. It is matched against an environment attribute that you define. An integration with [device posture](https://docs.lumoauth.dev/integrations/device-posture) adds a `device` namespace automatically. --- ## Example Policies Each condition is one row in **When it applies**: an attribute, an operator and a value, joined by AND or OR. The operators are **equals**, **not_equals**, **in**, **not_in**, **contains**, **not_contains**, **gt**, **lt**, **gte**, **lte**, **matches** (regular expression), **exists** and **not_exists**. A policy has one **Action**; use `*` for every action, or create one policy per action. ### Require MFA for Sensitive Actions ``` Name: Require MFA for deletions Action: delete Condition: user.mfaEnabled equals false Effect: Deny ``` ### Geographic Restriction Define an environment attribute `country` and send it in the `environment` object of each check. ``` Name: Allow access only from US Action: * Condition: environment.country not_equals "US" Effect: Deny ``` ### Business Hours Only This is the form's own **Business hours** example under **Start from an example**. ``` Name: Allow write access during business hours Action: write Condition: environment.hour gte 9 AND environment.hour lte 17 AND environment.isWeekend equals false Effect: Allow ``` ### Block High-Risk Sessions Define an environment attribute `riskScore` and pass the score from your application. ``` Name: Block high-risk sessions Action: * Condition: environment.riskScore gte 80 Effect: Deny ``` --- ## Combining ABAC with RBAC ABAC complements RBAC. A common pattern: 1. **RBAC** determines *what* a user can do based on their roles. 2. **ABAC** determines *when* and *where* they can do it based on context. Example: the `editor` role grants `documents.edit`, and an attribute rule limits the `edit` action on `document` resources to business hours. --- ## Testing Policies The *Attribute rules* mode of **Test access** at `/orgs/{orgId}/portal/abac/tester` (**Access control → Test access → Attribute rules**) evaluates the user that you pick. Any attribute that you type overrides the stored value for that check, so you can ask "what if": 1. Optionally pick a policy under **Start from a policy** to pre-fill its resource type and action. 2. Specify the resource type, the action and, optionally, a resource ID. 3. Add simulated **User attributes** and **Resource attributes**. Built-in user attributes (id, email, roles) come from the selected user's account; anything that you add under **User attributes** overrides them for this check. 4. Click **Run test**. 5. Review the decision and which active policies matched, and use it to debug policy conflicts. ![Test access in Attribute rules mode: a request is allowed by the matching policy, with the matched policies, active policies and defined attributes listed beside the form](https://docs.lumoauth.dev/img/portal/access-control/abac-tester.png) ### Via the ABAC Check API Evaluate a policy decision from code. The check runs against the **caller's own** access token, so send it as the Bearer token (no admin role needed): ```bash curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/abac/check \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{ "resourceType": "financial-report", "action": "read", "resourceId": "report-123" }' # -> { "allowed": false, "reason": "...", "matchedPolicies": [...] } ``` | Field | Required | Description | |-------|----------|-------------| | `resourceType` | yes | The resource type that the policy targets | | `action` | yes | The action being attempted (`read`, `write`, …) | | `resourceId` | no | Specific resource instance, for per-resource attributes | | `environment` | no | Extra context object; the server also adds `ip`, `userAgent`, `requestMethod` | Related endpoints: `POST /orgs/{orgId}/api/v1/abac/check-bulk` (up to 100 checks at once) and `GET /orgs/{orgId}/api/v1/abac/my-attributes` (the caller's resolved attributes). --- ## Related - [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) — base RBAC model. - [Relationships (ReBAC)](https://docs.lumoauth.dev/user-guide/access-control/zanzibar) — relationship-based access. - [AI Policy Authoring](https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring) — generate roles and relationship policies from plain English (attribute rules are written by hand). - [Adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa) — risk scores as ABAC attributes. --- # Zanzibar (Relationship-Based Authorization) Source: https://docs.lumoauth.dev/user-guide/access-control/zanzibar Zanzibar is the relationship-based authorization model described in Google's 2019 paper. Instead of assigning roles globally, Zanzibar stores relation tuples of the form object#relation@subject — for example… Zanzibar is the relationship-based authorization model described in Google's 2019 paper. Instead of assigning roles globally, Zanzibar stores **relation tuples** of the form `object#relation@subject` — for example `document:readme#editor@user:alice` ("alice is an editor of document readme"). An authorization check asks "is there a path in the relation graph from this subject to this object under this relation, given the schema's rewrite rules?". Use Zanzibar when access is driven by hierarchy and relationships: folders containing files, teams owning projects, organizations containing workspaces. Inheritance and sharing are natural in Zanzibar and awkward to express in RBAC (role-based access control). Examples are "editors of a folder can edit its documents" and "all members of this team can view this document". **Note: Fine-grained authorization add-on** Relationship-based access control (feature `zanzibar`) is part of the **Fine-grained authorization** add-on — $99/month, available on the Pro and Business plans, together with ABAC (attribute-based access control). No plan includes it; buy it under **Billing → Add-ons** (see the [pricing matrix](https://docs.lumoauth.dev/pricing-matrix)). When billing is enforced (`BILLING_ENFORCEMENT=enforce`, the production default) and the organization doesn't have the add-on: - every check denies (`allowed: false`), including MCP per-tool grants; - the portal shows an upgrade notice on the Relationships pages, and creating or editing a namespace or creating a tuple redirects to the upgrade page; - AI policy authoring rejects a policy that contains relations with `403` and `{"error": "feature_not_in_plan", "feature": "zanzibar", "upgrade": …}`. The RBAC → Zanzibar **Sync from roles** keeps working. Under `BILLING_ENFORCEMENT=log` checks are allowed and logged as `billing.would_deny`; an install with neither Stripe nor a license is not restricted. --- ## What is Zanzibar? Zanzibar stores relationships between subjects and objects. Each relationship is a single tuple. A check searches the graph formed by those tuples, following the schema's userset rewrites. | Traditional RBAC | Zanzibar | |-------------------|----------| | "Alice is an **editor**" (globally) | "Alice is an **editor** of **Document X**" | | "Bob is a **viewer**" (globally) | "Bob is a **member** of **Team A**, and Team A has **view** access on **Project Y**" | This answers fine-grained, per-object questions: - *"Can Alice edit this specific document?"* - *"Can Bob view this project because his team has access?"* - *"Who can access this file?"* --- ## Core Concepts ### Relation Tuples A **relation tuple** is the fundamental unit of Zanzibar. It describes a relationship between a subject and an object: ``` object#relation@subject ``` | Component | Description | Example | |-----------|-------------|---------| | **Object** | The resource (namespace:id) | `document:readme` | | **Relation** | The type of relationship | `editor` | | **Subject** | A user or a userset | `user:alice` or `group:eng#member` | Example tuples: ``` document:readme#editor@user:alice # Alice is editor of readme document:readme#viewer@group:engineering # Engineering group can view readme folder:docs#owner@user:bob # Bob owns the docs folder team:backend#member@user:carol # Carol is a member of backend team ``` A subject can be a concrete identity (`user:alice`) or a **userset** — a reference to every entity holding a given relation on another object, written `object#relation`. For example `group:engineering#member` means "every user who is a `member` of `group:engineering`". Userset subjects let you grant access to whole groups in a single tuple. ### Namespace Definitions A **namespace** defines the valid relations for a type of object and how each one is computed. The schema is a JSON object with a single `relations` key. The **Create namespace** form builds it for you, and its **Document sharing** example produces this: ```json { "relations": { "parent": { "_this": { "relation": "parent" } }, "owner": { "_this": { "relation": "owner" } }, "editor": { "union": [ { "computed_userset": { "relation": "owner" } }, { "_this": { "relation": "editor" } } ] }, "viewer": { "union": [ { "computed_userset": { "relation": "editor" } }, { "_this": { "relation": "viewer" } }, { "tuple_to_userset": { "tupleset": { "relation": "parent" }, "computed_userset": { "relation": "viewer" } } } ] } } } ``` This declares: - `parent`, `owner`, `editor` and `viewer` are valid relations for documents. `_this` means "the subjects stored directly in tuples for this relation". - `editor` = every owner (`computed_userset`) OR anyone stored as an editor. - `viewer` = every editor OR anyone stored as a viewer OR (`tuple_to_userset`) every viewer of the document's `parent` object, so a folder's viewers can see its documents. - Each relation is one of `_this`, `computed_userset`, `tuple_to_userset`, `union` or `intersection`; any other key is rejected when the namespace is saved. ### Check The core operation is **Check** — "does subject S have relation R on object O?" The engine searches the graph, following rewrite rules, and returns a yes/no. Run it from the portal Tester or via the [Check API](#via-api). **Note** The original Zanzibar paper also describes an **Expand** operation ("the full tree of subjects holding a relation on an object"). LumoAuth exposes Check and Expand (`POST /orgs/{orgId}/api/v1/zanzibar/expand` with `object` and `relation`, returning a `tree`). The portal tester shows the same tree under **Where it comes from**. There is no list-objects operation. ### Permission Inheritance Relations compose through the graph. Given: ``` folder:docs#owner@user:bob folder:docs#parent@folder:root document:readme#parent@folder:docs ``` and a schema rule "folder owners can view all documents in the folder", Bob can view `document:readme` — the check follows the `parent` edges up the tree. --- ## Managing Zanzibar ### Portal In the sidebar, under **Access control**, open **Relationships** (`/orgs/{orgId}/portal/zanzibar/`). The page has four tabs: - **Overview** — counts of namespaces and stored tuples, and the engine status. - **Namespaces** — define object types and their relations (`/zanzibar/namespaces`). The list shows each namespace with its relations and when it was last updated; the row menu (**...**) edits or deletes it. - **Tuples** — create and manage relationships; **Sync from roles** generates tuples from your roles and permissions (`/zanzibar/tuples`). - **Tester** — run a Check ("does subject S have relation R on object O?") against the relation graph (`/zanzibar/tester`). The header carries **Relationship check** (opens the Tester) and **Create namespace**. ![The Relationships overview with counters, the Overview, Namespaces, Tuples and Tester tabs, and the namespace list](https://docs.lumoauth.dev/img/portal/access-control/zanzibar-overview.png) ![The Namespaces tab listing a namespace with its parent, owner, editor and viewer relations](https://docs.lumoauth.dev/img/portal/access-control/zanzibar-namespaces.png) ### Creating Relation Tuples A tuple needs a namespace with the relation that you want to grant, so create the namespace first (**Create namespace**). 1. Go to `/orgs/{orgId}/portal/zanzibar/tuples` (the **Tuples** tab). 2. Click **Add relationship**. 3. Enter: - **Object namespace** and **Object ID** — the resource (e.g., `document` and `readme`). - **Relation** — one of the relations defined on that namespace (e.g., `editor`). - **User namespace** and **User ID** — the subject (e.g., `user` and `alice`). - **User relation** — optional; set it to make the subject a userset (e.g., `group`, `engineering` and `member` for `group:engineering#member`). 4. Click **Add tuple**. The tuple appears in the Tuples list, where **Revoke relationship** removes it again. Wildcards (`*`) are not accepted in the Object ID. ![The Add relationship form with object namespace, object ID, relation and subject filled in](https://docs.lumoauth.dev/img/portal/access-control/zanzibar-tuple-create.png) ![The Tuples tab listing stored relationships with their object, relation and subject, and the Sync from roles and Add relationship buttons](https://docs.lumoauth.dev/img/portal/access-control/zanzibar-tuples.png) To verify the result, open the **Tester** tab, pick an object, a relation and a user from your stored tuples, and click **Check permission**. **Quick tests** fills the form from your tuples for you: **Test as me** and **Random test**. ![Test access in Relationships mode, with an object, relation and user picked from the stored tuples and the Quick tests panel](https://docs.lumoauth.dev/img/portal/access-control/zanzibar-tester.png) ### Via API Tuples (writes) are managed through the portal — create them at `/orgs/{orgId}/portal/zanzibar/tuples/create` or generate them from your roles with the **Sync from roles** action. There is no public REST endpoint for tuple writes or list-objects. The REST API exposes **Check** and **Expand**. The `relation` field takes any relation defined on the namespace. The engine resolves relations computed from others (`editor` above, which includes every owner). A caller may always check **itself**. Checking any other subject (another user, an agent or a userset) needs the `authz.check` permission or the `authz:check` API-key scope. Otherwise the API answers `403` with `"code": "insufficient_permissions"`. ```bash # Check a relation (tenant-scoped) curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/zanzibar/check \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{ "object": "document:readme", "relation": "editor", "user": "user:alice" }' # Response: {"allowed": true, ...} # Expand: who holds a relation on an object, as a tree curl -X POST https://your-domain.com/orgs/{orgId}/api/v1/zanzibar/expand \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{"object": "document:readme", "relation": "viewer"}' # Response: {"tree": {"type": "union", "object": "document:readme", "relation": "viewer", "children": [...], "subjects": [...]}} ``` There is also a global authorization route, `POST /api/v1/authz/zanzibar/check`, with the same `object` and `relation` fields but using **`subject`** instead of `user`: ```bash curl -X POST https://your-domain.com/api/v1/authz/zanzibar/check \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{ "object": "document:readme", "relation": "editor", "subject": "user:alice" }' ``` **Caution: Canonical route + field names** The **canonical** Zanzibar check route is the organization-scoped `POST /orgs/{orgId}/api/v1/zanzibar/check` (used throughout this page). The other two are variants: `POST /api/v1/zanzibar/check` is a **deprecated** alias without a tenant in the path, and `POST /api/v1/authz/zanzibar/check` is the unscoped variant that the JavaScript/TypeScript SDKs use. Prefer the canonical route. Note the field name: `/orgs/{orgId}/api/v1/zanzibar/check` and `/api/v1/zanzibar/check` use **`user`**, while `/api/v1/authz/zanzibar/check` uses **`subject`**. All three use `relation` (not `permission`). The wrong field name returns `400`: `/api/v1/authz/zanzibar/check` answers with `"code": "MISSING_FIELDS"`, the other two with an `error` message listing the missing fields. --- ## Examples ### Google Drive-Style Sharing ``` # Bob owns a folder folder:shared-docs#owner@user:bob # Alice can edit files in the folder folder:shared-docs#editor@user:alice # The engineering group can view folder:shared-docs#viewer@group:engineering # A document is in the folder document:design-doc#parent@folder:shared-docs ``` ### Organization Hierarchy ``` # Carol is a member of the backend team team:backend#member@user:carol # Backend team belongs to the engineering organization org:engineering#team@team:backend # Engineering org has access to the production environment environment:production#accessor@org:engineering#member # Carol can access production through: team → org → environment ``` ### Multi-Organization SaaS ``` # Acme Corp's workspace workspace:acme#admin@user:alice workspace:acme#member@user:bob # Project within the workspace project:api-v2#parent@workspace:acme project:api-v2#lead@user:bob # Bob is project lead AND workspace member # Alice is workspace admin, so she can access all projects ``` --- ## Zanzibar vs RBAC | Aspect | RBAC | Zanzibar | |--------|------|----------| | Granularity | Role-level | Object-level | | Scale | Hundreds of roles | Large relation graphs | | Questions answered | "What can this user do?" | "Can this user do X to object Y?" | | Maintenance | Manage roles and assignments | Manage relation tuples | | Best for | Application-wide permissions | Per-resource permissions | --- ## Common mistakes - **Every check returns `allowed: false` although the tuple exists.** The organization doesn't have the Fine-grained authorization add-on and billing is enforced. Add it under **Billing → Add-ons** (on Developer, upgrade to Pro first); the Relationships pages show the notice. - **The Tuples and Namespaces pages show "Upgrade to enable" instead of "Add relationship" / "Create namespace".** Same cause: new namespaces and tuples can't be added without the add-on, and opening the create pages by URL redirects to Billing with the add-on preselected. ## Best Practices | Practice | Description | |----------|-------------| | Design namespaces carefully | Model domain objects and their natural relationships | | Use inheritance over duplication | Prefer `parent` relations to copying tuples | | Grant to groups, not individuals | Use userset subjects for team access | | Test on the **Tester** tab | Verify expected decisions at `/orgs/{orgId}/portal/zanzibar/tester` (**Test access → Relationships**) before rolling out | | Start small | Begin with a few relations, extend as you learn the domain | --- ## Related - [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) — application-wide RBAC. - [Attribute rules (ABAC)](https://docs.lumoauth.dev/user-guide/access-control/abac) — attribute and context conditions. - [AI Policy Authoring](https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring) — generate relationship namespaces from plain English. - [Access Control Overview](https://docs.lumoauth.dev/user-guide/access-control/overview) — compare all authorization models. --- # AI Policy Authoring Source: https://docs.lumoauth.dev/user-guide/access-control/ai-policy-authoring The AI Policy Author turns a natural-language description of an access rule into a concrete policy definition. The definition is either roles and permissions (RBAC, role-based access control), or relationship… The AI Policy Author turns a natural-language description of an access rule into a concrete policy definition. The definition is either roles and permissions (RBAC, role-based access control), or relationship namespaces, relations and tuples (ReBAC, relationship-based access control, which LumoAuth calls **Relationships**). The generated output is shown for review before you apply it, so the AI is a drafting aid, not an automatic approver. **Note** The AI Policy Author generates **roles and permissions** and **relationships** only. It does not generate attribute rules (ABAC) — author those by hand under [Attribute rules](https://docs.lumoauth.dev/user-guide/access-control/abac). Use this when you want to go from "here is what should be allowed" in plain English to a working policy quickly. The more specific your description, the better the draft. --- ## Prerequisites - An AI provider configured on the platform. - Access to `/orgs/{orgId}/portal/policies/author`. When no AI provider is configured, the page shows an **AI features disabled** notice instead of the authoring form. ![The AI policy authoring page showing the AI features disabled notice](https://docs.lumoauth.dev/img/portal/access-control/ai-policy-authoring.png) --- ## How It Works 1. **Describe your policy** in natural language. 2. The **AI generates** the corresponding RBAC roles/permissions and/or Zanzibar relations and tuples, returned as a JSON object. 3. **Review** the draft. It cannot be edited in place: change the description and click **Analyze and preview** again. 4. **Apply** to create the roles, permissions, and relationship namespaces/tuples in your organization. **URL:** `/orgs/{orgId}/portal/policies/author`. When AI is enabled, two buttons open it: **Draft with AI** on the **Roles & permissions** page and **AI policy authoring** on the **Relationships** page. Both need the `policies.view` permission. --- ## Examples ### RBAC Generation **Input:** > "Create roles for a project management app. Project managers should be able to create, edit, and delete projects and assign team members. Team members can view projects and update tasks. Viewers can only see project details." **Generated Output** (a JSON object — abbreviated here): ```json { "policy_name": "Project Management Access", "roles": [ { "name": "Project Manager", "slug": "project_manager", "description": "Manages projects and team members" }, { "name": "Team Member", "slug": "team_member", "description": "Works on projects and tasks" }, { "name": "Viewer", "slug": "viewer", "description": "Read-only access to projects" } ], "permissions": [ { "name": "Create Project", "slug": "projects.create", "resource": "projects", "action": "create", "description": "Create new projects" }, { "name": "Edit Project", "slug": "projects.edit", "resource": "projects", "action": "edit", "description": "Edit projects" }, { "name": "View Project", "slug": "projects.read", "resource": "projects", "action": "read", "description": "View project details" } ], "role_permissions": [ { "role_slug": "project_manager", "permission_slug": "projects.create" }, { "role_slug": "project_manager", "permission_slug": "projects.edit" }, { "role_slug": "team_member", "permission_slug": "projects.read" }, { "role_slug": "viewer", "permission_slug": "projects.read" } ] } ``` The full object also carries `zanzibar_relations`, `zanzibar_tuples`, and `exclusions` keys (empty for a pure-RBAC case). Applying a policy with non-empty `zanzibar_relations` needs the `zanzibar` feature (Fine-grained authorization add-on, Pro and Business). Without it, and with billing enforced, **Apply** is rejected with `403` and `{"error": "feature_not_in_plan", "feature": "zanzibar", …}` and nothing is created; pure-RBAC policies still apply. ### Zanzibar Generation **Input:** > "Design a document sharing system. Documents belong to folders. Folders belong to workspaces. Workspace admins can do everything. Folder editors can edit documents in their folder. Document viewers can only read." **Generated Output** (the `zanzibar_relations` / `zanzibar_tuples` portion of the JSON object — abbreviated): ```json { "policy_name": "Document Sharing", "zanzibar_relations": [ { "namespace": "folder", "relation": "editor", "description": "Folder editors can edit documents in the folder", "definition": { "union": [ { "_this": { "relation": "editor" } }, { "tuple_to_userset": { "tupleset": { "relation": "parent" }, "computed_userset": { "relation": "editor" } } } ] } }, { "namespace": "document", "relation": "viewer", "description": "Document viewers can read the document", "definition": { "_this": { "relation": "viewer" } } } ], "zanzibar_tuples": [ { "description": "Seed: editor of the design folder", "namespace": "folder", "object_id": "placeholder_id", "relation": "editor", "user_namespace": "user", "user_id": "placeholder_user" } ] } ``` --- ## Using the Policy Author ### Step 1: Write a Description Type the rule into **Policy description** (or pick one under **Start from an example**), then click **Analyze and preview**. Be specific about: - **Who** should have access (roles, groups, user attributes). - **What** they should access (resources, actions). - **When/Where** access is allowed (conditions, constraints). ### Step 2: Review the Draft The generator returns a JSON policy that combines RBAC (roles, permissions, role-permission mappings, exclusions) and Zanzibar (relations and seed tuples), as the description warrants. The naming is consistent, and the set of permissions is complete for the case that you described. ### Step 3: Adjust if Needed The preview tables (**Roles**, **Permissions**, **Assignments**, **Fine-grained relations**, **Exclusions**) are read-only. To change the draft, click **Reset**, refine the description and run **Analyze and preview** again. ### Step 4: Apply Click **Apply policy** and confirm to create the roles, permissions, role-permission mappings, and relationship namespaces/tuples in your organization. --- ## Tips for Better Results | Tip | Example | |-----|---------| | Be specific about actions | "can create, edit, and delete" instead of "can manage" | | Name your actors | "Project managers should..." instead of "some users should..." | | Mention edge cases | "External contractors can view but not download" | | Describe relationships and hierarchy | "editors of a folder can edit its documents" (generates Zanzibar relations) | | State exclusions explicitly | "everyone except contractors" (generates exclusions) | | Reference existing resources | "for the documents and folders in our file system" | --- ## Related - [Roles & Permissions](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions) — RBAC basics. - [ABAC](https://docs.lumoauth.dev/user-guide/access-control/abac) — attribute-based policies. - [Zanzibar](https://docs.lumoauth.dev/user-guide/access-control/zanzibar) — relationship-based access. - [Access Control Overview](https://docs.lumoauth.dev/user-guide/access-control/overview) — compare all models. --- # Multi-Tenancy Source: https://docs.lumoauth.dev/user-guide/multi-tenancy/overview LumoAuth is a multi-organization identity platform. Every resource is scoped to an organization: users, applications, roles and configurations. Data is therefore fully isolated between organizations, while they share a… LumoAuth is a multi-organization identity platform. Every resource is scoped to an organization: users, applications, roles and configurations. Data is therefore fully isolated between organizations, while they share a single deployment. --- ## What is Multi-Tenancy? An **organization** in LumoAuth represents an isolated identity domain. Each organization has its own: - Users and user profiles - OAuth applications and API clients - Roles, permissions, and access policies - Sign-in settings (MFA, social login, SSO) - Branding and custom domains - Audit logs and compliance data Organizations are completely isolated from each other. A user in one organization cannot access resources in another organization. --- ## URL Structure Every organization is accessed through its unique slug in the URL: ``` https://your-domain.com/orgs/{orgId}/... ``` | Path | Purpose | |------|---------| | `/orgs/{orgId}/portal/` | Organization admin portal dashboard | | `/orgs/{orgId}/portal/applications` | Manage OAuth applications | | `/orgs/{orgId}/portal/users` | Users (tabs: **Users**, **Invitations**, **Groups**) | | `/orgs/{orgId}/portal/roles` | Roles & permissions | | `/orgs/{orgId}/portal/settings/authentication` | Sign-in settings (social login, SAML and OIDC identity providers are sections of **Settings → Integrations**) | | `/orgs/{orgId}/api/v1/` | Organization API endpoints | | `/orgs/{orgId}/api/v1/oauth/authorize` | OAuth authorization endpoint | | `/orgs/{orgId}/api/v1/oauth/token` | Token endpoint | | `/orgs/{orgId}/api/v1/.well-known/openid-configuration` | OIDC discovery document (the JSON file that lists your endpoints and keys) | See the [Organization Portal](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-portal) guide for the full list of portal pages and their URLs. With [custom domains](https://docs.lumoauth.dev/user-guide/multi-tenancy/custom-domains), you can map `auth.yourdomain.com` to your organization, removing the `/orgs/{orgId}` prefix entirely. --- ## Use Cases | Scenario | How Multi-Tenancy Helps | |----------|------------------------| | **SaaS Platform** | Each customer gets their own organization with separate users and settings | | **Enterprise Departments** | Each department maintains independent identity configurations | | **Environments** | Separate organizations for dev, staging, and production | | **White-Label Products** | Each brand operates under its own custom domain | --- ## Organization Isolation LumoAuth enforces organization isolation at every layer: | Layer | Isolation Mechanism | |-------|-------------------| | **Data** | All database queries are scoped to the current organization | | **Authentication** | Login sessions are organization-specific | | **API** | All API requests require organization context | | **Configuration** | Auth settings, MFA policies, social providers are per-organization | | **Audit Logs** | Logs are organization-scoped and cannot be accessed cross-organization | | **Tokens** | Access tokens include organization claims and are validated per-organization | --- ## In This Section | Guide | Description | |-------|-------------| | [Organization Setup](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-setup) | Create, configure, and manage organizations | | [Organization Portal](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-portal) | Navigate the organization admin portal | | [Custom Domains](https://docs.lumoauth.dev/user-guide/multi-tenancy/custom-domains) | Map your own domain to an organization | --- # Organization Setup Source: https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-setup This guide covers configuring and managing your organization in LumoAuth. This guide covers configuring and managing your organization in LumoAuth. --- ## Getting Your Organization When you sign up for LumoAuth, you create an organization during onboarding. Your organization is immediately available at: ``` https://app.lumoauth.dev/orgs/{your-organization-slug}/portal/ ``` Your organization ID is a URL-safe identifier (e.g., `acme-corp`) that you chose during signup. --- ## Organization Configuration Configure your organization settings through the organization portal at `/orgs/{orgId}/portal/`. ![The portal dashboard, the landing page of the organization portal](https://docs.lumoauth.dev/img/portal/getting-started/dashboard.png) ### Sign-in settings Navigate to **Security → Sign-in settings** (`/orgs/{orgId}/portal/settings/authentication`) to configure: | Setting | Description | |---------|-------------| | **Identity methods** | **Passkeys & biometrics** (including the FIDO Metadata Service check of authenticators) and **Require push authentication** | | **Login flow** | Email-first login, magic link login | | **Password standards** | Template or custom rules: minimum length and complexity requirements | | **Auth lifecycle** | Session timeout, breached-password checks, password rotation | | **Platform security** | OAuth security and developer-facing protocol features | | **Progressive profiling** | Ask for profile details gradually across several sign-ins instead of all at registration | | **MFA policy** | Off, optional, risk-based or required MFA; allowed factors, minimum strength, grace period and trusted devices (see [MFA](https://docs.lumoauth.dev/user-guide/authentication/mfa#configuring-the-mfa-policy)) | | **Admin API keys** | Scoped keys for programmatic access to the Admin API (managed under **Applications & developer tools → API keys**) | Self-registration is switched on or off with **Enable self registration** under **Settings → Login page & emails → Login page**, not on this page. ![The Sign-in settings page with its section tabs](https://docs.lumoauth.dev/img/portal/getting-started/authentication-settings.png) ### Identity Providers Configure external identity providers for your organization: | Provider Type | Configuration Path | |--------------|-------------------| | [Social Login](https://docs.lumoauth.dev/user-guide/authentication/social-login) | **Social login** section of `/orgs/{orgId}/portal/integrations` (**Add provider**) | | [SAML 2.0 IdP](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso) | **Enterprise SSO (SAML)** section of `/orgs/{orgId}/portal/integrations`, or `/orgs/{orgId}/portal/saml-idps` | | [OIDC Federation](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso) | **Enterprise SSO (OIDC)** section of `/orgs/{orgId}/portal/integrations`, or `/orgs/{orgId}/portal/oidc-idps` | | [LDAP / Active Directory](https://docs.lumoauth.dev/user-guide/authentication/enterprise-sso) | **Directory sync (LDAP/AD)** section of `/orgs/{orgId}/portal/integrations` | All of these are under **Settings → Integrations**. ![The Integrations page with its section tabs and the Enterprise SSO (SAML) section](https://docs.lumoauth.dev/img/portal/getting-started/integrations.png) ### Adaptive Authentication Configure risk-based authentication under **Security → Adaptive security** (`/orgs/{orgId}/portal/settings/adaptive-auth`): - The adaptive security engine on/off switch - Risk score thresholds - Detection signals such as impossible travel, new country and IP reputation - Network guard (Tor and datacenter/VPN blocking) - Trusted IP ranges ![The Adaptive security page with the engine toggle and risk thresholds](https://docs.lumoauth.dev/img/portal/getting-started/adaptive-security.png) See [Adaptive MFA](https://docs.lumoauth.dev/user-guide/authentication/adaptive-mfa) for details. --- ## Organization Roles Every organization starts with one system role, **Tenant Admin**, which holds every admin permission and cannot be deleted. Any other administrator role is one that you create under **Access control → Roles & permissions** (**Create role**). Set an access level per area, or pick **Read-only**, **Editor** or **Administrator** under **Start from** and adjust. Typical roles teams create: | Role | Description | Typical Permissions | |------|-------------|-------------------| | **Tenant Admin** (system role) | Full control over the organization | All organization operations | | A user manager | Manage users and assignments | Create/edit/delete users, assign roles | | An application manager | Manage OAuth and SAML applications | Create/edit/delete applications | | An auditor | Read-only access to logs | View audit logs, view users | See [How the admin permissions work](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions#how-the-admin-permissions-work). ### Assigning Organization Admins 1. Go to **Identity → Users** (`/orgs/{orgId}/portal/users`) 2. Open a user and switch to the **Roles** tab 3. Tick the organization admin role (the system role named **Tenant Admin**) and click **Save changes** ![The Roles tab of a user's page with the available roles as checkboxes](https://docs.lumoauth.dev/img/portal/getting-started/user-edit-roles.png) --- ## Organization Settings via API You can also manage organization settings from code, using the Admin API: ```bash # Update organization authentication settings. # Note: /api/v1/admin/settings is read-only (GET). Update each settings # group at its own sub-resource, e.g. .../settings/authentication. curl -X PATCH https://app.lumoauth.dev/orgs/acme-corp/api/v1/admin/settings/authentication \ -H "Authorization: Bearer {access_token}" \ -H "Content-Type: application/json" \ -d '{ "session_timeout": 1209600, "social_login_enabled": true }' ``` The Admin settings API is split into per-group sub-resources, each supporting `GET` (read) and `PATCH`/`PUT` (update): | Group | Path | |-------|------| | Authentication | `/orgs/{orgId}/api/v1/admin/settings/authentication` | | Security | `/orgs/{orgId}/api/v1/admin/settings/security` | | Email | `/orgs/{orgId}/api/v1/admin/settings/email` | | General | `/orgs/{orgId}/api/v1/admin/settings/general` | `GET /orgs/{orgId}/api/v1/admin/settings` returns all groups in one aggregate response. The MFA policy has its own endpoint, `GET`/`PUT /orgs/{orgId}/api/v1/admin/policies/mfa` — see the [MFA API](https://docs.lumoauth.dev/api-reference/mfa#mfa-policy). See the [Admin API](https://docs.lumoauth.dev/admin) documentation for the full list of available endpoints. --- ## Organization Data Model Each organization owns the following resources: ```mermaid graph TD Organization Organization --> Users Users --> Profiles Users --> MFA["MFA Methods"] Users --> Passkeys["Passkey Credentials"] Users --> Social["Social Accounts"] Organization --> Apps["Applications (OAuth Clients)"] Apps --> AT["Access Tokens"] Apps --> RT["Refresh Tokens"] Apps --> AC["Authorization Codes"] Organization --> AC2["Access Control"] AC2 --> Roles AC2 --> Permissions AC2 --> Groups AC2 --> ABAC["ABAC Policies"] AC2 --> Zanzibar["Zanzibar Relations"] Organization --> IdP["Identity Providers"] IdP --> Social2["Social Login Providers"] IdP --> SAML["SAML IdP Configs"] IdP --> OIDC["OIDC IdP Configs"] IdP --> LDAP["LDAP Configs"] Organization --> Config["Configuration"] Config --> AuthSettings["Auth Settings"] Config --> EmailTemplates["Email Templates"] Config --> SigningKeys["Signing Keys"] Config --> Domains["Custom Domains"] Config --> Webhooks Organization --> Compliance Compliance --> AuditLogs["Audit Logs"] Compliance --> GDPR["GDPR Requests"] ``` --- ## Related Guides - [Organization Portal](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-portal) - Navigate the admin portal - [Custom Domains](https://docs.lumoauth.dev/user-guide/multi-tenancy/custom-domains) - Map your domain to an organization - [Your first 15 minutes in the portal](https://docs.lumoauth.dev/user-guide/getting-started/first-tenant) - Step-by-step walkthrough of organization configuration --- # Organization Admin Portal Source: https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-portal The organization admin portal is the primary interface for managing an organization's identity configuration, users, applications, and security settings. The organization admin portal is the primary interface for managing an organization's identity configuration, users, applications, and security settings. **URL:** `/orgs/{orgId}/portal/` --- ## Portal Dashboard The portal root, `/orgs/{orgId}/portal/`, **is** the dashboard. It provides an at-a-glance view of: - A **Get *your organization* set up** checklist (create an application, register your first agent, invite a teammate, turn on MFA and webhooks). You can dismiss it, and when you chose **An AI agent** on [Get started](#home) the agent step comes first. A new organization also gets a **Your Starter App is ready** card with the client ID, issuer and links to the quickstart and a test sign-in - A time-range switch (**24h**, **7 days**, **30 days**, **90 days**) that drives every tile and chart, each compared with the period before - Tiles for **Applications**, **Users**, **Roles**, **New signups**, **Sign-ins**, **Active users**, **Failed sign-in rate** and **Tokens issued** - Charts for **Sign-ins**, **Active users**, **New signups**, **Sign-in methods**, **Access tokens issued**, **Audit events**, **Most frequent events**, **Why sign-ins fail**, **Authentication latency**, **When people sign in** (a weekday-by-hour heatmap) and **Adaptive security decisions** - Lists for **Top applications** and **Recent sign-ins** (last 24 hours), plus a **Security posture** card (MFA and passkey coverage, recently active users) - Quick actions: **New client**, **Add user** and **Metadata** See [Analytics](https://docs.lumoauth.dev/user-guide/compliance/analytics) for what each panel counts. ![The portal dashboard with the setup checklist, the time-range switch and the summary tiles](https://docs.lumoauth.dev/img/portal/getting-started/dashboard.png) --- ## Portal Sections The left sidebar groups everything into the sections below, in this order. Entries that you have no permission for are hidden. ![The portal sidebar with all of its groups expanded](https://docs.lumoauth.dev/img/portal/getting-started/sidebar.png) ### Home The two entries at the top of the sidebar, above the first group heading. | Entry | URL | What it is | |-------|-----|------------| | **Dashboard** | `/orgs/{orgId}/portal/` | At-a-glance health, setup checklist, recent activity | | **Get started** | `/orgs/{orgId}/portal/onboarding` | Guided setup. Answer **What are you building?** (**An app people sign in to**, **An AI agent** or **Both**) once per organization. The page and the dashboard checklist then lead with that path. The page offers **Secure an AI agent** (**Start building**) and **Add sign-in to an app** (**Open quickstarts**). It also has the **AI agent + MCP in 60 seconds** tour and **Your organization credentials** (organization slug, auth server URL and OIDC discovery URL) | ![The Get started page: What are you building?, the Secure an AI agent and Add sign-in to an app paths, and the 60-second MCP tour](https://docs.lumoauth.dev/img/portal/getting-started/get-started.png) ### AI Agents | Entry | URL | Description | |-------|-----|-------------| | **Agents** | `/orgs/{orgId}/portal/agents` | Register agents (**Register agent**) and see what they are allowed to do. Tabs: **Agents**, **AAuth credentials**, **AAuth resources** (with the issuer's discovery endpoints) and **Connections** (`/portal/connections`, third-party services that agents call on a user's behalf). The list has an **All** / **Needs attention** filter and an **Access rules** shortcut. An AAuth credential is how an agent proves its identity: its old `/portal/aauth/agents/{id}` URL opens the agent, and its keys are edited as **AAuth credentials** there | | **Approvals** | `/orgs/{orgId}/portal/approvals` | Everything waiting on a human: permission requests (decided in place), user consents and action approvals, behind a type filter. `/portal/jit-approvals` serves the same page | | **MCP servers** | `/orgs/{orgId}/portal/mcp-servers` | Register and protect MCP servers (**Register MCP server**) | | **Agent rules** | `/orgs/{orgId}/portal/agents/policies` | Tabs: **Access rules** (conditional access when an agent authenticates), **Auto-approve rules** (`/portal/agents/rules/auto-approve`) and **Cross-app access** (`/portal/delegation`, which applications and identity providers may obtain tokens for other applications on a user's behalf) | | **Agent activity** | `/orgs/{orgId}/portal/caep-dashboard` | Tabs: **Activity** (fleet activity and CAEP) and **Security signals** (`/portal/agent-guard/security-signals`). See [Agent activity](#agent-activity) below | If you may manage identity providers but not agents, the group shows a single **Agent rules** entry instead, which opens the **Cross-app access** tab (`/orgs/{orgId}/portal/delegation`). ![The Agents page with the Register agent button and the Agents, AAuth credentials, AAuth resources and Connections tabs](https://docs.lumoauth.dev/img/portal/getting-started/agents.png) See [AI Agents](https://docs.lumoauth.dev/user-guide/ai-agents/overview). #### Agent activity **Agent activity** (`/orgs/{orgId}/portal/caep-dashboard`) is the fleet view for your agents. A **24h** / **7 days** / **30 days** switch drives the tiles (**Agent tasks**, **JIT requests**, **Pending approval**, **CAEP flagged**, **Approval rate**, **Time to decision**, **Average task risk**) and the **Agent tasks** and **JIT permission requests** charts. The **Security signals** tab manages the continuous access evaluation streams that notify receivers when an agent's access changes. ![The Agent activity page with fleet tiles and the agent task and permission request charts](https://docs.lumoauth.dev/img/portal/getting-started/agent-activity.png) ### Identity | Entry | URL | Description | |-------|-----|-------------| | **Users** | `/orgs/{orgId}/portal/users` | Invite users (**Invite users**), create users with a password (**Create user**), view profiles, assign roles. Tabs: **Users**, **Invitations** and **Groups** (`/portal/groups`, organize users into groups; not a sidebar entry of its own) | | **Customer organizations** | `/orgs/{orgId}/portal/organizations` | Customer organizations within your organization, with their own members and invitations | ![The Users page with the Users, Invitations and Groups tabs and the Create user and Invite users buttons](https://docs.lumoauth.dev/img/portal/getting-started/users.png) See [User Management](https://docs.lumoauth.dev/user-guide/user-management/overview). ### Access control | Entry | URL | Description | |-------|-----|-------------| | **How access works** | `/orgs/{orgId}/portal/access` | Landing page: the three models, what each answers, what you have set up, and the order in which one decision is evaluated | | **Roles & permissions** | `/orgs/{orgId}/portal/roles` | Tabs: **Roles** (create and manage RBAC roles — start here) and **Permissions** (`/portal/permissions`, the catalog that roles are built from). **Draft with AI** opens AI policy authoring when AI is enabled | | **Relationships** | `/orgs/{orgId}/portal/zanzibar/` | Fine-grained relationship-based access (add-on) | | **Attribute rules** | `/orgs/{orgId}/portal/abac/` | Attribute-based access control policies (add-on) | | **Test access** | `/orgs/{orgId}/portal/permissions/tester` | One tester, three modes: roles & permissions, attribute rules (`/portal/abac/tester`, with what-if attribute overrides) and relationships (`/portal/zanzibar/tester`, with the expansion of who holds the relation) | ![The Roles page with the Create role button](https://docs.lumoauth.dev/img/portal/getting-started/roles.png) See [Access Control](https://docs.lumoauth.dev/user-guide/access-control/overview) for how to choose between the three models. ### Applications & developer tools | Entry | URL | Description | |-------|-----|-------------| | **Applications** | `/orgs/{orgId}/portal/applications` | OAuth 2.0 / OIDC and SAML applications. Click **New application** | | **Quickstarts** | `/orgs/{orgId}/portal/onboarding/quickstart` | Copy-paste integration snippets pre-filled with your organization values. Each framework tab lists only clients of the kind that its snippets need (public PKCE for browser apps, confidential for server apps, machine-to-machine for scripts). It can create one that returns to the tab, and it ends with **Check it worked** — the first sign-in through the selected client | | **API keys** | `/orgs/{orgId}/portal/settings/api-keys` | Keys for the Admin API. Click **Create API key** | | **API explorer** | `/orgs/{orgId}/portal/developer/api-explorer` | Try API calls from the browser | | **Test login** | `/orgs/{orgId}/portal/developer/test-login` | Run a real sign-in against your organization | | **JWT inspector** | `/orgs/{orgId}/portal/developer/jwt-inspector` | Decode and check tokens | | **Endpoints & metadata** | `/orgs/{orgId}/portal/metadata` | Issuer, discovery document, JWKS and other endpoint URLs | The **Applications** page has two tabs: **Yours** (the applications that you created) and **Provided by LumoAuth** (first-party clients such as the LumoAuth CLI, managed for you). Search by name or identifier. ![The Applications page with the Yours and Provided by LumoAuth tabs and the New application button](https://docs.lumoauth.dev/img/portal/getting-started/applications.png) See [Applications](https://docs.lumoauth.dev/user-guide/applications/overview). #### Developer tools **API explorer** loads the organization's OpenAPI 3 spec in an interactive explorer (with a **Raw spec (JSON)** link). Choose **Authorize**, paste a Bearer access token, then expand an endpoint to try it. Open **How to use the explorer** for a short guide. ![The API explorer with the Authorize button and the Identity and Agents endpoint groups](https://docs.lumoauth.dev/img/portal/getting-started/api-explorer.png) **Test login** runs the complete hosted sign-in flow without leaving the portal. It uses a dedicated test client that LumoAuth provisions for the organization (its client ID, scopes, authorize endpoint and callback are shown). **Start test login** opens the sign-in in a popup, exchanges the code with PKCE and shows the **Token response**, the **Decoded ID token claims** and the `/userinfo` response (or an **Error**). ![The Test login page with the test client details and the Start test login button](https://docs.lumoauth.dev/img/portal/getting-started/test-login.png) **JWT inspector** decodes an access token, ID token or any JWT in your browser (the token is never sent to LumoAuth). **Decode** reads the header and claims, **Verify against JWKS** checks the signature against the organization's published keys, and **Introspect (opaque)** asks the introspection endpoint about non-JWT tokens. ![The JWT inspector with the token box, the Decode, Verify against JWKS and Introspect buttons and the JWKS source](https://docs.lumoauth.dev/img/portal/getting-started/jwt-inspector.png) **Endpoints & metadata** lists every URL that your integrations need, one tab per protocol. The tabs are **OAuth 2.0 & OIDC** (issuer, authorization, token, UserInfo, logout, revocation, introspection, PAR, JWKS and the discovery document), **SAML identity provider**, **SAML service provider**, **AAuth** and **MCP**. Each has a copy button. ![Endpoints & metadata with the OAuth 2.0 and OIDC endpoint URLs](https://docs.lumoauth.dev/img/portal/getting-started/endpoints-metadata.png) ### Security | Entry | URL | Description | |-------|-----|-------------| | **Sign-in settings** | `/orgs/{orgId}/portal/settings/authentication` | Identity methods, login flow, password standards, session timeout, MFA policy | | **Adaptive security** | `/orgs/{orgId}/portal/settings/adaptive-auth` | Risk-based authentication settings | | **Signing keys** | `/orgs/{orgId}/portal/signing-keys` | Manage JWT signing keys (RSA/EC) | | **Audit logs** | `/orgs/{orgId}/portal/audit-logs` | View all authentication and admin events | ![The Sign-in settings page with its section tabs](https://docs.lumoauth.dev/img/portal/getting-started/authentication-settings.png) ### Settings | Entry | URL | Description | |-------|-----|-------------| | **Integrations** | `/orgs/{orgId}/portal/integrations` | One hub for social login (**Add provider**), enterprise SSO (SAML and OIDC), LDAP, SCIM, webhooks, log export, alerting and more | | **Login page & emails → Login page** | `/orgs/{orgId}/portal/settings/login-page` | Branding, layout and self-registration for the sign-in, registration and consent pages | | **Login page & emails → Email templates** | `/orgs/{orgId}/portal/settings/email-templates` | Customize verification, reset, invite emails | | **Custom domains** | `/orgs/{orgId}/portal/custom-domains` | Map your domain to this organization | | **Billing** | `/orgs/{orgId}/portal/billing` | Plan, usage and add-ons | | **Data export** | `/orgs/{orgId}/portal/data-export` | Export organization data | ![The Integrations page with its section tabs and the Enterprise SSO (SAML) section](https://docs.lumoauth.dev/img/portal/getting-started/integrations.png) Webhooks are managed under **Settings → Integrations → Webhooks** (add one at `/orgs/{orgId}/portal/integrations/webhooks/create`). From there you can send a test event and open a webhook's delivery history. They are also available through the Admin API at `/orgs/{orgId}/api/v1/admin/webhooks`. ### GDPR & privacy | Entry | URL | Description | |-------|-----|-------------| | **Privacy overview** | `/orgs/{orgId}/portal/gdpr` | Entry point to breach incidents, privacy policies, processing activities and GDPR settings | | **Subject requests** | `/orgs/{orgId}/portal/gdpr/dsr` | Data subject access and deletion requests | | **Consent records** | `/orgs/{orgId}/portal/gdpr/consent-records` | Who consented to what, and when | ![Privacy overview with its counters, privacy tabs and shortcut cards](https://docs.lumoauth.dev/img/portal/getting-started/gdpr-overview.png) ### Help **Help for this page** opens the guide for the page that you are on, in a new tab (see the [portal page → guide map](https://docs.lumoauth.dev/user-guide/portal-map); the entry is hidden on pages without a guide). **Documentation** opens the home page of this documentation site. --- ## Portal Navigation ```mermaid graph TD Home["Home: Dashboard, Get started"] Agents["AI Agents"] Agents --> AgentCore["Agents (Agents, AAuth credentials, AAuth resources, Connections)"] Agents --> Approvals["Approvals"] Agents --> MCP["MCP servers"] Agents --> Rules["Agent rules: Access rules, Auto-approve rules, Cross-app access"] Agents --> Activity["Agent activity: Activity, Security signals"] Identity["Identity"] Identity --> Users["Users"] Identity --> Groups["Users › Invitations, Groups (tabs)"] Identity --> B2B["Customer organizations"] Access["Access control"] Access --> How["How access works"] Access --> Roles["Roles & permissions: Roles, Permissions"] Access --> ReBAC["Relationships"] Access --> ABAC["Attribute rules"] Access --> Tester["Test access"] Dev["Applications & developer tools"] Dev --> Apps["Applications"] Dev --> Quick["Quickstarts"] Dev --> Keys["API keys"] Dev --> Tools["API explorer, Test login, JWT inspector, Endpoints & metadata"] Sec["Security"] Sec --> AuthSettings["Sign-in settings"] Sec --> Adaptive["Adaptive security"] Sec --> SigningKeys["Signing keys"] Sec --> AuditLogs["Audit logs"] Settings["Settings"] Settings --> Integrations["Integrations"] Settings --> LoginPage["Login page"] Settings --> Email["Email templates"] Settings --> Domains["Custom domains"] Settings --> Billing["Billing, Data export"] GDPR["GDPR & privacy"] GDPR --> GdprPages["Privacy overview (tabs: Subject requests, Consents, Records of processing, Breaches, Policies, Sub-processors, Automated decisions, Settings)"] Help["Help: Help for this page, Documentation"] ``` --- ## Portal Access ### Who Can Access the Portal? Users with the organization admin role (the system role named **Tenant Admin** in the portal) can access the full portal. Anyone else who holds at least one admin-plane system permission (for example only `users.view`, or only `jit.approve`) can open the portal too, but sees only what they may use: - the sidebar hides every entry that the user holds no permission for; - each page and action checks its own permission (`users.view` to list users, `users.edit` to save one, `billing.manage` for billing, `gdpr.manage` for privacy requests, and so on) and answers `403` otherwise; - `settings.manage` covers authentication and security settings, API keys, custom domains and integrations, not the whole portal. See [How the admin permissions work](https://docs.lumoauth.dev/user-guide/access-control/roles-permissions#how-the-admin-permissions-work). ### Logging In Organization admins log in through the standard login page at `/orgs/{orgId}/login` and are redirected to the portal after authentication. ![The hosted login page with the email field](https://docs.lumoauth.dev/img/portal/getting-started/hosted-login.png) --- ## Related Guides - [Organization Setup](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-setup) - Create and configure organizations - [Custom Domains](https://docs.lumoauth.dev/user-guide/multi-tenancy/custom-domains) - Use your own domain - [Access Control Overview](https://docs.lumoauth.dev/user-guide/access-control/overview) - Manage roles and permissions --- # Custom Domains Source: https://docs.lumoauth.dev/user-guide/multi-tenancy/custom-domains Custom domains let you serve an organization's authentication pages under your own domain instead of the default /orgs/{orgId}/ path. Users then see https://auth.acme.com/login rather than the default URL. Custom domains let you serve an organization's authentication pages under your own domain instead of the default `/orgs/{orgId}/` path. Users then see `https://auth.acme.com/login` rather than the default URL. --- ## How It Works | Without Custom Domain | With Custom Domain | |-----------------------|-------------------| | `https://lumoauth.example.com/orgs/acme-corp/login` | `https://auth.acme.com/login` | | `https://lumoauth.example.com/orgs/acme-corp/api/v1/oauth/authorize` | `https://auth.acme.com/api/v1/oauth/authorize` | | `https://lumoauth.example.com/orgs/acme-corp/api/v1/.well-known/openid-configuration` | `https://auth.acme.com/api/v1/.well-known/openid-configuration` | When a custom domain is configured, the `/orgs/{orgId}` prefix is no longer needed. LumoAuth automatically maps requests to the correct organization based on the domain. On the custom domain the issuer is the bare domain, `https://auth.acme.com`. Standard clients therefore look for the discovery documents at the root of the domain, and LumoAuth serves them there: `https://auth.acme.com/.well-known/openid-configuration` and `https://auth.acme.com/.well-known/oauth-authorization-server`. --- ## Setting Up a Custom Domain ### 1. Add the Domain in LumoAuth 1. Go to **Settings → Custom domains** (`/orgs/{orgId}/portal/custom-domains`) 2. Click **Add domain** 3. Enter your **Domain name** (e.g., `auth.acme.com`), with no `http://` and no trailing slash 4. Pick a **Verification method**: **TXT record (recommended)**, which does not affect how the domain resolves, or **CNAME record** 5. Click **Add domain** ![The Custom domains page with the Add domain button](https://docs.lumoauth.dev/img/portal/getting-started/custom-domains.png) ![The Add domain dialog with the domain name and verification method](https://docs.lumoauth.dev/img/portal/getting-started/custom-domain-add.png) Custom domains are a paid-plan feature; see the [pricing matrix](https://docs.lumoauth.dev/pricing-matrix). ### 2. Configure DNS Open **DNS setup** on the new domain's card and add the TXT or CNAME verification record that it shows at your DNS provider. Then add a CNAME record pointing your domain to your LumoAuth instance so traffic reaches it: ``` auth.acme.com CNAME lumoauth.example.com ``` Or for an apex domain, use an A record pointing to the IP address of your LumoAuth server. ### 3. TLS Certificate LumoAuth handles TLS certificates for custom domains. After DNS propagation, the certificate is provisioned automatically. ### 4. Verify the Domain Once DNS is configured, choose **Verify now** from the domain's actions menu on the Custom domains page. LumoAuth confirms the verification record and activates the custom domain. --- ## OIDC Discovery with Custom Domains When a custom domain is active, the OIDC discovery document updates automatically: **Before:** ```json { "issuer": "https://lumoauth.example.com/orgs/acme-corp/api/v1", "authorization_endpoint": "https://lumoauth.example.com/orgs/acme-corp/api/v1/oauth/authorize", "token_endpoint": "https://lumoauth.example.com/orgs/acme-corp/api/v1/oauth/token" } ``` **After:** ```json { "issuer": "https://auth.acme.com", "authorization_endpoint": "https://auth.acme.com/api/v1/oauth/authorize", "token_endpoint": "https://auth.acme.com/api/v1/oauth/token", "jwks_uri": "https://auth.acme.com/api/v1/.well-known/jwks.json" } ``` The endpoints keep their `/api/v1/...` paths; only the `/orgs/{orgId}` prefix disappears. The `issuer` is the **bare domain** with no path. ### Issuer and token validation Every token minted for a request that arrived on the custom domain carries `"iss": "https://auth.acme.com"`; every token minted on the organization path carries `"iss": "https://lumoauth.example.com/orgs/acme-corp/api/v1"`. The `iss` parameter on authorization responses and the `iss` in introspection responses follow the same rule. In short: **the issuer is whatever the discovery document of the entry point that you use says.** 1. Pick one entry point per application (the custom domain, once verified). 2. Fetch its discovery document at startup and pass `issuer` and `jwks_uri` to your OIDC library. 3. Never mix entry points inside one application. A code obtained on `auth.acme.com` and redeemed on the `/orgs/acme-corp/` path yields a token whose `iss` your app will reject. That rejection is the correct outcome under the issuer identifier check. Tokens minted before 16 September 2026 carried `https://lumoauth.example.com/orgs/acme-corp` (no `/api/v1`). LumoAuth's own verifiers accept that legacy value until those tokens expire; new tokens never carry it. **Info: Standard** The issuer identifier in the response and [OpenID Connect Core 3.1.3.7](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation) require the client to compare `iss` with the discovery `issuer` byte-for-byte. To comply after enabling a custom domain, update your application's configured issuer to the bare domain (or, better, read it from discovery). See the [integrator checklist](https://docs.lumoauth.dev/oauth/security). --- ## Multiple Custom Domains An organization can have multiple custom domains, but only one is designated as the **primary domain**. For requests that arrive on a verified domain, the issuer is that domain. The primary domain is the issuer that LumoAuth uses when it mints a token outside of a request (background jobs, CLI commands). For that reason, keep your applications on the primary domain. | Use Case | Primary Domain | Additional Domain | |----------|---------------|-------------------| | Login portal | `auth.acme.com` | - | | Partner portal | `auth.acme.com` | `login.partner.acme.com` | --- ## Managing Custom Domains Navigate to `/orgs/{orgId}/portal/custom-domains`: - **Add domain** - Register a new custom domain - **Verify now** - Confirm DNS configuration - **Set primary** - Designate the primary domain for token issuance - **Enable** / **Disable** - Turn a domain on or off without removing it - **Remove** - Delete a custom domain's mapping All except **Add domain** are in each domain's actions menu. Custom domains are managed only through the portal — there is no public REST API for them. The portal actions map to the following endpoints (all require an authenticated organization-admin session, not a Bearer token): | Action | Method & Path | |--------|---------------| | List domains | `GET /orgs/{orgId}/portal/custom-domains` | | Add a domain | `POST /orgs/{orgId}/portal/custom-domains/add` | | View DNS instructions | `GET /orgs/{orgId}/portal/custom-domains/{domainId}/dns-instructions` | | Verify a domain | `POST /orgs/{orgId}/portal/custom-domains/{domainId}/verify` | | Set primary domain | `POST /orgs/{orgId}/portal/custom-domains/{domainId}/set-primary` | | Enable / disable a domain | `POST /orgs/{orgId}/portal/custom-domains/{domainId}/toggle` | | Remove a domain | `POST /orgs/{orgId}/portal/custom-domains/{domainId}/delete` | --- ## Troubleshooting | Issue | Solution | |-------|----------| | Domain verification fails | Confirm the TXT or CNAME verification record is correctly set and propagated (`dig auth.acme.com CNAME`) | | TLS certificate not provisioned | Wait for DNS propagation (up to 48 hours), then re-verify | | OIDC discovery returns old URLs | Clear any caches; the discovery endpoint updates immediately on the server | | Token validation fails after domain change | Update your applications to the new issuer, which is the bare domain (`https://auth.acme.com`), or read it from discovery | | `iss` mismatch reported by your OIDC library | Your app started the flow on one entry point and validates against another. Use one entry point per application. | --- ## Related Guides - [Organization Setup](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-setup) - Create and configure organizations - [Organization Portal](https://docs.lumoauth.dev/user-guide/multi-tenancy/tenant-portal) - Navigate the admin portal - [Applications](https://docs.lumoauth.dev/user-guide/applications/overview) - Update application URLs after domain changes - [Integrator checklist](https://docs.lumoauth.dev/oauth/security) - Issuer validation and redirect URI rules ## Standards used on this page | Plain name | Standard | |---|---| | Issuer identifier in the response | RFC 9207 | | OpenID Connect (OIDC) | OpenID Connect Core 1.0 | See the [standards reference](https://docs.lumoauth.dev/reference/standards) for what each one covers and how LumoAuth uses it. --- # Login Page & Branding Source: https://docs.lumoauth.dev/user-guide/multi-tenancy/branding Customize the login page for each organization — logo, brand colors, background, layout, custom CSS, custom HTML, and AI-generated themes. Branding is set per organization, and each OAuth application can override it. Customize the login page for each organization — logo, brand colors, background, layout, custom CSS, custom HTML, and AI-generated themes. Branding is set per organization, and each OAuth application can override it. --- ## Accessing Login Page Settings Go to **Settings → Login page & emails → Login page** (`/orgs/{orgId}/portal/settings/login-page`) in your organization portal. The page is one form split into sections (**General**, **Layout**, **Background**, **Branding & content**, **Style variants**, **Footer & links**, **Other pages**, **Advanced**) with a **Live preview** beside it. Under **General**, turn on **Enable custom login page**. While it is off, the default LumoAuth styling is used and none of the settings below (including custom CSS and layouts) are applied to any page. **Enable self registration** controls whether the "Create one" link is shown, whether or not the custom page is on. **Theme preference** picks automatic, light or dark; choosing light or dark fixes the theme for every visitor and removes the theme toggle from your sign-in pages. Click **Save changes** to apply, and use **Open login page** at the top to see the result on the real page. The settings apply to the whole sign-in journey of your organization: sign-in, password reset, email verification and MFA. Registration and the OAuth consent and device pages follow their own switches under **Other pages**. ![The Login page settings with the General section and the live preview](https://docs.lumoauth.dev/img/portal/getting-started/login-page.png) --- ## Basic Branding ### Logo In **Branding & content**, enter the `https://` address of your logo in **Logo URL** to display it at the top of the login page. Recommended format: PNG or SVG, transparent background, minimum 200px wide. **Favicon URL** sets the icon shown in the browser tab for your sign-in pages. Image and link fields accept `https://` (or `http://`) addresses only; a small inline `data:` PNG, JPEG, GIF or WebP also works for images. Anything else is refused when you save and the page tells you which field was not stored. ### Welcome Text Set **Welcome title** to replace the default "Welcome back" heading shown above the login form — for example, *"Welcome to Acme Corp"*. Set **Welcome subtitle** to replace the line beneath it. **Button text** changes the label of the sign-in button. The title and button text are plain text; the subtitle and footer text keep bold, italics and links and drop any other HTML. **Show organization name** controls the name next to the mark at the top of the page. **Show org name in subtitle** adds "to continue to *your organization*" under the title when you have not set a subtitle of your own. ![The Branding & content section with the logo URL, button color, button text and welcome fields](https://docs.lumoauth.dev/img/portal/getting-started/login-page-branding.png) ### Footer Text In **Footer & links**, add a **Custom footer text** message, such as contact information or a support link, and optionally a **Terms of service URL** and **Privacy policy URL**. --- ## Colors & Background | Setting | Description | |---------|-------------| | **Button color** (Branding & content) | Used for the sign-in button, links and focus rings. The button label switches between white and near-black automatically so it stays readable on your colour. | | **Background type** (Background) | Choose between **Solid color**, **Gradient**, **Image**, or **Pattern only** | | **Show geometric pattern overlay** (Background) | Adds a subtle pattern on top of the background, in the **Pattern style** that you pick | For a gradient background, pick one of the preset swatches or enter a `linear-gradient()`, `radial-gradient()` or `conic-gradient()` value. For an image background, **Overlay opacity** darkens the image so text stays readable. Text placed directly on your background (the header, the footer, the line under the form) follows the background's lightness, not the visitor's theme. A solid background left at the default white keeps the standard light and dark grounds. ![The Layout and Background sections of the Login page settings](https://docs.lumoauth.dev/img/portal/getting-started/login-page-layout-background.png) --- ## Layout Options Under **Layout**, pick a **Page layout** to control how the login form is positioned on the page: **Centered**, **Split left** (form left, panel right) or **Split right** (panel left, form right). In the split layouts the panel shows your background, or the **Panel image**, with the **Panel title** and **Panel subtitle** over it. On phones the panel is hidden and the form takes the full width. **Style variants** sets the shape of inputs (rounded, square, pill), buttons (filled, outline, pill) and the form card (card, flat, glass). --- ## Per-Application Branding You can configure separate branding for individual OAuth applications: 1. Go to `/orgs/{orgId}/portal/applications/oidc/{clientId}` 2. Open the **Branding** tab 3. In the **Login page** section, turn on **Enable custom login page** and override the organization-level logo, background and button colours, button text and custom CSS for this specific application. Everything that the application does not set (layout, background type, footer and so on) comes from the organization's settings. 4. Click **Save login page** ![The Branding tab of an application with the per-application login page settings and live preview](https://docs.lumoauth.dev/img/portal/getting-started/application-branding.png) This is useful if you serve multiple products under one organization but want each to have its own branded login experience. --- ## AI Theme Generation LumoAuth can automatically generate a matching color scheme and visual style by analyzing your website. 1. Go to `/orgs/{orgId}/portal/settings/login-page` 2. Click **Generate theme with AI** at the top of the page (the button is shown only when an AI provider is configured) 3. Enter your website URL and click **Generate** 4. LumoAuth analyzes your site's colors, fonts, and style, then suggests a matching theme for the login page 5. Preview the result and apply it or adjust individual settings **Note** AI theme generation requires a configured AI provider and a plan that includes it (Pro or higher). The feature uses your website's publicly accessible home page — no login or access is needed. --- ## Custom CSS Add your own stylesheet in the **Custom CSS** tab of the **Advanced customization** section and click **Save changes**. It is loaded after the settings above, on your organization's sign-in pages only, so a plain class selector is enough to override them: ```css /* A rounder card with a brand-coloured border */ .lumo-card { border-radius: 20px; border: 2px solid #16a34a; } .lumo-auth-title { font-size: 2rem; } .lumo-button-primary { letter-spacing: 0.02em; } @media (max-width: 480px) { .lumo-auth-footer { font-size: 11px; } } ``` Applying custom CSS requires the Pro plan or higher; on other plans the section shows an upgrade notice and the stylesheet is not served. ![The Advanced customization section with the Custom CSS and Custom layout (HTML) tabs](https://docs.lumoauth.dev/img/portal/getting-started/login-page-advanced.png) ### Selectors you can rely on | Selector | Element | |----------|---------| | `.lumo-auth` | The page. Carries `data-auth-layout`, `data-auth-input`, `data-auth-button` and `data-auth-card` with your chosen variants. | | `.lumo-auth-header`, `.lumo-auth-main`, `.lumo-auth-footer` | Top bar, the area around the form, and the footer | | `.lumo-auth-panel`, `.lumo-auth-panel-title`, `.lumo-auth-panel-subtitle` | The side panel of the split layouts | | `.lumo-auth-logo`, `.lumo-auth-title`, `.lumo-auth-subtitle`, `.lumo-auth-signup` | Logo, heading, the line under it, and the "Create one" line | | `.lumo-card` | The form card | | `.lumo-input` | Text fields | | `.lumo-button`, `.lumo-button-primary`, `.lumo-button-secondary` | Buttons | Other class names on the page are implementation details and can change between releases. ### What the CSS filter allows Your stylesheet is served from the origin shared by every organization, so LumoAuth does not try to detect dangerous CSS: it parses the stylesheet and rebuilds it from the parts that it recognises. Anything else is dropped. The same filter runs on the portal form, the Admin API and AI-generated themes, and again every time the stylesheet is served. | Kept | Details | |------|---------| | Style rules | Class, element and attribute selectors, combinators, the common pseudo-classes and pseudo-elements (`:hover`, `:focus-visible`, `:not()`, `:is()`, `:has()`, `:nth-child()`, `::before`, …) and nesting | | `@media`, `@supports`, `@container`, `@layer`, `@keyframes` | With their nested rules | | Declarations | Standard and custom (`--name`) properties; colour, gradient, transform, filter, shape, timing and math functions; `var()`, `calc()`, `attr()`, `env()` | | Inline images | `url()` with a `data:` PNG, JPEG, GIF or WebP only | | Dropped | Reason | |---------|--------| | `@import`, `@font-face`, `@namespace` and every other at-rule | Loads from another site or changes how the page is parsed | | `url()` with any address (`https://`, `//`, relative paths), `image-set()`, `image()`, `src()`, `cross-fade()`, `element()` | A request that the page would make. Attribute selectors plus per-character background images are a known way to read what a user types. | | `data:` URLs other than raster images | SVG and non-image data URLs can carry script | | `expression()`, `behavior`, `-moz-binding`, `javascript:` | Script execution in old browsers | | Functions that are not on the allow-list, and backslash escapes outside quoted strings | An escape such as `\75rl(` could otherwise hide a blocked function | | HTML (``, `