Authagonal

Documentation

Everything you need to get started with Authagonal — from creating your first tenant to wiring SSO, SCIM, and custom branding.

Getting Started

Authagonal gives each tenant a fully standards-compliant OIDC server. Every tenant gets its own issuer URL, discovery document, and token endpoints — no shared infrastructure between tenants. You can go from zero to a working login flow in under 5 minutes.

Create an Account

Sign up at authagonal.io and choose a slug for your account. The slug becomes your issuer domain: {slug}.authagonal.io. After creating your account, verify your email address to get started.

Authagonal signup page showing tenant slug input and email verification

Choose a unique slug for your account during signup

Register a Client

Navigate to Clients in the portal sidebar and click New Client. Enter a clientId and clientName for your application. Then, on the client's URIs tab, add at least one redirect URI; this is where users are sent after authenticating. For example: https://app.example.com/callback. New clients require a client secret by default, so for a browser app without a backend turn off Require Secret on the General tab.

New client form with Client ID and Client Name fields

Register a new OAuth client in the portal

Local Development

Use http://localhost:3000/callback as a redirect URI for local development. Authagonal allows non-HTTPS redirect URIs for localhost origins.

Your First Login

The fastest way to integrate is with oidc-client-ts, a lightweight OIDC client library for JavaScript and TypeScript applications.

oidc-client-ts integration
import { UserManager } from 'oidc-client-ts';

const mgr = new UserManager({
  authority: 'https://acme.authagonal.io',
  client_id: 'my-app',
  redirect_uri: 'https://app.example.com/callback',
  response_type: 'code',
  scope: 'openid profile email',
});

// Redirect to login
mgr.signinRedirect();

// On callback page
const user = await mgr.signinRedirectCallback();
console.log(user.profile); // { sub, email, name, ... }

If you prefer a minimal approach without a library, you can use the standard OAuth 2.0 authorization code flow with plain fetch:

Minimal fetch-based flow
// 1. Redirect the user to the authorization endpoint
const authorizeUrl = new URL('https://acme.authagonal.io/connect/authorize');
authorizeUrl.searchParams.set('client_id', 'my-app');
authorizeUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('scope', 'openid profile email');
authorizeUrl.searchParams.set('code_challenge', codeChallenge);
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authorizeUrl.toString();

// 2. On the callback page, exchange the code for tokens
const res = await fetch('https://acme.authagonal.io/connect/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: new URLSearchParams(window.location.search).get('code')!,
    redirect_uri: 'https://app.example.com/callback',
    client_id: 'my-app',
    code_verifier: codeVerifier,
  }),
});

const tokens = await res.json();
// tokens.id_token, tokens.access_token (refresh_token only when offline_access is requested and allowed)
Authagonal login page with email and password fields, branded with tenant logo

The default login page for your tenant

Sandbox Mode

Test your integration in a sandbox environment first. Create one under Environments in the portal: each sandbox gets its own URL ({env}-{slug}.authagonal.io, e.g. test1-acme.authagonal.io) and can be reset to empty at any time without affecting live users.

Dashboard

The portal dashboard gives you a real-time overview of your tenant. It surfaces the metrics that matter most — user growth, authentication activity, and quick navigation to every feature in the portal.

Overview

At the top of the dashboard you will see a welcome message and an Open login page link to your tenant's hosted login. Below the stat cards, a Monthly active users meter tracks usage against your plan limit and links to your plan options once you pass 80%. A Sign-in activity chart and a Recent activity feed of the latest audit entries complete the page.

Full dashboard view showing the welcome banner, stat cards, monthly active users meter, and sign-in activity chart

The dashboard home screen with stat cards and sign-in activity

Activity Metrics

Six stat cards summarize your tenant at a glance:

  • Active Users: the total number of users in your tenant
  • Sign-ins (24h): successful sign-ins in the last 24 hours
  • MFA Enrolled: the percentage of users with MFA enrolled, with the enrolled and total counts
  • Failed Attempts (24h): failed sign-ins in the last 24 hours, such as incorrect credentials, locked accounts, or policy rejections
  • SCIM Sync: provisioning activity from connected IdPs, shown as Idle or as an operation count
  • Monthly Spend: spend so far this month, with a projected end-of-month figure

The 24-hour cards compare the last 24 hours with the 24 hours before. The Sign-in activity chart plots successful and failed sign-ins per day over 14, 30, or 90 days. The dashboard refreshes every minute.

Six stat cards showing active users, sign-ins, MFA enrolment, failed attempts, SCIM sync, and monthly spend

Stat cards summarizing the last 24 hours

Quick Navigation

Below the metrics, navigation cards link directly to Clients, SSO, Users, SCIM, Branding, Settings, Billing, Domains, and Audit Log. Each card shows a brief description so new team members can orient themselves quickly.

Clients

OAuth clients represent the applications that authenticate users through your tenant. Each client has its own configuration for redirect URIs, scopes, grant types, token lifetimes, and MFA policy.

Client List

The Clients page displays a table of all registered clients. Each row shows the clientId, display name, allowed grant types as colored badges, and whether PKCE is enabled. Click any row to open the full configuration editor.

Clients table showing clientId, name, grant type badges, and PKCE status for each registered application

Client list with grant type badges and PKCE indicators

Creating a Client

Click New Client to register a new application. You need to provide two fields:

  • clientId — a unique identifier for the client (e.g. my-spa)
  • clientName — a human-readable display name
New client form with Client ID and Client Name input fields

Register a new OAuth client

Deleting a Client

To delete a client, click the trash icon on its row in the clients table and type the client ID to confirm. The client is removed permanently and can no longer sign users in or refresh tokens. Access tokens already issued are not revoked and stay valid until they expire.

Client Configuration Reference

Each client has a comprehensive set of configuration options organized into five tabs: General, URIs, Scopes & Grants, Tokens, and Security.

General Settings

SettingDescriptionDefault
clientNameDisplay name shown in consent screens and the portal–
requirePkceRequire Proof Key for Code Exchange on authorization code flowsOn
requireClientSecretRequire a client secret for token requests (disable for public clients like SPAs)On
allowOfflineAccessAllow the client to request refresh tokens via the offline_access scopeOff
alwaysIncludeUserClaimsInIdTokenInclude profile, email, role, and group claims in the ID token even when the matching scopes were not requestedOff
includeGroupsInTokensInclude the names of the user's SCIM groups as a groups claim, in tokens issued when the groups scope is requestedOff

PKCE Security

Disabling PKCE reduces security for authorization code flows. Only disable this for legacy clients that do not support PKCE. All modern applications should leave PKCE enabled.

URIs

URI fields use a tag input — type a value and press Enter or comma to add it. Click the X on any tag to remove it.

SettingDescription
redirectUrisAllowed callback URLs after authentication. Must exactly match the redirect_uri parameter in authorization requests.
postLogoutRedirectUrisAllowed URLs to redirect to after logout.
allowedCorsOriginsOrigins permitted for cross-origin requests to the token and UserInfo endpoints.
URI configuration section showing tag inputs for redirect URIs, post-logout URIs, and CORS origins

Tag input fields for configuring URIs

Scopes & Grant Types

SettingOptions
allowedScopesopenid profile email offline_access phone roles groups
allowedGrantTypesauthorization_code client_credentials refresh_token urn:ietf:params:oauth:grant-type:device_code urn:ietf:params:oauth:grant-type:token-exchange

Token Lifetimes

SettingDescriptionDefault
accessTokenLifetimeSecondsHow long access tokens are valid1800 (30 min)
identityTokenLifetimeSecondsHow long ID tokens are valid300 (5 min)
authorizationCodeLifetimeSecondsHow long authorization codes are valid for exchange300 (5 min)
absoluteRefreshTokenLifetimeSecondsMaximum lifetime of a refresh token regardless of activity2592000 (30 days)
slidingRefreshTokenLifetimeSecondsRefresh token expiry resets on each use, up to the absolute lifetime1296000 (15 days)
Token lifetime configuration fields with numeric inputs for each lifetime setting

Configure token lifetimes per client

Logout URIs

Clients can register both back-channel and front-channel logout URIs. Either or both are optional — configure whichever matches how your application clears its session.

SettingDescription
backChannelLogoutUriServer-to-server POST with a signed logout token. Reliable even when the user's browser is offline.
frontChannelLogoutUriRendered in a hidden iframe during logout so the browser clears cookies and local storage.
frontChannelLogoutSessionRequiredWhen on, the logout URL receives iss and sid query parameters so your app can correlate the logout with the specific session.

Use both together

Back-channel guarantees the server is notified; front-channel clears the browser. Most apps benefit from configuring both.

MFA Policy

Each client has its own MFA policy, set on the client's <strong>Security</strong> tab. The MFA policy dropdown offers three options:

PolicyBehavior
DisabledMFA is never prompted for this client
EnabledUsers can optionally enroll in MFA; they will be prompted if enrolled
RequiredAll users must complete MFA to authenticate through this client
MFA policy dropdown showing Disabled, Enabled, and Required options on the client configuration page

Per-client MFA policy

Enterprise SSO

Enterprise SSO lets your customers bring their own identity provider. Authagonal supports both SAML 2.0 and OIDC federation with domain-based routing, so users are automatically directed to the correct IdP based on their email address.

SSO domain routing flow: shows how email domains are matched to SAML or OIDC identity providers

Domain-based SSO routing

SAML 2.0 Connections

To create a SAML connection, navigate to the SSO page and select the SAML tab. Provide the following:

FieldDescription
connectionNameA human-readable name for this connection (e.g. "Acme Corp Okta")
entityIdYour SP entity ID. Register this exact value at your IdP as the application's Identifier (Entity ID); assertions must name it as the Audience
metadataLocationURL to the IdP's SAML metadata XML document
metadataXmlPasted IdP metadata XML, for IdPs without a metadata URL (Google Workspace) or whose URL is not internet-reachable. Provide either this or metadataLocation, not both
nameIdFormatOptional NameID format requested from the IdP. Omit for the emailAddress default, or set "none" to omit NameIDPolicy entirely (recommended for ADFS)
allowedDomainsEmail domains routed to this connection (e.g. acme.com). Set through the API; the portal shows them on the connection card and on the Domain Routing tab

When you save the connection, Authagonal fetches the metadata document and imports the IdP's signing certificate, SSO endpoint URL, and name identifier format. The metadata is periodically refreshed to pick up certificate rotations.

SAML connection creation form with fields for connection name, SP entity ID, and IdP metadata

Create a SAML 2.0 SSO connection

OIDC Connections

To create an OIDC federation connection, select the OIDC tab and provide:

FieldDescription
connectionNameA human-readable name for this connection
metadataLocationThe OpenID Connect discovery URL (e.g. https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration)
clientIdThe client ID registered with the external IdP for this federation
clientSecretThe client secret for the external IdP registration
allowedDomainsEmail domains routed to this connection (e.g. acme.com). Set through the API; the portal shows them on the connection card and on the Domain Routing tab
OIDC connection creation form with fields for connection name, discovery URL, client ID, and client secret

Create an OIDC federation connection

Domain Routing

Domain routing automatically redirects users to the correct identity provider based on their email domain. When a user enters their email on the login page, Authagonal checks if the domain portion (e.g. acme.com) matches the allowedDomains of any SSO connection. If it does, the user is seamlessly redirected to their organization's IdP.

Email DomainSSO ProviderProtocol
acme.comAcme Corp OktaSAML 2.0
contoso.comContoso Azure ADOIDC
example.orgExample OneLoginSAML 2.0
Domain routing table showing email domains mapped to SSO connections with protocol type

Domain routing maps email domains to identity providers

SP-Initiated Flow

SP-initiated flow is the default — users start at your login page and are routed to the correct IdP automatically. Users can also be deep-linked directly to a specific connection via /saml/{connectionId}/login or /oidc/{connectionId}/login.

JIT Provisioning

When a user signs in via SSO for the first time and doesn't already exist in your tenant, Authagonal can create their account automatically (Just-In-Time provisioning). JIT provisioning is off by default: turn it on per connection by checking Enable JIT provisioning when creating the connection.

When JIT provisioning is disabled, only users who have been pre-provisioned — via SCIM, the portal's Users page, or the API — can sign in through that connection. Unknown users receive an access_denied error and are directed to contact their administrator.

Per-Connection Setting

JIT provisioning is controlled per SSO connection, not tenant-wide. You can have one connection that allows JIT (e.g. for a partner org that manages their own users) and another that requires pre-provisioning (e.g. for an enterprise customer using SCIM sync).

Test Before Rollout

Test SSO connections with sandbox mode before rolling out to production users. This lets you verify the IdP configuration, attribute mapping, and domain routing without affecting live authentication flows.

Organisation-Scoped Connections

A connection can also belong to one organisation within your tenant instead of the whole tenant. It's offered only once that organisation has already been resolved — by a custom domain pinned to it, an organization parameter on the sign-in link, or a client registered to that one organisation — never on a tenant-wide login page. Which of those wins is checked in that order, highest first.

Signing In Creates Membership

A user who signs in through an organisation-scoped connection automatically becomes a member of that organisation, the same way JIT provisioning creates their account — there's no separate invite step.

Domain Uniqueness Is Per Scope

An email domain can be claimed once tenant-wide and, independently, once more inside each organisation — acme.com can route to a tenant-wide connection and also, once an organisation is already selected, to that organisation's own connection. What it can't do is belong to two connections in the same scope; Authagonal rejects that when you save the connection.

Users

The Users page lets you manage all end users in your tenant. You can search for users, view their details, invite new users, and see how each user was provisioned.

The search bar matches an exact user ID or email, or a prefix of the email, first name, or last name, and the All, Active, and Inactive filters narrow the list by status. Search is debounced at 300ms so results update as you type without overwhelming the API. Results are paginated at 50 users per page; use the navigation controls at the bottom of the table to move between pages.

User Table

The user table displays the following columns for each user:

ColumnDescription
UserThe user's name (or email when no name is set), with their email beneath and an Unverified badge until the email is confirmed
StatusActive or Inactive — indicates whether the account is enabled
SourceSCIM or Local — how the user was created
RolesThe roles assigned to the user
MFAEnabled when multi-factor authentication is enrolled, otherwise a dash
CreatedThe date the user account was created
User list table with columns for user, status, source, roles, MFA, and created date

The user list with search bar and pagination

Inviting Users

Click Invite user to invite someone to your tenant. They receive an email to set their own password. The form takes:

FieldDescription
emailThe user's email address (must be unique within the tenant)
firstNameThe user's first name
lastNameThe user's last name
localePreferred language. Sets the user's UI and email language; optional, falls back to English.
organizationIdOptional organization to add the user to, with their roles in it. Shown when your tenant has organizations
Invite user form with email, first name, last name, and Language fields

Invite a new user

SCIM-Provisioned Users

Users created via SCIM are marked with a "SCIM" badge in the Source column. Their lifecycle (creation, updates, and deactivation) is managed by the upstream identity provider.

Preferred Language

Every user has a preferred language that drives both their hosted UI and the transactional emails Authagonal sends them (verification, password reset, welcome, and more). You can set it when inviting a user and change it any time from the user's detail page. If a user has no preferred language set, Authagonal falls back to English. The selector offers all supported locales: English, Chinese (Simplified), German, French, Spanish, Vietnamese, Portuguese, Japanese, Arabic, Hindi, and Afrikaans.

User detail edit form with profile fields including a preferred Language selector

Set a user's preferred language on the detail page

User Detail

Click any row in the user list to open its detail page. From there you can edit profile data, manage roles, reset MFA, review custom attributes, and delete the user.

User detail page showing profile fields, status, assigned roles, and custom attributes

Profile

Edit email, first/last name, phone, company, language, external ID, and toggle the user's active flag. Changing the email or the email-confirmed flag requires an admin or owner. Email changes must remain unique across the tenant; the API returns email_already_in_use if taken.

Roles

Assign and unassign roles defined on the Roles page. Assigned roles are emitted as the roles claim in ID and access tokens when the client requests the roles scope.

Multi-Factor Authentication

See every MFA credential registered for the user (authenticator app (TOTP), WebAuthn/passkeys, and recovery codes), each with its own registered/last-used timestamps. Remove individual credentials, or reset all MFA; both require an admin or owner. Resetting forces the user to re-enroll at next login.

Custom Attributes

Arbitrary key/value data attached to the user. Keys must be unique. Attributes are exposed via the user profile API and SCIM, and can be mapped to access-token claims by configuring a custom scope's userClaims.

Organization

The organization this user belongs to. It is a free-text identifier of your own, emitted as the org_id claim on their tokens and on /connect/userinfo under the profile scope. Authagonal never derives it: it is set by your provisioning app, by the SCIM credential that created the user, or by hand here.

The link beside the field lists everyone sharing it, and the same filter is available on the API as GET /api/v1/users?organizationId=. Use it to answer who belongs to a given customer without paging the whole directory.

Delete User

Permanently removes the user and all their MFA credentials. Type the user's email to confirm — there is no undo.

Groups

Groups let you organize users and include group membership in tokens. Groups can be created manually in the portal or provisioned automatically via SCIM from an external identity provider.

Group List

Groups are listed on the Groups tab of the SCIM page, with the following information:

ColumnDescription
Group NameThe display name of the group
MembersThe number of users currently in the group
SourceSCIM or Manual — how the group was created
CreatedThe date the group was created
Groups list table showing group name, member count, source badge, granted roles, and created date

Group list with source indicators

Creating a Group

Click New Group and enter a Display Name for the group. Group names should be descriptive and unique within your tenant (e.g. "Engineering", "Billing Admins", "Beta Testers").

Group Detail & Members

Click any group to open the detail view. Here you can see all current members and manage membership:

  • Add members: search for a user by email or name and add them to the group.
  • Remove members: click the remove button next to any member and confirm. Any roles granted through the group are revoked.
Group detail view showing the member list, a user search to add members, and the roles the group grants

Manage group membership in the detail view

Groups in Tokens

When Groups in Tokens is enabled on a client and the groups scope is requested, issued tokens include a groups claim listing the display names of the user's groups:

groups claim
{
  "sub": "user-123",
  "email": "[email protected]",
  "groups": ["Engineering", "Beta Testers"]
}

Enable per Client

The Groups in Tokens setting is configured on each client individually, on the client's General tab. The client must also allow the groups scope.

Roles

Roles support role-based access control (RBAC) in your application. Define roles in Authagonal, assign them to users, and use the roles claim in tokens to enforce authorization in your application logic.

Managing Roles

The Roles page displays a table of all defined roles with inline editing. Each role has:

ColumnDescription
NameA unique identifier for the role (e.g. "admin", "editor", "viewer")
DescriptionA human-readable description of what the role grants
CreatedThe date the role was created

Creating a Role

Click New Role and provide a name and description. Role names should be concise and follow a consistent naming convention across your application (e.g. lowercase with hyphens: billing-admin).

Inline Editing

Roles support inline editing directly in the table. Click the pencil icon on any role to enter edit mode — the name and description fields become editable. Modify the values, then click the checkmark icon to save. Changes take effect immediately.

Deleting a Role

Click the delete icon on any role to remove it. You will be asked to confirm before the role is permanently deleted. Removing a role does not retroactively invalidate existing tokens — the role will be absent from new tokens issued after deletion.

Roles table with inline editing active, showing editable name and description fields with save and cancel icons

Inline editing of roles in the roles table

Roles in Tokens

Roles assigned to a user are included as a roles claim in ID and access tokens when the client requests the roles scope. Your application can read this claim to make authorization decisions:

roles claim in ID token
{
  "sub": "user-123",
  "email": "[email protected]",
  "roles": ["admin", "billing-admin"]
}

SCIM Provisioning

SCIM 2.0 (System for Cross-domain Identity Management) enables automatic user and group provisioning from enterprise identity providers like Okta, Azure AD, OneLogin, and JumpCloud. When configured, user accounts and group memberships are automatically synced from the upstream IdP to your Authagonal tenant.

SCIM provisioning flow: shows user lifecycle events flowing from enterprise IdP through Authagonal SCIM API to your app via TCC webhooks

SCIM user lifecycle sync with downstream provisioning

Setup Steps

Follow these steps to enable SCIM provisioning for a client:

  1. Select the client application — Choose the OAuth client that the SCIM provisioning will be associated with.
  2. Generate a SCIM token — Provide a description and an expiry period in days, then generate the token.
  3. Copy the token immediately — The raw token value is displayed only once. Copy it before closing the dialog.
  4. Configure your IdP — In your identity provider's SCIM settings, enter the base URL and bearer token.
  5. Test user sync — Trigger a test sync from your IdP and verify that users appear in the Authagonal portal.

SCIM Base URL

Configure your identity provider with the following base URL:

SCIM Base URL
https://{slug}.authagonal.io/scim/v2

Replace {slug} with your tenant slug.

SCIM configuration page showing client selector, token generation form, and base URL

SCIM setup page with token generation

Token Management

SCIM tokens authenticate provisioning requests from your IdP. You can manage multiple tokens per client:

FieldDescription
DescriptionA label to identify the token (e.g. "Okta Production SCIM")
ExpiryToken lifetime in days (1 to 3650, default 365).
StatusActive tokens are in use. Revoked tokens display a Revoked badge and can no longer authenticate requests.

To revoke a token, click the Revoke button next to it. Revoked tokens remain visible in the list for audit purposes but immediately stop accepting requests.

SCIM token list showing active and revoked tokens with description, expiry date, and revoke button

Token management with active and revoked token indicators

Copy Token Immediately

The raw SCIM token is displayed only once when created. Copy it immediately — it cannot be retrieved later. If you lose the token, you will need to generate a new one and update your IdP configuration.

Testing Connectivity

Verify that your SCIM integration is working by querying the ServiceProviderConfig endpoint:

Test SCIM connectivity
curl -H "Authorization: Bearer YOUR_TOKEN" \
  https://acme.authagonal.io/scim/v2/ServiceProviderConfig

A successful response returns a JSON document describing the supported SCIM features: PATCH and filtering are supported, while bulk operations, password changes, sorting, and ETags are not.

Preferred Language

The SCIM preferredLanguage attribute (falling back to locale) maps to the user's stored language. SSO users provisioned through SCIM automatically receive localised emails in the language their IdP sends.

What a credential can see

A SCIM credential only sees the users and groups it provisioned. Reading, updating or deleting a user another connector created answers 404, listing returns only its own, and a group's membership may only name users the same connector provisioned. Accounts created any other way, by an administrator, by self-service signup or by SSO just-in-time provisioning, are invisible to SCIM entirely.

That boundary is per client, not per token. Two credentials issued against the same client are one identity with two secrets, and each can modify what the other created. Give mutually untrusted connectors a client each. externalId is scoped the same way, so two connectors may each use ext-001 for different people without colliding.

Deprovisioning

DELETE /scim/v2/Users/{id} deactivates the account and marks it deleted. The record is kept, as RFC 7644 allows, but it answers 404 for every subsequent operation and is omitted from listings. Their MFA enrolments and group memberships are purged, their externalId mapping is released, and any issued tokens are revoked, so access ends immediately rather than at the next token expiry.

If the same person is later re-hired and re-created, they get a new user id. Identifiers are never recycled: the id is the subject in every token you have ever issued, so reusing it would silently give the new joiner the previous holder's history at every application that trusts it.

Tagging synced users with an organization

SCIM has no way for a connector to say which of your customers it is syncing. Core SCIM defines no organization attribute, so a plain user create tells you the person's name and email and nothing about who they belong to. If several customers provision into your tenant, their users arrive indistinguishable.

The credential answers it instead of the request. When you create a SCIM token, pick one of your tenant's organizations under Organization (the picker appears once your tenant has organizations). Every user provisioned through that token becomes an active member of that organization and carries it as the org_id claim on their tokens from then on. Issue one credential per customer against the same client, and their synced users come out correctly attributed without a client registration each. Leave it blank and users are untagged.

Tagging is not isolation

Ownership is enforced per client, not per token. Two credentials on the same client are one identity with two secrets: each can read, rename, deactivate and delete the users the other created. That is fine when you hold all the credentials yourself. If each customer's own IT team holds theirs, give them a client each, and set the organization on their credential as well.

The tag is applied when the user is created and never on a later update, so a routine incremental sync cannot silently move an existing account. If you also run a provisioning app, an explicit credential tag wins: a /try response only fills an organization that is still empty.

Supported schemas

We implement the core SCIM 2.0 User and Group schemas (RFC 7643). Supported user attributes are userName, name.givenName, name.familyName, displayName, emails, active, externalId and preferredLanguage / locale.

The enterprise user extension is not implemented, so department, manager, employeeNumber, costCenter, division and organization are accepted and ignored rather than stored, on create, replace and PATCH alike. Entra and Okta both map some of these by default, so you do not need to remove them from your attribute mapping. To attribute a user to one of your customers, set the organization on the SCIM credential instead: the enterprise organization attribute is asserted by your customer's identity provider, and deliberately does not become their org_id.

OAuth Scopes

Scopes let clients request specific slices of a user's data or permissions. Authagonal supports both standard OIDC scopes and custom scopes you define for your APIs.

Built-in Scopes

ScopeDescription
openidRequired for any OpenID Connect flow. Issues an ID token.
profileReturns standard profile claims (name, given_name, family_name, locale, org_name).
emailReturns the user's email address and verification status.
phoneReturns the user's phone number (phone_number).
offline_accessIssues a refresh token alongside the access token.
rolesReleases the user's roles claim. Without this scope, roles are not emitted.
groupsReleases the user's groups claim (SCIM group membership). Without this scope, groups are not emitted.

Custom Scopes

Define your own scopes on the Scopes page. Each scope describes a permission or resource a client can request (for example, billing.read, orders.write).

Custom scope creation form with name, display name, description and User Claims fields
FieldDescription
nameThe scope identifier sent in token requests (e.g. billing.read).
displayNameHuman-readable label shown on the consent screen.
descriptionLonger explanation shown under the display name on consent.
userClaimsExtra claims added to the access token and ID token when this scope is granted.
showInDiscoveryDocumentIf on, the scope appears in /.well-known/openid-configuration.
emphasizeHighlights the scope on the consent screen as sensitive.
requiredPrevents the user from deselecting the scope during consent.
groupConsent group: scopes sharing a heading appear together on the consent screen under one checkbox. Presentation only.
allowedRolesRoles a user must hold to be granted this scope. Empty allows everyone; a user without one of these roles has the scope dropped from their token rather than being refused.

Consent integration

Clients with RequireConsent: true prompt the user on first request. Deleting a scope does not revoke already-issued tokens — revoke them explicitly if needed.

Custom claims on tokens

Custom claims have two halves. The source is per-user data: each AuthUser has a customAttributes dictionary you can populate from the Portal (Users → user → Custom Attributes), via SCIM, or via a TCC provisioning hook. The release is per-scope: each scope's userClaims list names the keys it permits to leave the server.

When a client requests scopes, Authagonal walks the granted scopes, unions their userClaims lists, and emits only those keys from the user's customAttributes. Unknown keys are silently dropped — a client cannot read an attribute by guessing the name. Standard OIDC claims (sub, email, name, etc.) follow the spec and are not subject to the whitelist.

Example: project-scope claims on an access token
# 1. On the user (Portal → Users → {user} → Custom Attributes)
department = "engineering"
employee_id = "E-1042"
seat_tier = "enterprise"

# 2. On a custom scope (Portal → Scopes → projects.read)
name        = "projects.read"
userClaims  = ["department", "seat_tier"]   # <-- whitelist

# 3. Client requests scope=openid projects.read
# Decoded access token (relevant fields only):
{
  "sub": "u-9b…",
  "scope": "openid projects.read",
  "department": "engineering",
  "seat_tier":  "enterprise"
  // employee_id is NOT emitted; it's not in the whitelist for any granted scope.
}

Federation claims fill gaps per session

When a user signs in via an upstream IdP (SAML/OIDC SSO), per-session claims that arrive from the IdP, for example a department attribute mapped from a SAML assertion, flow through the same scope whitelist but only fill gaps: on a key collision the persisted customAttributes value wins. They are emitted on this session's tokens (and survive refresh rotations) without being written back to the user record.

Assigning Scopes to Clients

Add allowed scopes on the Clients → Scopes & Grants tab. A client can only request scopes it has been granted; unknown scopes are rejected with invalid_scope.

Branding

Customize the look and feel of your tenant's login pages. Branding settings let you match the authentication experience to your product's visual identity — from logos and colors to advanced CSS overrides.

Appearance

SettingDescription
appNameThe application name displayed in the login page header (when no logo is set) and in transactional emails
logoUrlURL to your logo image. Displayed at the top of the login page. Recommended size: 200x60px or similar aspect ratio.
primaryColorThe primary brand color used for buttons, links, and focus states. Set via a color picker or hex input. A live preview updates as you change the value.
customCssUrlURL to a CSS file loaded after the default styles. It must be served from the same origin as the login page; a URL on any other origin is ignored.
Branding appearance settings with app name input, logo URL field, color picker with hex input, and custom CSS URL field

Appearance settings with live color preview

Contact Information

SettingDescription
supportEmailA support email address displayed on login pages. Users see this when they need help with their account.

Login Page Toggles

Control which elements appear on your tenant's login page:

ToggleDescriptionDefault
showForgotPasswordDisplay the "Forgot password?" link on the login formOn
showRegistrationDisplay the "Sign up" link for self-service user registrationOn
poweredByDisplay the "Powered by Authagonal" badge at the bottom of the login pageOn
A customized login page showing a branded logo, custom primary color on the sign-in button, and support email in the footer

Example login page with custom branding applied

Custom CSS

For full control over the login page appearance, provide a CSS file URL in your branding settings. The file is loaded after the default styles, so your rules take precedence. The URL must be on the same origin as the login page; stylesheets from other origins are not loaded.

CSS Custom Properties

The login page supports CSS custom properties (variables) for common overrides. Set these in your CSS file to change colors, fonts, and shape without writing complex selectors.
/* your-custom-styles.css */
:root {
--auth-bg: #1a1a2e;
--auth-card-bg: #16213e;
--auth-heading: #e0e0e0;
--auth-radius: 12px;
--auth-font: 'Inter', sans-serif;
}
VariableDescriptionDefault
--auth-bgPage background color#f3f4f6
--auth-card-bgLogin card backgroundwhite
--auth-headingHeading text color#111827
--auth-radiusCard border radius0.5rem
--auth-fontFont familyinherit

Dark Mode

The login app ships with light, dark, and system themes. The Dark Mode setting on the Branding page chooses the default: off, auto (follows the system preference, the default) or force. Users can still pick from a toggle on the login page; the choice persists across sessions. When set to system, the SPA tracks prefers-color-scheme live.

Light values are declared at :root; dark overrides are scoped to .dark. Tenant branding set via customCssUrl always wins — so your colors persist regardless of the user's theme.

Element Selectors

For more granular control, target specific elements using data-auth attributes. These selectors are stable across updates — they won't break when we change internal class names.
SelectorElement
[data-auth="page"]Full-page background container
[data-auth="header"]Logo and app name area
[data-auth="logo"]Logo image
[data-auth="app-name"]App name heading (when no logo is set)
[data-auth="content"]Main content area (forms, messages)
[data-auth="login-form"]Login form element
[data-auth="email-field"]Email input wrapper
[data-auth="password-field"]Password input wrapper
[data-auth="submit-button"]Sign-in button
[data-auth="languages"]Language selector bar

Settings

Configure tenant-wide security policies, webhooks, and environment settings. These settings apply globally across all clients unless overridden at the client level.

Password Policy

Define the password complexity requirements for all users in your tenant, under Users → Settings:

SettingRangeDefault
minPasswordLength6 – 1288
requireUppercaseOn / OffOn
requireLowercaseOn / OffOn
requireDigitOn / OffOn
requireSpecialCharOn / OffOn
Password policy settings showing minimum length slider and toggle switches for character requirements

Password policy configuration

MFA Policy

The tenant-wide MFA policy, set under Users → Settings, sets the default multi-factor authentication behavior. Individual clients can override this setting.

PolicyBehavior
DisabledMFA is not available. Users cannot enroll in MFA.
EnabledMFA is optional. Users can choose to enroll and will be prompted at login if enrolled.
RequiredMFA is mandatory. All users must enroll in MFA and complete a second factor at every login.

Session & Lockout

Control session duration and account lockout behavior:

SettingRangeDefault
sessionLifetimeMinutes5 – 43,200 (30 days)60
maxFailedAttempts1 – 1005
lockoutDurationMinutes1 – 1,440 (24 hours)10
Session and lockout settings with numeric inputs for session lifetime, max failed attempts, and lockout duration

Session and lockout configuration

Webhooks

Webhooks let you react to authentication events in real time. Two events (onUserAuthenticated, onTokenIssued) are enforceable — by default they fire asynchronously and don't block the user, but you can opt in to enforcement per event so a non-2xx response or {"allow": false} body rejects the action. The remaining events are notifications — always fire-and-forget, never block.

EventTypeDescription
onUserAuthenticatedEnforceableFired after a successful login. Defaults to fire-and-forget so login latency is unaffected. Toggle <code>webhookEnforceUserAuthenticated</code> to make it blocking — a non-2xx response or <code>{"allow": false}</code> body then rejects the login.
onTokenIssuedEnforceableFired before tokens are minted (authorization_code, refresh_token, client_credentials). Defaults to fire-and-forget. Toggle <code>webhookEnforceTokenIssued</code> to make it blocking — a non-2xx response or <code>{"allow": false}</code> body then prevents token issuance.
onUserCreatedNotificationFire-and-forget notification when a new user registers or is provisioned via SCIM.
onUserUpdatedNotificationFire-and-forget notification when a user record is updated (profile changes, role changes, SCIM updates).
onUserDeletedNotificationFire-and-forget notification when a user is deleted, either via Portal/SCIM or by retention policy.
onLoginFailedNotificationFire-and-forget notification when a login attempt fails due to bad credentials, lockout, or policy rejection.

Additional webhook settings:

SettingRangeDefaultDescription
webhookTimeoutSeconds1 – 305Maximum time to wait for an enforcement webhook response before timing out
webhookFailOpenOn / OffOnWhen enabled, if an enforcement webhook is unreachable or times out, the operation is allowed to proceed
Webhook configuration section showing URL inputs for each event type, timeout slider, and fail-open toggle

Webhook event configuration

Enforcement Webhook Availability

Enforcement webhooks can block authentication flows. If your webhook endpoint goes down and webhookFailOpen is disabled, no users will be able to log in. Use fail-open mode unless you have strict compliance requirements that mandate blocking on webhook failure.

Verifying webhooks

Once any webhook URL is configured, Authagonal mints a per-tenant signing secret (a whsec_… value shown read-only under Settings → Webhooks). Every outbound delivery carries an X-Authagonal-Signature: t=<unix>,v1=<hex> header, where v1 is HMAC-SHA256(secret, "{t}.{body}") computed over the raw request body. Recompute it on your endpoint and constant-time-compare to confirm the request genuinely came from Authagonal and wasn't tampered with — and reject deliveries whose t is too old to block replays.

Verify a webhook (Node.js)
import crypto from 'node:crypto';

// rawBody MUST be the exact bytes Authagonal sent: verify before any JSON re-serialization.
function verifyAuthagonalWebhook(signatureHeader, rawBody, signingSecret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const { t, v1 } = parts;

  // Replay protection: reject deliveries older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(`${t}.${rawBody}`)
    .digest('hex');

  return v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}

Rotating the signing secret

Use Regenerate next to the signing secret to rotate it — for example after a suspected leak. The previous secret is invalidated immediately, so update your verifier with the new value or in-flight deliveries will start failing their signature check.

Maintenance Window

Set a preferred maintenance window for disruptive operations such as moving your tenant's data between storage shards. Choose a UTC hour (0–23); the portal also displays the equivalent time in your local timezone for convenience.

Signup and access

Who is allowed to become a user in your tenant, and under what conditions they can sign in.

SettingDefaultDescription
Public signupOnWhether anyone can register themselves. Turning it off hides the registration page and rejects the registration API, which matters: hiding the link alone would leave the endpoint open. Use it when you provision users yourself through SCIM, the API or invitations.
Require email verification to sign inOnAn unconfirmed address cannot sign in. Turn it off if your own app gates on verification instead; the email_verified claim still rides the token either way, so you keep the signal.
Dynamic client registrationOffLets a client register itself at runtime under RFC 7591, which is what an AI agent or MCP connector needs before it can start a flow. Off by default, and enabling it opens the door for your tenant alone. Registrations stay rate-limited, PKCE-required and consent-required regardless.
Portal MFA policyDisabledMulti-factor for your own team signing in to the portal, set independently of the policy for your end users. Disabled, offered at login, or required.
Maximum usersNoneThere is no cap on total users by default, and no plan sets one. While the tenant owner's email address is unverified, a temporary cap of 5 users applies; it is lifted for good once the address is verified.

Inactive user retention

Optionally deactivate and then delete accounts that have gone unused, so a directory does not accumulate dormant identities forever. Both are off unless you set them, and deletion is permanent.

SettingDefaultDescription
Deactivate after (days of inactivity)NeverDeactivates an account that has not signed in for this long. The record is kept and can be reactivated.
Delete after (days of inactivity)NeverPermanently deletes the account. There is no undo, so set the warning window and webhook below before you turn this on.
Warning days7How many days ahead of a deactivation or deletion the warning webhook fires, giving you time to intervene.
Retention webhook-Where those warnings and the executed actions are posted, so you can notify the person or hold the account open.

Audit export and remote backups

Two ways to get your data out on a schedule rather than on request. Audit export pushes each completed day of audit events to a URL you nominate, signed so you can verify it came from us, for feeding a SIEM or a compliance archive. Remote backups send copies of your daily and weekly backups to a destination you control, with no authentication, basic authentication or a bearer token. Audit export is configured on the Audit Log page's Export tab and remote backups on the Backups page; both are yours to hold, independent of the backups we keep.

Sandbox Environments

The Environments page manages sandbox environments: named, isolated environments with their own users, clients, signing keys, MFA enrolments and grants, each reachable at a separate URL. Branding, plan and billing are shared with live. Use them to test configuration changes, SSO integrations, and webhook endpoints without affecting live users. You can create up to 5 by default, and active sandbox users count toward your MAU.

ActionDescription
Add environmentCreates a new environment. The name is 1 to 20 lowercase letters and digits, and "live" is reserved. The environment starts empty: nothing is copied from live.
RefreshWipes the environment's data and resets it to empty.
DeletePermanently deletes the sandbox environment and all its data.

Each environment is reachable at {name}-{slug}.authagonal.io.

Environments page listing sandbox environments with their URLs and Refresh and Delete actions

Sandbox environment controls

Billing

Manage your subscription and billing through the portal's Billing page. This page gives you an overview of your current plan and provides access to the Stripe billing portal for managing payment methods, invoices, and plan changes.

Subscription Information

The billing page displays your current subscription details at a glance. You'll see a status badge indicating your subscription state — active, trialing, past_due, canceled, or unpaid — along with your plan name, the current billing period (start and end dates), and whether your subscription is set to cancel at the end of the current period.

Manage Subscription

Click the Manage Subscription button to open the Stripe billing portal in a new window. From there you can update your payment methods, view and download invoices, change your plan, or cancel your subscription.

If no subscription exists yet, a Setup Billing call-to-action is shown instead, which guides you through selecting a plan and entering payment details.

Billing page showing subscription status, plan name, billing period, and Manage Subscription button

The billing page displays your current subscription details and provides access to Stripe

Payment Security

All billing is handled through Stripe. Your payment information is never stored on Authagonal servers.

Custom Domains

Serve your authentication pages from your own domain (e.g., auth.yourdomain.com) instead of the default {slug}.authagonal.io. Custom domains give your users a seamless, branded authentication experience.

Adding a Domain

Enter the hostname you want to use in the add domain form (e.g., auth.yourdomain.com). Once added, the domain will appear in your domain list with a pending_verification status.

DNS Verification

Create the two CNAME records the portal shows for the domain, both pointing to {slug}.authagonal.io: one at the hostname itself, which serves traffic and may be proxied, and one at _authagonal-challenge. followed by the hostname, which proves ownership and must be DNS-only (not proxied). Once the records are in place, click Check DNS to verify. Pending domains are also re-checked automatically.

CNAME records
auth.yourdomain.com.                        CNAME  acme.authagonal.io.
_authagonal-challenge.auth.yourdomain.com.  CNAME  acme.authagonal.io.

DNS Propagation

DNS propagation can take up to 48 hours. If verification fails, wait and try again.

TLS Certificates

Once your domain is verified, you need a TLS certificate so users can connect securely over HTTPS. Authagonal supports two options:

Automatic (cert-manager) — Authagonal provisions and renews TLS certificates automatically using cert-manager. This is the recommended option for most users. No additional configuration is required.

Bring Your Own (BYO) — Upload your own certificate and private key in PEM format. This option is useful if your organization requires certificates from a specific certificate authority. Certificate expiry is tracked so you can renew before it lapses.

Domain Status

Each domain displays a status badge indicating its current state: pending_verification (DNS not yet confirmed), verified (DNS confirmed, TLS pending), or active (fully operational).

Domain list showing domains with status badges and verification controls

The domain list displays each custom domain and its current status

BYO certificate upload form with certificate and private key PEM fields

Upload your own TLS certificate and private key in PEM format

BYO Certificate Renewal

Keep your BYO certificate renewed. Expired certificates will cause browser security warnings for your users.

Organizations

An organization is a customer inside a tenant. The tenant stays the isolation boundary: one table prefix and one signing key, with every host of the tenant its own issuer. An organization partitions identity within it, so one tenant can serve many customer organizations without a tenant per customer.

Organizations sit beside custom domains (a domain can be pinned to one) and provisioning apps (a provisioning app can tag a new user with an organization). See the Multi-Customer Apps guide for the end-to-end "one tenant, many branded customers" pattern this feature exists for.

Model and ids

Every organization has a stable, immutable id (shaped org_<32 hex>) emitted as the org_id token claim, and an immutable, tenant-unique slug emitted as org_slug and accepted by the organization authorize parameter. Ids and slugs share one lookup namespace — a slug equal to another organization's id is refused as a collision exactly like an ordinary duplicate slug.

FieldMutableDescription
idNoThe org_id claim. Minted as org_ + a GUID — the underscore is not slug-legal, so a fresh id can never collide with a slug.
slugNoThe org_slug claim, and what the organization parameter accepts. Immutable because a relying party hard-codes it.
nameYesThe <code>org_name</code> claim. Freely editable.
metadataYesFree-form, tenant-controlled key/value pairs. Never emitted on a token unless a scope's claim set explicitly releases one.
brandingJsonYesJSON object merged field by field over the tenant's branding — see Branding override below.
domainsDomain endpoints onlyThe email domains the organization has claimed, each with the DNS record that proves it. Changed only through the domain endpoints below, never by an update.

Memberships

A membership is the many-to-many join between a user and an organization — one row per (organization, user) pair. A user may hold many; the membership row, not the legacy per-user organization tag, is what authorizes signing in as an organization.

StatusGrants tokens?
invitedInvited but not yet accepted. Does not authorize token issuance.
activeA member in good standing. The only status that authorizes token issuance.
suspendedMembership withdrawn without deleting the record. Does not authorize token issuance.

A membership's roles are drawn from the tenant's existing role catalogue and unioned into the roles claim — but only when the organization was explicitly selected by the request (see Token org selection) and only from an active membership row. A role held in one organization never reaches a token issued for another.

Inviting into an organization

POST /api/v1/users/invite accepts an optional organizationId and organizationRoles. One call creates the account with an invited membership and sends an invite email carrying the organization's branding. The link opens on the organization's pinned custom domain when it has one. When the person accepts, the membership becomes active. Naming an organization requires an owner or admin.

SCIM provisioning into an organization

A SCIM token can be bound to an organization (organizationId on POST /api/v1/scim/tokens, or the Organization picker when creating a token). The id must name an organization of the tenant. Every user that token creates becomes an active member and carries the organization as org_id. The binding applies at creation only, and it decides membership, not access: two tokens on one client still see each other's users. Deleting a user, by any route, removes their memberships.

Policy flags

enabled (default on) — a disabled organization mints no tokens for itself, checked both at authorize and on every refresh, so disabling one stops live sessions at their next rotation rather than only blocking new logins.

requireMembershipForTokens (default on): whether a user must hold an active membership to be issued a token for this organization. It only bites for an explicit selection (a request naming the organization, a refresh carrying it, an organization-scoped connection, or a client restricted to exactly one). An organization merely inherited from the account's own legacy tag is never gated by this flag.

allowAutoMembership (default off) lets a user join without an invitation when their confirmed email address is on one of the organization's verified domains. It is enforced at authorize and on every refresh. See Email domains and automatic membership below.

Email domains and automatic membership

An organization claims an email domain and proves it controls it with a DNS TXT record. With allowAutoMembership on, a user whose confirmed email address is on that domain becomes a member the first time they sign in as the organization.

  1. Claim it. POST /api/v1/organizations/{id}/domains with the domain. The response is 201 with recordName (_authagonal-org.acme.com) and recordValue (authagonal-org-verify=<token>). The token is random and per claim.
  2. Publish the record. Add a TXT record with that name and exact value at the domain's DNS.
  3. Verify it. POST /api/v1/organizations/{id}/domains/{domain}/verify looks the record up. A match returns 200 with verified: true. A miss returns 409 verification_failed, never 200 with verified: false, so a client polling for success cannot mistake a miss for one.
  4. Remove it. DELETE /api/v1/organizations/{id}/domains/{domain} returns 204. Existing memberships stay; only future auto-joins stop.
POST /api/v1/organizations/{id}/domains
POST /api/v1/organizations/org_7fa2c9e1.../domains
Content-Type: application/json

{ "domain": "acme.com" }

201 Created
{
  "domain": "acme.com",
  "verified": false,
  "verifiedAt": null,
  "createdAt": "2026-10-03T01:02:03Z",
  "recordName": "_authagonal-org.acme.com",
  "recordValue": "authagonal-org-verify=Qm9...base64url"
}

Refusals: 400 domain_invalid (not a bare DNS name), 409 domain_exists (already on this organization), 409 domain_taken (another organization in the tenant holds it), 404 organization_not_found and 404 domain_not_found. A domain belongs to at most one organization per tenant, and that is checked again at verify time.

The auto-membership rule

The selector admits a user who holds no active membership when all of these hold:

  • The organization was explicitly selected: a carried refresh grant, an organization-scoped connection, the organization parameter (a domain pin supplies it), or a client restricted to exactly one organization. The account's own legacy tag never auto-joins.
  • The organization is enabled and allowAutoMembership is on.
  • The user's email is confirmed, because anyone can register as [email protected].
  • The part of the email after its last @, lowercased, is exactly equal to one of the organization's domains that has been verified. There is no subdomain matching: a verified acme.com does not admit [email protected]. Claim and verify each subdomain on its own.

What it writes, before the membership gate runs: no row becomes a new active membership with no roles; an invited row is promoted to active with its roles kept; an active row is left alone; a suspended row is never promoted.

Deleting a member does not keep them out

While allowAutoMembership is on, a deleted member who still qualifies rejoins on their next authorize or refresh. To keep a qualifying user out, set their membership to suspended, the one state the rule never changes.

Token org selection

Which organization a request resolves to is decided in exactly one place, so the answer is identical at /connect/authorize, on every refresh, and on the device grant. Precedence, highest first:

#SourceBehavior
1Carried grant (refresh)The organization a prior token was issued for. If it no longer exists, the refresh itself is refused rather than falling back to anything else.
2Organization-scoped connectionThe session signed in through a SAML or OIDC connection that belongs to one organization, so that organization is selected. It is the only source that is proven rather than asserted. A request naming a different organization is refused, and so is a connection whose organization no longer exists.
3organizationResolved slug-first, then id. A domain pin reaches this same branch: the middleware appends it before the request is evaluated. Named and not found is a hard refusal, never a silent fallback.
4Client restricted to exactly one orgAuto-selected — a per-customer application registers its one organization once and never sends a parameter. Restricted to <em>more</em> than one with no hint is refused with <code>account_selection_required</code>.
5Account's own legacy organization tagA single flat string, resolved by id in one lookup, not a scan of the user's memberships. If it names no real organization row, it's emitted verbatim with no gating, exactly as before organizations existed. It is never an explicit selection, so it never auto-joins.
6None of the aboveNo organization on the token at all — no <code>org_id</code>, <code>org_slug</code> or <code>org_name</code> claim.

Memberships are never the hint

A user with several active memberships and a request naming none behaves identically to a user with zero memberships: the memberships table is never consulted to pick a default. Selection falls straight to the account's own legacy tag (row 5), and if that's empty too, the token carries no organization at all. Memberships only start to matter after an organization has already been named explicitly, which is when requireMembershipForTokens is checked and, if it applies, an auto-join is tried.

Domain pinning

A custom domain may be pinned to one organization, making that domain the organization's own front door. The pin is enforced by the tenant-resolution middleware for both GET /connect/authorize (appended to the query string) and POST /connect/par (the pushed body is rewritten instead, since PAR reads its parameters from the stored payload). See Custom Domains for the full mechanism.

PUT /api/v1/custom-domains/{domain}/organization
PUT /api/v1/custom-domains/auth.acme.com/organization
Content-Type: application/json

{ "organizationId": "org_7fa2c9e1..." }

A pinned domain refuses a conflicting request

A request naming a different organization than the pin is refused directly with 400 access_denied — before any login page is shown. Without this, a relying party could send its own organization parameter and the pin would be purely decorative.

Organization-scoped connections

A SAML or OIDC connection can belong to one organization. Signing in through it selects that organization and creates an active membership when the user has none. An invited row is accepted: it becomes active and keeps its roles and inviter, so an invitee who signs in through their IdP never needs the email. A suspended member stays suspended and is refused at token issuance. This does not depend on allowAutoMembership or on domains, because the organization was already chosen by the connection.

The organization name on the login page

The login page shows Signing in to {name} under the app name. The name comes from the same organization the page's branding comes from: the pinned domain's organization, then the organization named by the authorize request, then the client's single restricted organization. It is hidden when there is no organization, or when the organization's name equals the app name, so the header never reads "Acme / Signing in to Acme".

Branding override

An organization's brandingJson is merged field by field over the tenant's branding for the login page: a key that's present and non-null overrides, an absent or null key inherits, and an unknown key is ignored. Malformed JSON degrades to the tenant's own branding with a warning logged, never a failed page load.

The Branding tab edits all 21 mergeable fields: appearance (appName, logoUrl, primaryColor, customCssUrl), light and dark mode colors, email colors, supportEmail, showForgotPassword, poweredBy and languages. Each field either inherits the tenant's value, shown as a hint, or overrides it, with a Clear control that removes the key.

showRegistration cannot be overridden

The tenant's public sign-up setting already decided whether registration is offered, and the registration endpoint enforces that regardless of branding. An organization is not the authority for that decision, so this one field is excluded from the merge and has no control on the Branding tab.

Backfill from the legacy organization tag

Before organizations existed, a user could carry a free-text organization tag with no record behind it. POST /api/v1/organizations/backfill migrates that into real organizations and memberships, grouped by each distinct legacy value. It is a dry run unless the body is { "dryRun": false }.

  • Bounded — scans at most 50,000 users per call. A truncated response means run it again; already-migrated users are skipped on the re-run.
  • Idempotent — a distinct legacy value already migrated is matched by an internal stamp, not by the organization's (editable) display name, so renaming the resulting organization afterward doesn't cause a duplicate on the next run.
Example response (dry run)
{
  "dryRun": true,
  "organizationsCreated": 3,
  "membershipsCreated": 41,
  "usersSkipped": 0,
  "organizations": [
    { "legacyValue": "acme-corp", "slug": "acme-corp", "users": 22 }
  ],
  "truncated": false
}

Portal UI walkthrough

The Organizations page lists every organization in the tenant, 50 at a time with a Load more button, and offers New Organisation (slug + name only; policy flags, domains and branding are set afterward) and a Backfill from legacy organisation field flow that runs the backfill above as a preview, then Apply.

Organizations list showing slug, name, and created-date columns, with New Organisation and Backfill from legacy organisation field controls

The organizations list shows every customer organization in the tenant with its slug, name, and creation date

Each organization's detail page has three tabs: General (name, read-only slug, the three policy toggles, an Email domains card to add, verify and remove email domains and copy the TXT record, and a type-the-slug-to-confirm delete), Branding (every overridable field, each inheriting the tenant's value or overriding it), and Members (add by email, with an invite offered when no account exists, per-row status change and remove, with Load more).

Organization detail page's Members tab showing invited, active, and suspended member rows with per-row status control

The Members tab lists each membership with its status and a per-row control to change or remove it

Organization detail page's Branding tab with each overridable field showing either the tenant's inherited value or an override with a Clear control

The Branding tab overrides the tenant's login-page branding field by field for this organization

The Domains page carries the domain → organization selector: every domain card has an inline dropdown listing the tenant's organizations plus a "Tenant (none)" option that clears the pin, applied immediately on change. The same selector appears when adding a domain.

Domain card showing the inline organisation selector that pins a custom domain to one organisation

The inline organization selector on a domain card pins that custom domain to one organization

Audit events

organization.created · organization.updated · organization.deleted · organization.member.added · organization.member.invited · organization.member.joined · organization.member.updated · organization.member.removed · organization.domain_added · organization.domain_verified · organization.domain_removed · organization.backfill · domain.organization_set

Paging

The organization list and the member list are cursor-paged and answer { items, nextCursor }. Pass limit (default 50, clamped to 1 to 200) and, after the first page, the previous response's nextCursor unchanged as cursor. A null nextCursor is the last page. The cursor is an opaque keyset position over organization id (or user id for members), so adding or removing rows between calls cannot shift or repeat a page. A cursor the listing did not issue is 400 invalid_cursor.

Managing organizations from an AI assistant

The hosted portal MCP exposes thirteen organization tools, all requiring the tenant admin role: list, get, create, update and delete organizations; list, add, update and remove members; add, verify and remove email domains; and list a user's organizations. They call the same operations as the API above, so a tool cannot drift from its route. See Run your portal from an AI assistant.

One issuer per host

Every host of a tenant is its own OIDC issuer. When the request host resolved through the tenant's domain table, the issuer and every endpoint URL discovery advertises is https://{host}, so each active custom domain asserts its own iss and the canonical tenant host is unchanged. Signing keys are per tenant, so every host serves the same key set. This is what lets one tenant front several customers, each on its own branded domain pinned to its own organization. See Custom Domains.

Email Configuration

Configure how your tenant sends transactional emails, such as verification, password reset, and invitation emails. Choose between the default shared sender, a verified custom domain via Resend, or your own SMTP server.

Email settings showing provider options (default, Resend domain, custom SMTP) and a test-send

Localised Emails

Transactional emails are sent in the recipient's preferred language. Verification, password-reset, account-exists, welcome, invite, account deletion, support, and billing emails are templated in eleven locales: English, German, French, Spanish, Portuguese, Vietnamese, Simplified Chinese, Japanese, Arabic, Hindi, and Afrikaans. When no template exists for a recipient's language, the email falls back to English.

The language is resolved from the recipient's stored preference at send time. That preference can come from several places:

  • Sign-up and registration — captured from the language the user chose on the hosted sign-in screens.
  • The portal Users page — set by an admin when creating or editing a user.
  • SCIM provisioning: mapped from the IdP's preferredLanguage (or locale) when users are synced via SCIM.
  • The self-service account page — chosen by the user themselves at /login/account.

No configuration needed

Localisation is automatic and applies to every provider mode (default, Resend custom domain, and SMTP). There is nothing to enable.

Email Providers

ProviderDescriptionSetup
Authagonal (Default)Emails sent from [email protected] using our shared Resend infrastructure.No configuration needed — works out of the box.
Custom Domain (Resend)Emails sent from your own verified domain via Resend.Register your domain, add DNS records, verify ownership.
Custom SMTPEmails sent through your own SMTP server.Provide SMTP host, port, credentials, and TLS settings.

Sender Identity

Sender email and name are shared across all provider modes. Sender email is required for the Custom Domain (Resend) and Custom SMTP providers; sender name falls back to the branding app name when empty.

FieldDescription
emailSenderEmailThe From address on outbound emails. Must be on a verified domain for Custom Domain (Resend) mode.
emailSenderNameDisplay name shown in the recipient's inbox.

Resend Custom Domain

Verify your sending domain with Resend once, then use it as the From address for this tenant. The DNS records (SPF, DKIM) are shown in the Custom Sending Domain panel on Settings → Email; Resend checks them when you click <strong>Check Verification</strong>.

Custom SMTP

Bring your own SMTP server — useful for internal relays, vendors not covered by Resend, or regulatory pinning.

FieldDescription
smtpHostSMTP server hostname (e.g. smtp.example.com).
smtpPortConnection port, default 587. TLS is negotiated with STARTTLS; implicit TLS on port 465 is not supported. Use 25 for unauthenticated internal relays.
smtpUsernameAuth username (optional — leave blank for unauthenticated relays).
smtpPasswordAuth password. Stored encrypted in the tenant settings secret.
smtpUseTlsRequire TLS. Leave on unless you are targeting a trusted internal relay.

Custom Sending Domain

When using the Custom Domain (Resend) provider, you can register your own domain so emails come from your brand (e.g. [email protected]) instead of @authagonal.io.

  1. Go to Settings → Email and select the Custom Domain (Resend) provider.
  2. Enter your domain name and click Register Domain.
  3. Add the DNS records shown (DKIM, SPF, and return path) to your domain's DNS.
  4. Click Check Verification — once DNS propagates (usually 1–10 minutes), the domain status will change to verified.

DNS Propagation

DNS changes can take up to 48 hours to propagate globally, though most providers update within minutes. You can check verification as many times as needed.

Testing

Use the Send Test Email button in Settings → Email to verify your configuration. A test email will be sent to your admin email address using the currently saved settings.

Audit Log

The Audit Log provides a read-only record of all administrative actions performed on your tenant. Every change made through the portal or API is captured with full context, giving you a complete trail for compliance and troubleshooting.

Log Columns

ColumnDescription
TimestampThe date and time the action occurred
ActorThe email address of the admin who performed the action, or "system" for automated actions
ActionThe type of action performed (e.g., Client Created, Settings Updated)
EntityThe target of the action in type:id format (e.g., client:my-app)
DetailAdditional context about the change

Tracked Actions

The following administrative actions are recorded in the audit log:

CategoryActions
ClientsClient Created, Client Updated, Client Deleted
SSO ConnectionsSAML Connection Created, SAML Connection Deleted, OIDC Connection Created, OIDC Connection Deleted
UsersUser Created, User Updated
SettingsSettings Updated, Branding Updated
DomainsDomain Added, Domain Verified, Domain Deleted
SCIMSCIM Token Created, SCIM Token Revoked
RolesRole Created, Role Updated, Role Deleted
GroupsGroup Created, Group Deleted
TeamTeam Member Invited, Team Member Removed
Audit log table showing timestamped administrative actions with actor, action, entity, and detail columns

The audit log provides a complete record of all administrative actions

Retention

Audit logs are retained for 365 days and cannot be modified or deleted. To keep them longer, turn on automated export from the Audit Log page's Export tab.

Backups

Authagonal automatically backs up your tenant data on an hourly schedule. Backups include all users, groups, roles, clients, SSO connections, SCIM tokens, branding, and settings. You can view backup history and download the latest complete backup from the Backups page.

Backups page showing backup history with timestamps, types, and entity counts

How Backups Work

  • Once a day the hourly incrementals are rolled up into a new full backup, and on Sundays the daily fulls are rolled up into a weekly backup. Seven daily and four weekly backups are kept.
  • Incremental backups run hourly, capturing only rows that changed since the last backup.
  • Backups are stored in Azure Blob Storage with the same managed identity used by your tenant.
  • Deleted records are tracked via tombstones and included in backups for audit completeness.

Downloading Backups

Click Download Latest to get a ZIP file containing the most recent full backup merged with all subsequent incremental backups. Each table is exported as a JSONL file (one JSON object per line).

Backup Format

Backups are exported as JSONL (JSON Lines) — one entity per line per table. This format is easy to parse, diff, and import into other systems.

Provisioning Apps

Provisioning apps are your own services, called by Authagonal whenever a user is created, so they can set up an account, assign a licence, decide which organization the user belongs to, or refuse the signup outright.

How It Works

When a user is created, Authagonal calls your provisioning app's callback URL using the TCC (Try/Confirm/Cancel) pattern. Every app must accept in the Try phase before any of them is committed, so several downstream systems can agree, or one can veto, without leaving half-created accounts behind.

PhaseEndpointPurpose
/tryPOST {callbackUrl}/tryChecks if the app can handle the user. Return 200 to accept or 4xx to reject.
/confirmPOST {callbackUrl}/confirmCommits the operation after all apps have accepted the /try phase.
/cancelPOST {callbackUrl}/cancelRolls back the operation if another app fails during the /try phase.

When provisioning runs

Provisioning runs on every path that creates a user, not just self-service signup. Already-provisioned app and user combinations are skipped, so an app sees each user once.

Creation pathWhen it fires
POST /api/auth/registerSelf-service registration
SAML ACS callbackFirst SSO login for a new user (JIT)
OIDC callbackFirst SSO login for a new user (JIT)
POST /scim/v2/UsersA connector provisions a user from the customer's directory
Portal and admin user creationAn operator creates or invites a user by hand

The Try request

Authagonal POSTs this JSON to <code>{callbackUrl}/try</code>. Fields with no value are omitted rather than sent as null.

FieldTypeDescription
transactionIdstringIdentifies this provisioning transaction. The same value is sent to /confirm and /cancel, so stage your work against it and commit or discard it when that call arrives.
userIdstringThe user's Authagonal id. This is the subject you will see in their tokens.
emailstringThe user's email address.
firstNamestringGiven name, when the creation path supplied one.
lastNamestringFamily name, when the creation path supplied one.
organizationIdstringThe organization the user is already in, if any. Present only when something has previously assigned one; on a first signup it is absent, which is your cue to assign it.
customAttributesobjectThe user's stored custom attributes. For a user created by SSO this includes federated_connection, the name of the connection that vouched for them.

A user arriving through SSO is the common case worth planning for: they have no organization yet, and federated_connection tells you which of your customers they came from.

Try request for a user arriving through an SSO connection
{
  "transactionId": "8f14e45fceea167a5a36dedd4bea2543",
  "userId": "0f6b1c8e-3d2a-4f51-9e77-2c1a4b5d6e7f",
  "email": "[email protected]",
  "firstName": "Ada",
  "lastName": "Lovelace",
  "customAttributes": {
    "federated_connection": "acme-okta"
  }
}

The Try response

Your app answers with 200 and a JSON body. The body is not just an acknowledgement: it is how a downstream app assigns the organization and attributes that end up on the user's tokens.

FieldTypeDescription
approvedbooleanWhether this app accepts the user. Defaults to true if omitted. False rejects the signup and the new account is deleted.
reasonstringWhy the user was rejected. Surfaced to the caller of the creation path.
organizationIdstringThe organization this user belongs to. Stored on the user and emitted as the org_id claim on their tokens. Applied only if the user does not already have one, so the first app to answer wins and later apps see that assignment.
customAttributesobjectAttributes to merge onto the user, key by key. Emitted on tokens through a scope's UserClaims configuration.
emailVerifiedbooleanYour app vouches that it verified this address, for example by redeeming an invite sent to it. Authagonal marks the account confirmed and skips its own verification email.
Try response assigning an organization
{
  "approved": true,
  "organizationId": "org_acme",
  "customAttributes": { "org_role": "member" }
}

This is where org_id comes from

The account's organization tag, and so the org_id claim when a request names no organization, is whatever your provisioning app returned as organizationId. When it is the id of an organization in the tenant, tokens carry that organization's org_id, org_slug and org_name, and a disabled organization refuses the sign-in. Any other value is emitted verbatim as org_id, with no uniqueness rule and no format requirement. Returning an organization id does not create a membership. You can also set it directly with PUT /api/v1/users/{userId}, and filter by it with GET /api/v1/users?organizationId=.

Tagging users with the right organization

Decide the organization here, in one place, rather than at each creation path. An SSO user carries federated_connection, which identifies the connection that authenticated them and therefore the customer, and stays correct when one customer federates several email domains. An invited user has no connection, so match them by the invite you issued. Both paths reach /try before the user exists, so one piece of logic covers them and there are no two rules to drift apart.

Resolving the organization in the Try callback
// POST {callbackUrl}/try
app.post('/provisioning/try', async (req, res) => {
  const { email, customAttributes } = req.body;

  // An SSO user carries the connection that vouched for them. That is the
  // customer, and it stays right when one customer has several domains.
  const connection = customAttributes?.federated_connection;

  const org = connection
    ? await orgByConnection(connection)
    : await orgByPendingInvite(email);

  if (!org) return res.json({ approved: false, reason: 'No organization for this user' });

  // Stamped on the user and emitted as org_id on every token from now on.
  res.json({ approved: true, organizationId: org.id });
});

Rejection deletes the account

If any app answers approved: false, the newly created user is deleted so no half-provisioned account is left behind. The API creation paths return 422 with your reason; the SAML and OIDC callbacks return 400. Return approved: true for a user you have nothing to do for.

Adding a Provisioning App

To add a provisioning app, provide an App Name, a Callback URL, an optional API Key, and an optional Try timeout (seconds, default 60, range 5 to 300; Confirm and Cancel use a fixed short timeout). The API key is sent as a Bearer token in the Authorization header of each webhook request, allowing your app to authenticate requests from Authagonal.

Testing

Click Test next to any provisioning app to send a test request to your callback URL. The test results display the HTTP status code and response body, helping you verify that your app is receiving and processing webhooks correctly.

Provisioning apps page showing configured apps with test results displaying HTTP status and response body

Test provisioning apps to verify webhook delivery and response handling

Plan Limits

The maximum number of provisioning apps is configurable per tenant, with a default limit of 6. This limit can be adjusted by an admin if your workflow requires additional provisioning targets.

API Key Authentication

If an API key is set, it's sent as a Bearer token in the Authorization header. Use this to authenticate webhook requests from Authagonal.

Team

The Team page manages portal administrators: the people who can access and configure your tenant through the management portal. Each team member has a role (Owner, Admin, Developer or Support) that decides what they can change.

Admin List

The admin list displays each team member's name, email address, role, and the date they were added. A "You" indicator is shown next to the current user's row so you can easily identify your own account.

Inviting Admins

To invite a new team member, provide their email address, name, and role. They receive an email with a link to set their password and activate their account; the link is valid for 7 days.

Invite Fields

Admin invitations create a pending account and email the invitee an activation link.

FieldDescription
emailEmail address of the new admin. Must be unique in the tenant.
nameDisplay name shown in the admin list.
roleRole to grant: tenant:admin, tenant:developer or tenant:support. Defaults to tenant:admin. The owner role cannot be granted by invitation.

Removing Admins

The tenant owner can click Remove next to any team member to revoke their access. A confirmation dialog is shown before the removal is finalized. You cannot remove yourself, and the tenant always keeps its owner.

Team page showing the admin list with name, email, date added columns, a You indicator, and invite/remove controls

Manage portal administrators from the Team page

Ownership

Each tenant has one owner. Only the owner can remove team members or transfer ownership, using Make owner on another member; the previous owner becomes an Admin.

Support

Open a support ticket with the Authagonal team without leaving the portal. Every ticket is a threaded conversation, so you and our team stay on the same page from first report to resolution.

Your tickets

The support page lists every ticket you have raised, newest activity first. Use the status badge to see at a glance what is waiting on you and what is waiting on us.

Support ticket list showing each ticket's subject, status badge, priority, and last-activity time, with a New ticket button

Your support tickets with subject, status, priority, and last activity

  • Each row shows the subject, current status (open, pending, resolved, or closed), priority, and time of the last activity.
  • Click New ticket to open one, then give it a subject, priority, and your first message.
  • Colour-coded status badges make it easy to scan the list for tickets that need your attention.

A ticket thread

Opening a ticket shows the full conversation. Replies post in order, and new messages from our team appear without a page reload.

Support ticket thread showing the conversation between the customer and the Authagonal team with a reply box and file attachment control

A ticket thread between you and the Authagonal team

  • Threaded messages between you and the Authagonal team are shown in chronological order.
  • Reply inline and attach files to share logs, screenshots, or configuration.
  • The thread updates live, so a reply from our team shows up as soon as it is sent.
  • If you reply to a notification email instead, your message is threaded in automatically.

How replies reach you

Authagonal staff work tickets from the admin side. You are notified by email whenever the team replies, so you do not need to keep the portal open to stay on top of a conversation.

Support desk for your users

Separate from the support you get from us, Authagonal can run a support desk for your end users. They raise tickets from their account pages on your tenant's own branded host, and your team answers them from the portal.

It exists because the people who cannot sign in are exactly the people who cannot reach a support tool that requires signing in. The desk sits beside the login screens, so a locked-out user still has a way through, and every ticket arrives already tied to a real account in your directory rather than to whatever address someone typed.

Turning it on

Open Customer support in the portal, switch to its Settings tab and turn on Enable customer support. The tab is there for owners and admins. Nothing is visible to your users until you do.

SettingWhat it does
Enable customer supportThe master switch. Off, both the end-user pages and your operator inbox are hidden and their APIs answer 404.
Allow requests from signed-out visitorsLets a signed-out visitor raise a ticket, which is the locked-out case. Protected by a bot check, and the exchange continues by email plus a private link, since there is no account to sign in to.
Email notificationsWho is emailed when a ticket arrives or a user replies: nobody, specific addresses, or your whole support team (owners, admins and support), so nobody has to sit watching the inbox.
Default customer languageThe language assumed for a user when we do not already know theirs, for their tickets and emails. A user's own saved language always wins, and with nothing set it is English. This one is on the <strong>General</strong> tab of Settings.
Support webhook URLA URL to POST ticket events to, for pushing them into your own tooling.

Not available on Free

The support desk is the one capability the Free plan does not include, because inbound email and translation carry real per-use cost. Every paid plan has it. Authentication features are never gated this way: single sign-on, SCIM, multi-factor, custom domains, branding and audit are on every plan including Free.

What your users see

Signed-in users get a support area in their account pages on your tenant host, carrying your branding. They can raise a ticket, see everything they have raised, and reply in a thread. Replies from your team arrive by email as well, so a user does not have to keep checking.

With anonymous tickets enabled, someone who cannot sign in can still raise one. They give an email address and their message, and get back a private link to the conversation. That link is the only way in, so treat it as a credential: it is unguessable, and anyone holding it can read and reply to that one thread.

The operator inbox

Your team answers from Customer support in the portal's side menu. It is a separate place from your own tickets with us, and it is available to the tenant:support role and above, so you can give an agent access to the desk without giving them the rest of the portal.

ActionWhat it does
ReplyPost into the thread. The user is emailed and sees it live if they have the page open.
AssignGive a ticket to a named member of your team, so two people do not answer the same one.
Status and priorityMove a ticket through open, pending, resolved and closed, and mark how urgent it is. Closed tickets are deleted after a retention period rather than kept forever.
Internal notesNotes visible only to your team, never to the user. Edits and deletions are recorded in the audit log.
Open on behalf of a userStart a thread with one of your users by picking them from your directory, for when the conversation began somewhere else.
Escalate to AuthagonalIf a ticket turns out to be about Authagonal rather than your product, escalate it. That opens a linked ticket with our team, optionally carrying the thread so far, and links the two so you can follow both. A ticket can only be escalated once.

Users write in their own language

A ticket is stored in the language the user wrote it in, and each person on the thread reads it in theirs. Your agent sees a message translated into their portal language, the user sees your reply translated into theirs, and the original text is always kept alongside. The language is detected from the first message and pinned to the ticket. Translations are computed once per language and reused, so a long thread does not re-translate itself.

Time zones

A ticket records the time zone the user raised it from. Each message then shows their local time next to yours, so a reply at 14:32 reads as the 02:32 it actually was for the person waiting on it. Nothing is shown when the zone is unknown, such as a ticket that arrived by email, or when it matches yours.

Webhooks

Set a support webhook URL to receive ticket events as they happen, for raising an alert or mirroring tickets into your own system. Payloads are signed so you can verify they came from us.

EventWhat it does
support.ticket.createdA user raised a ticket.
support.ticket.messageA message was posted to a thread, by a user or by one of your operators. fromStaff in the payload tells you which.
support.ticket.status_changedA ticket's status changed, for example to resolved.
support.ticket.assignedA ticket was assigned to a team member.

Import & migrate

Migrate an existing identity system into your Authagonal tenant. Two sources are supported — Duende IdentityServer (a SQL Server database) and Auth0 (the Management API). Each runs a read-only preview so you can review exactly what will be copied before committing.

Import from Duende IdentityServer

Migrate clients, scopes, users, and roles from an existing Duende IdentityServer SQL Server database into your Authagonal tenant. The import runs in two phases — preview and commit — so you can review what will be copied before any changes are made.

What Gets Imported

The importer reads from Duende's ConfigurationDb and ASP.NET Identity tables and writes mapped rows into your tenant. Short-lived artifacts like persisted grants, device codes, and signing keys are skipped.

EntitySource TablesNotes
ClientsClients, ClientSecrets, ClientGrantTypes, ClientScopes, ClientRedirectUrisDisabled clients are imported disabled. Expired secrets are skipped.
ScopesApiScopes, ApiResources, IdentityResourcesUser-claim mappings are preserved where recognised.
UsersAspNetUsers, AspNetUserClaimsPassword hashes (ASP.NET Identity V3) copy verbatim and rehash on first login.
RolesAspNetRoles, AspNetUserRolesRole assignments are preserved.
External LoginsAspNetUserLoginsStored for reference; reconnect upstream IdPs via SSO after import.

Preview Before Commit

Paste your Duende ConfigurationDb / IdentityDb connection string and click Run Preview. The preview opens a read-only connection and counts every row that would be imported. No writes occur.

  • Entity counts for clients, scopes, users, roles, and role assignments.
  • Collision warnings when the target tenant already has clients (matching ClientIds are overwritten), roles, or scopes (matching names are skipped).
  • Warnings for unknown tables and unmapped columns so you know what will be dropped.
Import preview panel showing entity counts and warnings before committing the import

Preview panel with counts and warnings

Password Hashes

Duende stores passwords using ASP.NET Identity V3 (PBKDF2). Authagonal's PasswordHasher verifies that format directly and rehashes to the native format on the first successful sign-in — users keep their existing passwords without a reset flow.

User ID Reconciliation

If a user already in this tenant has the same email as an incoming record, the import rotates that account's userId to the source sub before importing, so the imported roles, logins, and claims attach to the existing account and apps that already reference the user by their source sub keep resolving after cutover. The account's existing password and profile are preserved; the source roles merge on top. The preview lists every account that will be reconciled before you commit.

Running the Import

Click Start Import after reviewing the preview. The commit phase writes clients, scopes, users, roles, and external-login references into your tenant stores. Clients with a matching clientId are overwritten; rows with a matching scope name, email, or role name are skipped, so the importer is safe to re-run.

What's Not Imported

  • Persisted grants, device codes, server-side sessions — short-lived, regenerated automatically.
  • Signing keys — Authagonal issues its own per-tenant keys.
  • Custom columns and tables — anything outside Duende's standard schema is surfaced as a warning so you know the data was dropped.
  • Disabled clients — imported in a disabled state; re-enable them from the Clients page when ready.

Not available in sandbox

Import runs against the live tenant only. Switch out of sandbox mode before importing.

Import from Auth0

Connect Authagonal to your Auth0 tenant’s Management API and bring your applications, APIs, roles, users, and enterprise connections across. Imported user and application IDs are preserved, so existing sub and client_id references keep resolving after cutover.

What you’ll need

Create a Machine-to-Machine application in Auth0 authorized for the Management API, granted these read scopes: read:users, read:clients, read:resource_servers, read:roles, read:connections, read:client_grants. Paste its domain, client ID, and client secret into the import form — they’re used only for the import.

What Gets Imported

EntitySource TablesNotes
Applicationsclients, client-grantsPublic vs confidential is detected automatically. Client secrets are re-hashed so they keep working.
APIs & scopesresource-serversAudiences and scopes are assigned to each client from its grants.
Rolesroles + assignmentsPer-user role assignments are preserved.
Usersusers + identitiesProfiles and metadata transfer; social/enterprise identities become linked logins.
Connectionsconnections (OIDC)Enterprise OIDC connections become federated providers. SAML, social, and database connections are skipped with a warning.

Passwords

Auth0’s Management API never returns password hashes. If you have Auth0’s support-assisted bulk password export (NDJSON), provide it — bcrypt hashes import verbatim and your users keep their passwords with no reset. That file also carries your full user set, lifting Auth0’s 1,000-user API listing limit. Without it, users import as profiles and set a new password on first sign-in.

Same preview, rotation, and limits

The preview, owner-userId rotation, re-runnable commit, and sandbox restriction described above apply to Auth0 imports too.

API Reference

Each tenant exposes a standards-compliant OIDC server at https://{slug}.authagonal.io. All endpoints follow the OAuth 2.0 and OpenID Connect specifications. This reference covers every endpoint your application may need to interact with.

OIDC Authorization Code Flow with PKCE: sequence diagram showing the 9-step flow between your app, the browser, and Authagonal

Authorization Code Flow with PKCE

OIDC Discovery & JWKS

The discovery document lets OIDC client libraries automatically configure themselves. No authentication is required for either endpoint.

GET /.well-known/openid-configuration

Returns the OpenID Provider Configuration document. The response includes all metadata your client needs to interact with this tenant.

FieldDescription
issuerThe tenant issuer URL
authorization_endpointURL for authorization requests
token_endpointURL for token exchange
userinfo_endpointURL for fetching user claims
jwks_uriURL for the JSON Web Key Set
revocation_endpointURL for token revocation
introspection_endpointURL for token introspection
end_session_endpointURL for logout / end-session
device_authorization_endpointURL for device authorization requests
pushed_authorization_request_endpointURL of the Pushed Authorization Request endpoint (RFC 9126).
require_pushed_authorization_requestsWhether the tenant globally requires PAR. Even when this is false, individual clients can still set RequirePushedAuthorizationRequests = true.
scopes_supportedList of supported scopes
response_types_supportedSupported response types
grant_types_supportedSupported grant types
code_challenge_methods_supportedSupported PKCE methods (S256)
backchannel_logout_supportedWhether back-channel logout is supported

GET /.well-known/openid-configuration/jwks

Returns the JSON Web Key Set used for verifying token signatures. The response contains a keys array of EC P-256 public keys (tokens are signed with ES256), each including kty (EC), use, kid, alg, crv, x, and y fields.

Fetch discovery document
curl https://acme.authagonal.io/.well-known/openid-configuration

Authorization Endpoint

GET /connect/authorize

Initiates an authorization code flow. The user must have an active session or they will be redirected to the login page. On success, the user is redirected back to your application with an authorization code.

ParameterRequiredDescription
response_typeYesMust be "code"
client_idYesYour registered client identifier
redirect_uriYesMust exactly match a registered redirect URI
scopeYesSpace-separated list of scopes (e.g. "openid profile email")
stateRecommendedOpaque value for CSRF protection, returned unchanged in the redirect
code_challengeRequired if PKCEBase64url-encoded SHA-256 hash of the code_verifier
code_challenge_methodRequired if PKCEMust be "S256"
nonceOptionalValue bound to the ID token for replay protection
login_hintOptionalPre-fill the email field on the login page

Success response: 302 redirect to redirect_uri with code and state query parameters.

Error response: 302 redirect with error, error_description, and state query parameters.

PKCE Required

PKCE is required by default for all clients. Generate a code_verifier (a random string of 43 or more characters), hash it with SHA-256, and base64url-encode the result to create the code_challenge.

Pushed Authorization Requests (PAR)

RFC 9126. Instead of putting every authorize parameter on the URL, your client POSTs them to /connect/par with normal client authentication and gets back a short-lived opaque request_uri. The browser then visits /connect/authorize?client_id=...&request_uri=... — nothing else lands in browser history, server logs, or Referer headers, and the server has already integrity-checked the parameters under client auth.

POST /connect/par

Client authentication is the same as /connect/token: HTTP Basic with client_id/client_secret, or form-encoded credentials. Public clients post without a secret. The body carries the same parameters you would normally send to /connect/authorize; request_uri itself is rejected (chaining a PAR is forbidden by §2.1 of the spec). Returns 201 Created.

ParameterRequiredDescription
client_idYesYour client ID. Must match the authenticated client.
client_secretConfidential clientsYour client secret. Required for confidential clients.
response_typeYesMust be "code"
redirect_uriYesMust exactly match a registered redirect URI
scopeYesSpace-separated list of scopes (e.g. "openid profile email")
code_challengeRequired if PKCEBase64url-encoded SHA-256 hash of the code_verifier
code_challenge_methodRequired if PKCEMust be "S256"
stateRecommendedOpaque value for CSRF protection, returned unchanged in the redirect
nonceOptionalValue bound to the ID token for replay protection

Response

FieldDescription
request_uriSingle-use opaque reference, e.g. <code>urn:ietf:params:oauth:request_uri:abc123…</code>. Pass it to <code>/connect/authorize</code> as <code>request_uri</code>.
expires_inLifetime of the <code>request_uri</code> in seconds. Default is 90 — typical reference-IdP value.

On the follow-up GET /connect/authorize?client_id=…&request_uri=…, all other parameters are pulled from the pushed payload and any extra query parameters are ignored. The client_id on the authorize call must match the client that pushed the request. Once consumed (or once expires_in elapses), the request_uri is removed from the store.

Forcing PAR per client

Toggle Require pushed authorization requests on a client (Portal, Clients, open the client, Security tab) to refuse plain /connect/authorize calls from it. The recommended posture for high-risk clients combines RequirePushedAuthorizationRequests = true with PKCE, which removes the URL bar as an attack surface entirely.
Push an authorization request and follow up
# 1. Push parameters (server returns request_uri + expires_in)
curl -X POST https://acme.authagonal.io/connect/par \
  -u "my-app:CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "response_type=code" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "scope=openid profile email" \
  -d "state=$(openssl rand -hex 16)" \
  -d "code_challenge=YOUR_CODE_CHALLENGE" \
  -d "code_challenge_method=S256"

# 2. Send the user to /authorize with only client_id + request_uri
# https://acme.authagonal.io/connect/authorize?client_id=my-app&request_uri=urn:ietf:params:oauth:request_uri:abc123...

Token Endpoint

POST /connect/token

Exchanges credentials for tokens. Requests must use Content-Type: application/x-www-form-urlencoded. Client authentication can be provided via HTTP Basic auth (Authorization: Basic base64(client_id:client_secret)) or as form body parameters (client_id + client_secret).

Authorization Code Grant

ParameterRequiredDescription
grant_typeYes"authorization_code"
codeYesThe authorization code from the redirect
redirect_uriYesMust match the URI used in the authorization request
code_verifierRequired if PKCEThe original random string used to generate the code_challenge
client_idYesYour client identifier (if not using Basic auth)
client_secretConfidential clientsYour client secret (if not using Basic auth)

Refresh Token Grant

ParameterRequiredDescription
grant_typeYes"refresh_token"
refresh_tokenYesThe refresh token to exchange
client_idYesYour client identifier
client_secretConfidential clientsYour client secret

Client Credentials Grant

ParameterRequiredDescription
grant_typeYes"client_credentials"
client_idYesYour client identifier
client_secretYesYour client secret
scopeOptionalSpace-separated scopes to request

Device Code Grant

ParameterRequiredDescription
grant_typeYes"urn:ietf:params:oauth:grant-type:device_code"
device_codeYesThe device code from the device authorization response
client_idYesYour client identifier
client_secretConfidential clientsYour client secret

Token response:

FieldDescription
access_tokenThe access token for API calls
token_type"Bearer"
expires_inToken lifetime in seconds
id_tokenOpenID Connect ID token (when openid scope is requested)
refresh_tokenRefresh token (when offline_access scope is granted)
Exchange authorization code with PKCE
curl -X POST https://acme.authagonal.io/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTHORIZATION_CODE" \
  -d "redirect_uri=https://app.example.com/callback" \
  -d "client_id=my-app" \
  -d "code_verifier=YOUR_CODE_VERIFIER"

UserInfo Endpoint

GET /connect/userinfo

Returns claims about the authenticated user. Requires a valid access token with the openid scope.

FieldTypeDescription
substringUnique user identifier
emailstringUser email address
email_verifiedbooleanWhether the email has been verified
given_namestringFirst name
family_namestringLast name
namestringFull display name
phone_numberstringPhone number (if provided). Released under the <code>phone</code> scope.
org_idstringThe organization the user belongs to. Assigned by your own provisioning app (see Provisioning apps) or set with PUT /api/v1/users/{userId}; Authagonal never derives it. Released under the profile scope, and absent when the user has none.
rolesstring[]Array of assigned roles. Released only when the token carries the <code>roles</code> scope.
groupsobject[]Array of group memberships, each with id and name. Released only when the token carries the <code>groups</code> scope.
Fetch user info
curl https://acme.authagonal.io/connect/userinfo \
  -H "Authorization: Bearer ACCESS_TOKEN"

Token Introspection (RFC 7662)

POST /connect/introspect

Validates a token and returns its metadata. Requires client credentials (Basic auth or form body parameters).

ParameterRequiredDescription
tokenYesThe token to introspect
token_type_hintOptionalHint about the token type (e.g. "refresh_token")

Active token response:

FieldDescription
activetrue
subSubject (user ID)
client_idClient that the token was issued to
scopeSpace-separated scopes granted
issIssuer
expExpiration time (Unix timestamp)
iatIssued-at time (Unix timestamp)
audAudience
token_typeToken type (e.g. "Bearer")

Inactive token response: { "active": false }

Always 200 OK

Per RFC 7662, the introspection endpoint answers 200 OK for any token, so it cannot be used to enumerate tokens. An invalid, expired or revoked token simply returns active: false. The one exception is the caller itself: a client that fails authentication gets 401 invalid_client.
Introspect a token
curl -X POST https://acme.authagonal.io/connect/introspect \
  -u "my-app:CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token=ACCESS_OR_REFRESH_TOKEN"

Token Revocation (RFC 7009)

POST /connect/revocation

Revokes a previously issued token. Requires client credentials.

ParameterRequiredDescription
tokenYesThe token to revoke
token_type_hintOptionalHint about the token type (e.g. "refresh_token")

The endpoint always returns 200 OK, even for invalid or already-revoked tokens, per the RFC 7009 specification.

Access and refresh tokens

Both refresh tokens and access tokens can be revoked. Revoking a refresh token also revokes the access tokens minted from it. A revoked access token is refused by introspection and userinfo, but a resource server that validates the JWT locally keeps accepting it until it expires, so keep access token lifetimes short.

Device Authorization (RFC 8628)

POST /connect/deviceauthorization

Initiates the device authorization flow for input-constrained devices (CLIs, smart TVs, IoT devices). The device displays a code to the user, who then approves the request on a separate device with a browser.

ParameterRequiredDescription
client_idYesYour client identifier
client_secretConfidential clientsYour client secret
scopeOptionalSpace-separated scopes (defaults to "openid")

Response:

FieldDescription
device_codeDevice verification code (used for polling)
user_codeUser-facing code in XXXX-XXXX format
verification_uriURL the user visits to enter the code
verification_uri_completeURL with the user_code pre-filled
expires_in300 by default (seconds, so the code is valid for 5 minutes). Set per client.
interval5 (seconds — minimum polling interval)

Approval flow: The user visits the verification_uri, enters the user_code, and approves the request. Meanwhile, the device polls the token endpoint with the device_code.

Polling error codes:

ErrorMeaning
authorization_pendingUser has not yet approved — keep polling
expired_tokenThe device code has expired — restart the flow
access_deniedThe user denied the authorization request
Request device authorization
curl -X POST https://acme.authagonal.io/connect/deviceauthorization \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=my-cli" \
  -d "scope=openid profile email"

End Session / Logout

GET POST /connect/endsession

Signs out the current user session, notifies every client with a registered back-channel or front-channel logout URI, and revokes the grants bound to that session. Without an id_token_hint that matches the current session, the user is first asked to confirm the sign-out.

ParameterRequiredDescription
id_token_hintOptionalThe ID token — used to validate the post_logout_redirect_uri
post_logout_redirect_uriOptionalWhere to redirect after logout (must be registered)
stateOptionalOpaque value returned in the redirect

If a valid post_logout_redirect_uri is provided and matches a registered URI, the user receives a 302 redirect. Otherwise, a JSON response confirms the session has been ended.

Back-Channel Logout

When a user signs out, Authagonal sends a signed JWT to each client's BackChannelLogoutUri. The JWT contains sub, aud, iss, and the http://schemas.openid.net/event/backchannel-logout event claim. Your application should invalidate the user's local session when it receives this notification.

SCIM 2.0 API Reference

Authagonal supports the SCIM 2.0 protocol for automated user and group provisioning. Identity providers such as Okta, Azure AD, and OneLogin can use this API to keep your Authagonal tenant in sync with your corporate directory.

Base URL: https://{slug}.authagonal.io/scim/v2

Authentication: All requests require a Bearer token. Generate a SCIM token in the portal on the SCIM page: select the client your IdP provisions for, then Create Token.

Common headers:

HeaderValue
AuthorizationBearer SCIM_TOKEN
Content-Typeapplication/scim+json

List endpoints take count (default 100, max 200; 0 returns only the total) and filtering via the filter parameter (e.g. userName eq "[email protected]"). Users page with cursor: pass the previous response's nextCursor. Groups accept either startIndex (1-based) or cursor.

Users

GET /scim/v2/Users: List users with optional pagination and filtering.

Query ParameterDescription
startIndexOnly 1 is accepted for Users; page with cursor instead. A larger value returns 400 invalidValue.
cursorOpaque paging cursor: pass the previous page's nextCursor to get the next page
countMaximum number of results per page (default: 100, max: 200; 0 returns the total only)
filterSCIM filter expression (e.g. userName eq "[email protected]")

GET /scim/v2/Users/{id}: Get a single user by their Authagonal user ID.

POST /scim/v2/Users: Create a new user. Returns 201 Created.

FieldRequiredDescription
userNameYesEmail address (must be unique within the tenant)
name.givenNameNoFirst name
name.familyNameNoLast name
displayNameNoFull display name
activeNoWhether the user is active (default: true)
externalIdNoIdentifier from the upstream identity provider

PUT /scim/v2/Users/{id}: Full replacement of a user resource. All fields must be provided.

PATCH /scim/v2/Users/{id}: Partial update using SCIM PatchOp.

OperationSupported PathsExample Value
replaceuserName, active, name.givenName, name.familyName, displayName, externalId, preferredLanguagetrue / false, or a string value
adduserName, active, name.givenName, name.familyName, displayName, externalId, preferredLanguagetrue / false, or a string value
removename.givenName, name.familyName, displayName, externalId, preferredLanguage(no value needed)

DELETE /scim/v2/Users/{id}: Soft deletes the user (deactivates the account and revokes all tokens). Returns 204 No Content.

Create a user via SCIM
curl -X POST https://acme.authagonal.io/scim/v2/Users \
  -H "Authorization: Bearer SCIM_TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
    "userName": "[email protected]",
    "name": {
      "givenName": "Jane",
      "familyName": "Smith"
    },
    "displayName": "Jane Smith",
    "active": true,
    "externalId": "ext-12345"
  }'

Groups

GET /scim/v2/Groups: List all groups with optional pagination and filtering.

GET /scim/v2/Groups/{id}: Get a single group by ID, including its members list.

POST /scim/v2/Groups: Create a new group. Returns 201 Created.

FieldRequiredDescription
displayNameYesGroup display name
membersNoArray of member objects, each with a value field containing the user ID
externalIdNoIdentifier from the upstream identity provider

PUT /scim/v2/Groups/{id}: Full replacement of a group resource (including its member list).

PATCH /scim/v2/Groups/{id}: Partial update: add, remove or replace members, or change displayName and externalId.

DELETE /scim/v2/Groups/{id}: Hard deletes the group. Returns 204 No Content.

Add members to a group via PATCH
curl -X PATCH https://acme.authagonal.io/scim/v2/Groups/GROUP_ID \
  -H "Authorization: Bearer SCIM_TOKEN" \
  -H "Content-Type: application/scim+json" \
  -d '{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [
      {
        "op": "add",
        "path": "members",
        "value": [
          { "value": "USER_ID_1" },
          { "value": "USER_ID_2" }
        ]
      }
    ]
  }'

SCIM Error Responses

When a SCIM request fails, the response body follows the SCIM error schema: { "schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"], "status": "400", "detail": "..." }. Common status codes include 400 (bad request), 404 (resource not found), 409 (conflict / duplicate), and 429 (rate limited).

Portal API (automation)

The Portal API lets your own backend automate everything you can do in the portal — manage users, clients, groups, roles, scopes, SSO connections, and settings — using a machine-to-machine credential. It is the same API the portal UI calls.

Base URL: https://portal-api.<your-domain>/api/v1. Requests authenticate with a Bearer access token; the tenant is taken from the token, not the URL.

Creating an API credential

In the portal, open Clients → Create API credential, pick an access level, and give it a name. Authagonal generates an OAuth client_credentials client wired for the Portal API and returns a client ID and secret.

Copy the secret immediately

The client secret is shown only once, right after creation. Store it in your secret manager before closing the dialog — if you lose it, delete the credential and create a new one.

Access levels

ScopeGrants
tenant:ownerFull access, including destructive owner-only actions such as deleting the entire tenant.
tenant:adminManage everything except owner-only actions — users, clients, SSO, groups, roles, branding, and settings.
tenant:developerManage clients, scopes, branding, and provisioning apps.
tenant:supportRead and manage users for support tasks, and read the audit log.

You can only grant what you hold

A credential can be no more privileged than the person creating it. An admin cannot mint an owner-scoped credential, and the platform administrative scope can never be granted to a credential.

Getting a token

Exchange the credential for an access token at your tenant's token endpoint — https://<your-tenant>.<your-domain>/connect/token — then send the token as a Bearer header to the Portal API. Tokens are valid for one hour.

Get a token, then call the API
# 1. Exchange the credential for an access token (your tenant's token endpoint)
curl -X POST https://acme.authagonal.io/connect/token \
  -d grant_type=client_credentials \
  -d client_id=api-3f2a... \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d scope=tenant:admin

# Response: { "access_token": "ey...", "token_type": "Bearer", "expires_in": 3600 }

# 2. Call the Portal API with the access token
curl https://portal-api.authagonal.io/api/v1/users \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Endpoints

All paths are relative to the base URL and require a Bearer access token. The scope beside each group is the minimum credential access level it needs. Paging differs by resource: users and the audit log take count (1 to 200, default 50) and after, set to the previous response's continuationToken; groups take startIndex and count; organizations take limit and cursor; clients, roles and scopes return every row.

Clientstenant:developer

GET/api/v1/clientsList OAuth clients.

GET/api/v1/clients/{id}Get a single client by ID.

POST/api/v1/clientsCreate a client. Returns 201 with the client record. Secrets are not returned here: mint one with POST /api/v1/clients/{clientId}/secrets, which shows it once.

PUT/api/v1/clients/{id}Update a client (redirect URIs, grant types, token lifetimes, PKCE/PAR requirements).

DELETE/api/v1/clients/{id}Delete a client.

POST/api/v1/clients/api-credentialMint a machine-to-machine Portal API credential.

Userstenant:support

GET/api/v1/usersList users. Supports count, search (email / name prefix), organizationId to filter to one organization, and after for cursor paging.

GET/api/v1/users/countTotal user count for the tenant.

GET/api/v1/users/stats/mfaMFA enrollment statistics.

GET/api/v1/users/{id}Get a single user.

POST/api/v1/users/inviteInvite a user by email. Creates a pending account and emails a link where the invitee sets their own password. Optional organizationId and organizationRoles also add an organization membership.

PUT/api/v1/users/{id}Update a user (profile, email, isActive, emailConfirmed, organizationId). Changing email or emailConfirmed needs tenant:admin.

DELETE/api/v1/users/{id}Delete a user.

GET/api/v1/users/{id}/mfaGet a user's enrolled MFA methods.

DELETE/api/v1/users/{id}/mfaReset a user's MFA enrollment. Needs tenant:admin.

Rolestenant:admin

GET/api/v1/rolesList roles.

POST/api/v1/rolesCreate a role.

DELETE/api/v1/roles/{id}Delete a role.

POST/api/v1/roles/assignAssign a role to a user.

POST/api/v1/roles/unassignRemove a role from a user.

Groupstenant:admin

GET/api/v1/groupsList groups.

GET/api/v1/groups/{id}Get a group with its members.

POST/api/v1/groupsCreate a group.

POST/api/v1/groups/{id}/membersAdd members to a group.

DELETE/api/v1/groups/{groupId}/members/{userId}Remove a member from a group.

DELETE/api/v1/groups/{id}Delete a group.

GET/api/v1/group-role-mappingsList group-to-role mappings (roles granted at token issuance by group membership).

Scopestenant:developer

GET/api/v1/scopesList API scopes.

POST/api/v1/scopesCreate a scope.

DELETE/api/v1/scopes/{name}Delete a scope.

SSO connectionstenant:admin

GET/api/v1/saml/connectionsList SAML connections.

POST/api/v1/saml/connectionsCreate a SAML connection.

DELETE/api/v1/saml/connections/{id}Delete a SAML connection.

GET/api/v1/oidc/connectionsList OIDC connections.

POST/api/v1/oidc/connectionsCreate an OIDC connection.

DELETE/api/v1/oidc/connections/{id}Delete an OIDC connection.

GET/api/v1/sso/domainsList the domains routed to SSO connections (home-realm discovery).

Brandingtenant:developer

GET/api/v1/brandingGet the tenant branding (colors, logo, supported languages).

PUT/api/v1/brandingUpdate the tenant branding.

Settingstenant:admin

GET/api/v1/settingsGet tenant settings (webhooks, public sign-up, token policy).

PUT/api/v1/settingsUpdate tenant settings.

POST/api/v1/settings/webhook-secret/regenerateRotate the webhook signing secret.

POST/api/v1/settings/test-emailSend a test email with the current email configuration.

Custom domains & emailtenant:admin

GET/api/v1/custom-domainsList custom login domains and their verification status.

POST/api/v1/custom-domainsAdd a custom domain.

POST/api/v1/custom-domains/{domain}/verifyTrigger DNS verification for a custom domain.

DELETE/api/v1/custom-domains/{domain}Remove a custom domain.

GET/api/v1/email/domains/{domainId}Get a sender email domain's DNS records and verification status.

Audit logtenant:support

GET/api/v1/auditQuery the tenant audit log.

Provisioning users via SCIM

For bulk user and group provisioning from an IdP (Entra, Okta), use the SCIM 2.0 API rather than these endpoints.

Example: invite a user

POST /api/v1/users/invite
curl -X POST https://portal-api.authagonal.io/api/v1/users/invite \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "firstName": "Ada",
    "lastName": "Lovelace"
  }'

# 200 OK
# { "userId": "8f3a...", "email": "[email protected]" }

Anything the UI can do

The Portal API exposes the same endpoints the portal UI uses, so any operation you can perform in the portal can be automated — subject to the credential's access level.

Organizations API

All routes below require the TenantAdmin policy and live under /api/v1/organizations unless noted. See Portal API for how to mint a credential. The two list routes are cursor-paged: pass limit (default 50, 1 to 200) and the previous nextCursor as cursor; they answer { items, nextCursor }, and a bad cursor is 400 invalid_cursor. The organization object carries a read-only domains list.

MethodPathBodyNotes
GET/api/v1/organizations–One page of organizations in id order. Query: cursor?, limit? (default 50, 1 to 200). Returns { items, nextCursor }; a null nextCursor is the last page.
POST/api/v1/organizationsslug, name, brandingJson?, enabled?, requireMembershipForTokens?, allowAutoMembership?, metadata?201 + the organization. 409 slug_taken.
GET/api/v1/organizations/{idOrSlug}–Looked up by id first, then slug.
PUT/api/v1/organizations/{id}name?, brandingJson?, slug?, enabled?, requireMembershipForTokens?, allowAutoMembership?, metadata?Only the named fields change. A differing slug is refused: it is immutable once created. domains cannot be changed here.
DELETE/api/v1/organizations/{id}–Deletes every membership of the organization first, then the organization itself.
GET/api/v1/organizations/{id}/members–One page of memberships in user id order, with the member's live email/name resolved from the user store. Query: cursor?, limit?.
POST/api/v1/organizations/{id}/membersuserId? | email?, status?, roles?201 + the membership. 404 user_not_found. 409 membership_exists. Reserved tenant:* and platform:* roles are refused with 400 invalid_role, not stripped.
PUT/api/v1/organizations/{id}/members/{userId}status?, roles?Setting status to active stamps joinedAt if it was not already set.
DELETE/api/v1/organizations/{id}/members/{userId}–Removes the row outright, not a status change.
POST/api/v1/organizations/{id}/domainsdomainClaims an email domain, unverified. 201 + { domain, verified, verifiedAt, createdAt, recordName, recordValue }. 400 domain_invalid. 409 domain_exists, domain_taken.
POST/api/v1/organizations/{id}/domains/{domain}/verify–Looks up the TXT record at recordName and matches recordValue exactly. 200 + the domain when verified (a repeat is a no-op 200); 409 verification_failed on a miss; 409 domain_taken; 404 domain_not_found.
DELETE/api/v1/organizations/{id}/domains/{domain}–204. Drops the claim, verified or not. Existing members stay. 404 domain_not_found.
POST/api/v1/organizations/backfilldryRun? = trueMigrates the legacy AuthUser.organizationId tag into real Organization/OrganizationMembership rows.
GET/api/v1/users/{userId}/organizations–Every organization this user belongs to, with their status/roles within each one.

Login Screens

These are the hosted screens your end users see on your tenant's auth server. Authagonal ships every screen ready to use, so you get a complete, secure sign-in experience without building any UI. This page walks through each screen and shows which portal settings control it.

Fully white-labelled

Every screen here is painted by your tenant's Branding settings — your logo, colour, app name, and custom CSS. The screens also respect prefers-color-scheme, so they switch between light and dark to match the user's device.

Sign-in

Hosted sign-in screen with an email field, Continue button, single sign-on provider buttons, and forgot-password and create-account links
  • Email-first, two-step flow: the user enters their email and clicks Continue, then the password field appears.
  • "Continue with {provider}" single sign-on buttons appear automatically when SSO connections exist.
  • Forgot password and create account links, each of which can be shown or hidden.
  • Optional Cloudflare Turnstile captcha to deter automated sign-in attempts.

Controlled in the portal admin

  • Branding sets the logo, colour, app name, support email, and custom CSS.
  • Show or hide the forgot-password and registration links (Branding).
  • SSO connections add the social sign-in buttons (SSO page).
  • Session lifetime and lockout thresholds (Settings → Session).

Registration

Account registration screen with first and last name fields, email, password, and a live password-policy checklist
  • Collects first and last name (optional), email, and a password.
  • A live password-policy checklist updates as the user types, so requirements are clear before submitting.
  • Optional Cloudflare Turnstile captcha.
  • A "Sign in" link for users who already have an account.

Controlled in the portal admin

  • Show or hide the registration link (Branding). To turn registration off entirely, switch off Allow public sign-up (Settings → Session).
  • Your tenant's password policy drives the checklist.
  • Branding styles the whole screen.

Forgot password

Forgot-password screen with an email field and a neutral check-your-email confirmation state
  • The user enters their email, then sees a neutral "check your email" confirmation.
  • The screen never reveals whether an account exists, which defeats account-enumeration probing.
  • A "Back to sign in" link returns the user to the sign-in screen.

Controlled in the portal admin

  • Show or hide the forgot-password link (Branding).
  • Your tenant's email delivery sends the reset message.
  • Branding styles the whole screen.

Reset password

Reset-password screen with new and confirm password fields and a live per-rule requirement checklist
  • New password and confirm password fields with a live per-rule requirement checklist.
  • A clear invalid or expired link state when the reset token is no longer valid.
  • A success state confirming the password has been changed.

Controlled in the portal admin

  • Your tenant's password policy drives the checklist.
  • Branding styles the whole screen.

MFA challenge

MFA challenge screen with a method switcher, a six-digit authenticator code field, recovery-code entry, and a passkey button
  • A method switcher between authenticator app, passkey, and recovery code.
  • A 6-digit TOTP field that auto-submits once all digits are entered.
  • Recovery-code entry for users who have lost access to their authenticator.
  • A passkey button for hardware-backed verification.

Controlled in the portal admin

  • MFA policy is set per application (Clients → Security).
  • Any user with an enrolled factor is always challenged, regardless of policy.

MFA setup

MFA setup screen showing enrolled-method status, authenticator QR code and manual key, passkey enrolment, and recovery-code generation
  • Shows the status of enrolled methods so the user knows what is already configured.
  • Authenticator setup via QR code, a manual key fallback, and a confirmation step.
  • Passkey enrolment for hardware-backed authentication.
  • Recovery-code generation for account recovery.
  • An optional skip when MFA is self-service rather than required.

Controlled in the portal admin

  • MFA policy is set per application; Required forces setup at login (Clients → Security).
  • Branding styles the whole screen.

Device authorization

Device authorization screen with a centered user-code entry field, an Approve button, and an approved confirmation state
  • A centered user-code entry field for the code shown on the device.
  • An approve step to authorize the device.
  • A sign-in interstitial when the user is not yet authenticated.
  • An approved confirmation once the device is authorized.

Controlled in the portal admin

  • Enable the device-code grant on the application (Clients → Scopes & Grants).
  • Set the device-code lifetime (Clients → Tokens).
Consent screen showing the requesting application's logo and name, a per-scope permission list, and Allow and Deny buttons
  • Shows the requesting client's logo and name.
  • A per-scope list with friendly, human-readable labels for each permission.
  • Allow and Deny buttons to grant or refuse access.
  • A consent hint footer explaining what the decision means.

Controlled in the portal admin

  • Turn on Require consent per application (Clients → General).
  • The logo, name, and URL come from the application's own metadata.
  • Branding paints the consent card.

Connected apps (grants)

Connected apps screen listing the applications a user has authorized with their scopes and granted date, plus a revoke control
  • Lists every app the user has authorized, with its name, scopes, and granted date.
  • Revoke access to an app, with a confirmation step before it takes effect.
  • A friendly empty state when the user has not authorized any apps.

Controlled in the portal admin

  • The list is populated by consent-required applications.
  • Branding styles the whole screen.

Account

A hosted self-service account page at /login/account where signed-in users manage their own profile and preferred language, with no portal access required.

Self-service account screen with editable profile fields and a preferred Language selector
  • Edit first and last name, company, and phone; the email address is shown read-only.
  • Choose a preferred language from the supported locales; the UI previews the choice instantly and persists it on save.
  • The saved language drives the user's hosted UI and the language of the transactional emails they receive.

Controlled in the portal admin

  • Branding styles the whole screen.
  • The same preferred language is editable by an admin on the portal Users page.

Authentication Flows

Authentication flows cover how end users interact with your Authagonal tenant — logging in, registering, resetting passwords, and setting up MFA. These endpoints are used by the hosted login page and can be called directly if you are building a custom login UI.

Login

POST /api/auth/login

Authenticates a user with email and password. On success, signs a session cookie and returns the user profile. If MFA is configured, the response indicates that a second factor is required before the session is fully established.

Request body:

Login request
{
  "email": "[email protected]",
  "password": "correct-horse-battery-staple"
}

Success response:

FieldTypeDescription
userIdstringUnique user identifier
emailstringUser email address
namestringFull display name
mfaAvailablebooleanWhether the user has MFA methods enrolled

MFA required response: When the user has MFA enrolled, the response includes mfaRequired: true along with a challengeId and a methods array listing available MFA methods.

MFA setup required response: When the application's MFA policy is Required but the user has not yet enrolled, the response includes mfaSetupRequired: true with a setupToken for the enrollment flow.

Error responses:

Error CodeHTTP StatusDescription
invalid_credentials401Email or password is incorrect
account_disabled403The account has been deactivated by an admin
email_not_confirmed403The user has not verified their email address
locked_out423Account is temporarily locked (includes retryAfter in seconds). Returned only when the password is correct; a wrong password while locked returns invalid_credentials
sso_required409Email domain has SSO configured (includes redirectUrl)
too_many_attempts429Too many login attempts from this IP or for this email; try again later
captcha_failed400Turnstile challenge failed or missing (only when Turnstile is enabled)

SSO check: If the user's email domain has an SSO connection configured, the login endpoint returns sso_required with a redirectUrl. The client should redirect the user to the SSO provider.

Account lockout: After maxFailedAttempts consecutive failed login attempts, the account is locked for lockoutDurationMinutes. Both values are configurable in tenant settings.

Hosted Login Page

The login endpoint is typically called by the hosted login page, not directly by your application. Use the OIDC authorization code flow to initiate authentication — your users will be redirected to the hosted login page automatically.

Registration

POST /api/auth/register

Creates a new user account and sends a verification email. While <strong>Require verified email to sign in</strong> (Settings &rarr; Session) is on, which is the default, the user must verify their email before they can log in.

Request body:

Registration request
{
  "email": "[email protected]",
  "password": "a-strong-password-here",
  "firstName": "Jane",
  "lastName": "Smith"
}
FieldRequiredDescription
emailYesEmail address (must be unique)
passwordYesMust meet the tenant password policy
firstNameNoFirst name
lastNameNoLast name

Success: 201 Created with the userId of the new account. Registering with an email that's already taken also returns 201: we never disclose whether an email exists (to prevent account enumeration), and instead notify the real account holder by email.

Error responses:

Error CodeHTTP StatusDescription
weak_password400Password does not meet the tenant password policy
rate_limited429Too many registration attempts
provisioning_rejected422A provisioning webhook rejected the registration
invalid_email400Email address is not valid
captcha_failed400Turnstile challenge failed or missing (only when Turnstile is enabled)
public_signup_disabled403Public sign-up is turned off for this tenant (Settings &rarr; Session)

Password Policy

Check the tenant's password requirements before submission via GET /api/auth/password-policy. This returns a rules list: minimum length and the required character classes.

Password Reset

POST /api/auth/forgot-password

Requests a password reset email. The endpoint always returns a success response regardless of whether the email exists, to prevent email enumeration.

Forgot password request
{
  "email": "[email protected]"
}

POST /api/auth/reset-password

Resets the user's password using the token from the email link.

Reset password request
{
  "token": "RESET_TOKEN_FROM_EMAIL",
  "newPassword": "new-strong-password"
}

Side effects of a successful password reset:

  • Failed login attempt counter is reset to zero
  • All existing refresh tokens are revoked
  • A new security stamp is generated (invalidating all existing sessions)

MFA Setup & Verification

Authagonal supports three MFA methods: TOTP (authenticator apps), WebAuthn (security keys and biometrics), and single-use recovery codes.

TOTP Setup

POST /api/auth/mfa/totp/setup: Returns a <code>setupToken</code>, a QR code data URI and a manual entry key. The user scans the QR code with their authenticator app (Google Authenticator, Authy, 1Password, etc.), then confirms enrollment.

POST /api/auth/mfa/totp/confirm: Confirms TOTP enrollment by sending the <code>setupToken</code> from setup together with a 6-digit code from the authenticator app.

Confirm TOTP enrollment
{
  "setupToken": "SETUP_TOKEN_FROM_SETUP_RESPONSE",
  "code": "123456"
}

WebAuthn Setup

POST /api/auth/mfa/webauthn/setup — Returns credential creation options for the WebAuthn API. The browser calls navigator.credentials.create() with these options.

POST /api/auth/mfa/webauthn/confirm: Confirms WebAuthn enrollment by submitting the attestation response from the browser.

Recovery Codes

POST /api/auth/mfa/recovery/generate: Generates 10 single-use 10-character recovery codes (format <code>XXXXX-XXXXX</code>). Each code can be used exactly once to bypass MFA. An authenticator app or passkey must be enrolled first.

Recovery Codes Are Shown Only Once

Recovery codes are displayed only at the time of generation and cannot be retrieved later. If a user loses both their authenticator device and their recovery codes, an admin must manually delete their MFA credential from the portal before they can log in again.

MFA Verification

POST /api/auth/mfa/verify: Completes the MFA challenge after a successful password login.

FieldRequiredDescription
challengeIdYesThe challenge ID from the login response
methodYes"totp", "recovery", or "webauthn"
codeTOTP / Recovery6-digit TOTP code or recovery code (XXXXX-XXXXX)
assertionWebAuthnThe assertion response from navigator.credentials.get()

MFA Status

GET /api/auth/mfa/status: Returns the user's currently enrolled MFA methods.

SSO Login Flow

Authagonal supports both SAML 2.0 and OIDC-based SSO connections. Domain-based routing automatically detects which SSO provider to use based on the user's email address.

SSO Check

GET /api/auth/[email protected]

FieldTypeDescription
ssoRequiredbooleanWhether the email domain requires SSO
providerTypestring"saml" or "oidc"
connectionIdstringThe SSO connection identifier
redirectUrlstringThe URL to redirect the user to for SSO login

SAML Flow

The user is redirected to GET /saml/{connectionId}/login which sends a SAML AuthnRequest to the identity provider. The IdP authenticates the user and posts a SAML response back to the Assertion Consumer Service (ACS) endpoint. Authagonal validates the assertion, creates or updates the user, and signs a session cookie.

SAML metadata for configuring your IdP is available at GET /saml/{connectionId}/metadata.

OIDC Flow

The user is redirected to GET /oidc/{connectionId}/login which redirects to the upstream identity provider with PKCE. After the user authenticates, the callback at /oidc/callback exchanges the authorization code, validates the ID token, and creates or updates the user.

JIT Provisioning: Both SAML and OIDC flows support just-in-time provisioning, which is off by default on each connection (Enable JIT provisioning). When it is on and the user does not already exist in the tenant, they are created automatically from the identity provider's claims. If they do exist, their profile attributes are updated to match the latest values from the provider.

Domain-Based Routing

Domain-based routing means your users don't need to know which SSO provider they use. Entering their email address is enough — Authagonal matches the domain to the correct SSO connection and redirects automatically.

Backend-for-Frontend (BFF)

A BFF keeps OAuth tokens out of the browser entirely. Your single-page app holds nothing but an httpOnly session cookie, and a confidential client on your own backend does the OpenID Connect flow and holds the tokens server-side.

Anything a single-page app can read, cross-site scripting can steal, and that includes an access token in memory and a refresh token in localStorage. Storing tokens in the browser also caps their lifetime, because a long-lived refresh token in reach of scripts is a standing liability. The IETF OAuth 2.0 for Browser-Based Apps best current practice recommends this pattern for exactly that reason.

In exchange you get a session that can outlive a page reload without a token in sight, refresh handled for you server-side, immediate revocation through back-channel logout, and an authenticated proxy so your API never has to parse a token the browser could have tampered with.

Create the client

In the portal, open Clients and choose Create BFF app. Give it the Application URL your app is served from, for example https://app.acme.com, and Authagonal registers a correctly configured confidential client for you rather than leaving you to assemble one. The redirect and logout URLs are derived from that one value:

SettingValue
Redirect URI{appBaseUrl}/bff/callback
Post-logout redirect URI{appBaseUrl}/
Back-channel logout URI{appBaseUrl}/bff/backchannel-logout
Grant typesauthorization_code, refresh_token
Scopesopenid, profile, email, offline_access
PKCE and client secretBoth required

The secret is shown once

The response carries clientId, clientSecret and authority, and the secret is never recoverable afterwards because only its hash is stored. Put it straight into your backend's configuration or secret store. If you lose it, create another client rather than trying to recover this one.

Wire it into your backend

Two runtimes are supported and share the same core protocol: Authagonal.Bff for .NET and @authagonal/bff for Node, the latter with adapters for Express and Next.js. Point either at the values the portal just gave you. The Node package also needs a cookieSecret of your own, which it uses to encrypt its cookies.

.NET
// dotnet add package Authagonal.Bff
builder.Services.AddAuthagonalBff(o =>
{
    o.Authority    = "https://acme.authagonal.io";       // your tenant auth host
    o.ClientId     = builder.Configuration["Bff:ClientId"]!;
    o.ClientSecret = builder.Configuration["Bff:ClientSecret"]!;
    o.Scope        = ["openid", "profile", "email", "offline_access"];
    o.PostLogoutRedirectUri = "https://app.acme.com/";
});

// Trust X-Forwarded-Proto from your ingress. With no options, UseForwardedHeaders() changes nothing.
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
    o.ForwardedHeaders = ForwardedHeaders.XForwardedProto;
    o.KnownNetworks.Clear();
    o.KnownProxies.Clear();
});

var app = builder.Build();

app.UseForwardedHeaders();   // required behind a proxy or ingress, see the note below
app.MapAuthagonalBff();
app.MapFallbackToFile("index.html");   // your SPA
app.Run();
Node (Express)
// npm install @authagonal/bff
import express from 'express';
import { authagonalBff } from '@authagonal/bff/express';

const app = express();

app.use(authagonalBff({
  authority: 'https://acme.authagonal.io',
  clientId: process.env.BFF_CLIENT_ID,
  clientSecret: process.env.BFF_CLIENT_SECRET,
  cookieSecret: process.env.BFF_COOKIE_SECRET,   // encrypts the session and login cookies
  scope: ['openid', 'profile', 'email', 'offline_access'],
  postLogoutRedirectUri: 'https://app.acme.com/',
}));

app.listen(8080);

Behind a proxy, trust the forwarded headers

Almost every deployment puts the BFF behind an ingress or load balancer that terminates TLS and speaks plain HTTP to your process. Without forwarded-header handling the BFF believes the request is insecure and emits its __Host- session cookie without the Secure attribute, which browsers then silently discard. The symptom is a login that completes cleanly and a session that never appears. In .NET enable XForwardedProto in ForwardedHeadersOptions and call app.UseForwardedHeaders() before MapAuthagonalBff(), as above: the bare call trusts no header. The Node adapters read X-Forwarded-Proto themselves and always mark a __Host- cookie Secure; set your framework's trust-proxy setting so the client address forwarded to your API is the real one.

Endpoints

Mounted under /bff by default. They must be served from the same origin as your SPA, because the session cookie is httpOnly and same-origin: put them behind the same hostname rather than on a separate API domain.

RoutePurpose
GET /bff/login?returnUrl=/Starts login and redirects to Authagonal. Returns the user to returnUrl afterwards.
GET /bff/callbackThe OIDC redirect URI. Handled for you; you never write this.
GET /bff/userReturns isAuthenticated, the session claims, and sessionExpiresAt. Requires the anti-forgery header.
GET|POST /bff/logoutEnds the session locally and at Authagonal.
POST /bff/backchannel-logoutReceives logout notifications from Authagonal, so a sign-out elsewhere ends this session too.

From the browser

Every non-navigation request must carry a static anti-forgery header. It defends against cross-site request forgery alongside the cookie's SameSite attribute: a cross-site form post cannot set a custom header, so a request without it is refused.

Checking who is signed in
const me = await fetch('/bff/user', {
  headers: { 'X-Authagonal-Bff': '1' },
}).then(r => r.json());

if (!me.isAuthenticated) {
  // Navigate, do not fetch: this is a redirect to your identity provider.
  window.location.href = '/bff/login?returnUrl=' + encodeURIComponent(location.pathname);
}

Log in and out by navigating, not fetching: location.href = '/bff/login'. Those routes answer with a redirect to your identity provider, and a redirect chain is not something fetch can usefully follow.

Calling your API

The BFF can forward your API under its own base path and attach the session's access token on the way through. The browser sends a cookie, your API receives a bearer token it validates normally, and nothing about that token is visible to or forgeable by the page. Register an upstream and requests to /bff/api/** reach it authenticated. Leave the list empty and the proxy is disabled entirely.

Forwarding an upstream API
o.Upstreams.Add(new BffUpstream
{
    Prefix        = "/orders",                        // matches /bff/api/orders/**
    TargetBaseUrl = "https://api.internal.acme.com",
    StripPrefix   = false,                            // keep /orders in the forwarded path
});
OptionDefaultWhat it does
Upstreams[]The APIs the proxy forwards to. Empty disables the proxy endpoint.
Prefix/Path prefix after /bff/api that this upstream handles, for example /orders.
TargetBaseUrl-Base URL requests are forwarded to.
StripPrefixfalseRemove the matched prefix before forwarding. Lets you use a synthetic routing prefix to fan one BFF out to several backends that share a path namespace.
AllowAnonymousProxyRequestsfalseForward a request with no usable session without an Authorization header, instead of refusing it. For an API that serves both signed-in and anonymous callers.
RequiredAuthority[]An authority gate as type:action pairs, for example email:send. When set, the proxy checks the outgoing token's RFC 9396 authorization details before forwarding.
AuthorityLocation-The RFC 9396 locations root this upstream is known by, when authority is granted against a public resource identifier that differs from the internal address the proxy calls.
StrictAuthorityfalseRefuse a call carrying a grant constraint the proxy cannot evaluate, rather than forwarding it. The proxy forwards blind and derives no constraint context, so this is off by default.
ExchangeRoutes[]Proxy routes whose upstream calls ride a context-bound exchanged token rather than the session's primary access token. The first matching pattern wins.

WebSockets

A WebSocket handshake cannot carry a custom header or a bearer token, so neither the anti-forgery header nor the proxy helps. Enable tickets and the SPA can call GET /bff/ws-ticket, put the short-lived single-use ticket on the connect URL, and have your API redeem it. Mint one immediately before each connect: it is deleted on first use and expires in seconds.

OptionDefaultWhat it does
WsTicketsEnabledfalseEnables the ws-ticket endpoint. Off by default.
WsTicketLifetime30sHow long a ticket is valid. Kept short deliberately, since it travels in a URL.
TicketExchangeParams[]Query parameters a ticket request may forward into a token exchange, so the ticket is bound to that context rather than being valid for everything.

Handing a token to the browser, deliberately

The whole point of a BFF is that the browser holds no token, so this is opt-in and narrow. It exists for the one case the cookie model cannot reach: a resource server on another origin, such as an app you embed in an iframe, that must be called with a bearer. Enabled, GET /bff/token?resource=… returns an exchanged token: the session's token downscoped to one allow-listed resource and bound to any allow-listed context parameter. The browser never sees the session's own token, and what it does get is short-lived and single-audience. A request naming a resource outside the allow-list is refused, which is what stops this being a general-purpose mint.

OptionDefaultWhat it does
TokenEndpointEnabledfalseEnables the token endpoint. Off by default.
TokenEndpointResources[]The resource values a token may be addressed to. Anything else is refused.
TokenEndpointExchangeParams[]Query parameters forwarded into the exchange as context bindings, for example project_id.

Serving several tenants from one BFF

Set a tenant query parameter and one deployment serves many tenants: /bff/login?slug=acme selects the tenant, a resolver supplies that tenant's authority and client credentials, the key rides the correlation cookie into the session, and back-channel logout resolves the tenant from the token issuer. The default resolver keeps single-tenant behaviour byte-identical, so you pay nothing for this unless you use it.

Running more than one instance

Sessions live behind IBffSessionStore, which defaults to an in-process cache. That is fine for a single instance and wrong for several: a user whose next request lands on another replica is signed out. Register a shared store, such as Redis through IDistributedCache, before adding the BFF.

A shared store is not enough on its own

You also need a cross-replica refresh lock. Refresh single-flight is process-local, while the session and its rotating refresh token live in the store every replica shares. Two replicas can read the same session, both decide it needs refreshing, and both redeem the same refresh token. That is indistinguishable from a stolen-token replay, and the correct response to replay is to revoke the whole grant family, so a multi-instance BFF without a lock can sign users out as a matter of routine. Supply it either way: register an ILeaseProvider through clustering, or implement IBffRefreshLockStore on your session store, which is a conditional write with a time to live and is the shorter route when you already run Redis. A BFF whose store looks shared and has no lock warns about it at startup rather than leaving you to discover it from a support ticket.

Options worth knowing

The full set, in the .NET spelling. The Node package takes the core options in camelCase, so BasePath is basePath and SessionLifetime is sessionLifetimeSeconds. PersistentCookie, CorrelationLifetime (fixed at 15 minutes in Node) and LoginPassthroughParams are .NET only.

OptionDefaultWhat it does
Authority-Your tenant auth host. OIDC metadata is discovered from it. Required unless you are multi-tenant, where the resolver supplies it.
ClientId-The confidential client id registered for this BFF.
ClientSecret-The client secret. A BFF is a confidential client, so this is required.
Scopeopenid profile offline_accessRequested scopes. Include offline_access or there is no refresh token and sessions end when the access token does.
BasePath/bffWhere the BFF routes are mounted.
CallbackPath/bff/callbackThe OIDC redirect URI path. Must match what the client is registered with.
CookieName__Host-agbffSession cookie name. The __Host- prefix requires HTTPS, so local development over plain HTTP needs a different name.
SessionLifetime8hHow long a session may live. Set it to match your refresh token's absolute lifetime, or an idle user is signed out while holding a credential that was still valid.
PersistentCookiefalseWhether the cookie survives closing the browser. The refresh token stays server-side either way.
CorrelationLifetime30mHow long a login may take between starting it and the callback. It bounds the cookie carrying the state, nonce and PKCE verifier, so a user who leaves the login screen open and comes back is the case it has to survive.
RefreshThresholdSeconds60How many seconds before expiry the access token is refreshed.
AntiForgeryHeaderX-Authagonal-BffThe header name the browser must send on non-navigation requests.
PostLogoutRedirectUri-Where the browser lands after logout completes.
ReturnUrlAllowlist[]Absolute origins a non-relative returnUrl may target. Relative paths are always allowed and everything else is coerced to /, so an open redirect is not reachable through the login route.
LoginPassthroughParams[]Query parameters forwarded from /bff/login to the authorize request, for example prompt so a get-started link can route to registration rather than sign-in.
TenantQueryParam-Set it to serve several tenants from one BFF. See above.

Both runtimes behave the same

The .NET and Node packages implement the same core protocol contract, so login, callback, user, logout and back-channel logout, the cookie, the anti-forgery header, the refresh behaviour and the basic upstream proxy are identical. WebSocket tickets, the token endpoint, exchange routes, StripPrefix, the authority gates and anonymous proxying are .NET only. The names above are the .NET spelling; Node uses camelCase equivalents.

Everything is swappable

The pieces are interfaces, so you can move the BFF onto your own infrastructure without forking it: IBffSessionStore for where sessions live, ICookieProtector for cookie encryption (ASP.NET Data Protection by default), and ITokenClient for talking to the token and revocation endpoints. One BFF can also serve several tenants through IBffTenantResolver, selecting the tenant from a query parameter at login and resolving it from the token issuer on back-channel logout.

Run your portal from an AI assistant

Connect an AI assistant to your tenant and ask it to do the things you would otherwise click through: find a user who cannot sign in, check whether they still have a second factor, invite someone, see who holds an admin role.

It is an ordinary OAuth connection, not an API key. Each person signs in as themselves and approves the access, so an assistant can do exactly what that person can do in the portal and nothing more. Nothing new is created that could leak, and revoking one assistant leaves everyone else untouched.

Turning it on

Open Settings and enable AI assistant access. It is off until you do, and while it is off the endpoint does not exist rather than merely refusing. Once enabled, the panel shows the URL to paste into your AI client:

Your MCP URL
https://portal-api.authagonal.io/api/v1/mcp/{your-tenant}

How an assistant gets permission

An assistant registers itself before it can start a sign-in, and enabling AI assistant access is what permits that. You do not need to turn on anything else, and in particular you should not enable dynamic client registration on your end-user tenant for this: that setting governs the tenant serving your own users, which is not where an assistant signs in. Registering is not access. A newly registered assistant holds nothing at all until one of your people signs in and approves it, and every tool then re-checks that person’s role.

What an assistant can do

Exactly what the person who connected it can do, decided per call against their own token. A tenant:support agent gets the diagnostic and everyday tools; the admin tools are not merely hidden from them, they are refused if named directly. Anything gated inside the portal stays gated: changing a user's email address needs the admin role there, and it needs it here too.

Because it is a normal grant, you manage it the normal way: remove the assistant on the Authorized Apps page of your account and it can no longer refresh, so it stops working when its current access token expires. Every change an assistant makes is written to your audit log as the person it acted for, so the trail is the same one you already read.

The tools

Thirty-three in this release. Your AI client decides which ones to enable, so you can hand an assistant only the reading tools if that is all you want it doing.

ToolRoleWhat it does
find_userSupportFind users by email, name prefix or id. The starting point for everything else.
get_userSupportOne user in full, including whether they are active, confirmed and locked out.
get_user_mfaSupportWhich second factors someone has enrolled.
get_user_sessionsSupportWhere a user is currently signed in.
search_auditSupportSearch the audit log by actor, action or the thing acted on.
list_usersSupportList the directory, optionally filtered to one organization.
get_user_statsSupportHow many users you have and how many use a second factor.
invite_userSupportInvite someone by email.
resend_inviteSupportSend an invitation again.
send_verification_emailSupportSend the email-verification message again.
update_userSupportUpdate a profile. Changing the email address still needs admin.
revoke_user_sessionsSupportSign a user out everywhere.
list_rolesAdminThe roles defined in your tenant.
list_role_membersAdminWho holds a given role.
assign_roleAdminGive a user a role.
unassign_roleAdminTake a role away.
reset_user_mfaAdminRemove every second factor, for someone who has lost their authenticator.
get_settingsAdminYour tenant's configuration.
list_sso_connectionsAdminYour SSO connections and the domains they cover.
list_organizationsAdminThe organizations in your tenant, one page at a time.
get_organizationAdminOne organization by id or slug, with its email domains.
create_organizationAdminCreate an organization. The slug is permanent.
update_organizationAdminChange an organization's name, policy flags, metadata or branding. The slug cannot change.
delete_organizationAdminDelete an organization and all of its memberships.
list_organization_membersAdminThe members of an organization, one page at a time.
add_organization_memberAdminAdd a user to an organization.
update_organization_memberAdminChange a member's status or roles within an organization.
remove_organization_memberAdminRemove a user from an organization.
add_organization_domainAdminClaim an email domain for an organization. Returns the DNS TXT record to publish.
verify_organization_domainAdminCheck the TXT record and mark the domain verified. Safe to repeat.
remove_organization_domainAdminDrop a domain claim. Existing members stay.
list_user_organizationsAdminThe organizations a user belongs to, with their status and roles in each.
list_clientsDeveloperThe OAuth clients registered in your tenant.

Readers and writers are marked

Every tool tells your client whether it only reads, whether it changes something, and whether that change is destructive. A good client uses this to run a lookup without interrupting you and to pause before something like removing a second factor. Treat it as a convenience rather than a control: the thing that actually decides what an assistant may do is your own role, checked on every call.

What is not included yet

Deleting users, creating or editing SSO connections, billing, backups and client secrets are all absent from this release. Deletion belongs with your erasure queue rather than beside it; an SSO connection is large enough that a wrong edit locks out an entire workforce, so those are read-only for now; and a tool that returns a client secret would put it in an assistant's transcript. Tell us which of these you want and in what order.

MCP server authentication

If you expose a Model Context Protocol server, Authagonal can be the authorization server behind it. An AI assistant connects, the person behind it signs in and grants access, and your server receives a normal bearer token you validate like any other API.

The alternative is an API key pasted into an assistant's configuration, which is a credential with no user behind it, no expiry, no consent step and no way to revoke one connector without rotating for everyone. Doing it as OAuth means the grant belongs to a named person, is visible in your audit log, and can be revoked from the portal without touching anything else.

How the connection happens

The whole exchange is driven by discovery, so a conformant client needs nothing configured but your server's URL.

StepWhat happens
1The connector calls your MCP server with no token and gets a 401 naming where to look.
2It fetches your protected-resource metadata, which names your Authagonal tenant as the authorization server.
3It fetches the tenant's authorization-server metadata and, having never been registered anywhere, registers itself.
4It sends the user to sign in and approve the access, naming your MCP server as the resource it wants a token for.
5It calls your server again with the resulting bearer token, which is scoped to your server and to that user.

Nothing in that sequence is Authagonal-specific: it is the MCP authorization spec, built on RFC 9728 for the resource metadata, RFC 8414 for discovering the authorization server, RFC 7591 for registration and RFC 8707 for naming the resource. A connector that follows the spec works without special-casing us.

Let connectors register themselves

A connector you have never met cannot use a client you created by hand, so it registers one at runtime. That is off by default. Turn on dynamic client registration in Settings and the registration endpoint appears in your discovery document; leave it off and the endpoint is not advertised and refuses. Enabling it opens registration for your tenant alone, never anyone else's.

GuardWhat happens
Grant typesOnly the authorization code and refresh flows can be registered. Self-registration cannot mint a machine-to-machine client that would bypass a user entirely.
PKCEForced on every registered client, regardless of what the registration asked for.
ConsentForced too. A registered connector cannot get a token until a person has seen what it is asking for and approved it.
ScopesThe OIDC built-ins are always available. Beyond those, a self-registering client may ask only for a scope named mcp, and only once your tenant defines it without role restrictions. roles and groups cannot be self-registered.
Rate limitTen registrations per IP address per hour, so an open endpoint cannot be used to fill your client store.

Both discovery paths are served

MCP clients resolve the authorization server through /.well-known/oauth-authorization-server (RFC 8414), while OIDC clients use /.well-known/openid-configuration. Your tenant answers on both with the same metadata, so a connector following the MCP spec finds you without being told where to look.

What your MCP server implements

Two small things, and then it is an ordinary resource server. First, publish protected-resource metadata naming your tenant as the authorization server. Serve it at the well-known path and, if your MCP endpoint is on a sub-path, at the path-suffixed form too, since clients try both.

Protected-resource metadata
GET https://your-app.example/.well-known/oauth-protected-resource

{
  "resource": "https://your-app.example/mcp",
  "authorization_servers": ["https://acme.authagonal.io"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["mcp"]
}

Second, when a call arrives without a valid token, answer 401 with a WWW-Authenticate header pointing at that metadata. That header is what turns a refusal into a connection: without it the client has no way to discover where to authenticate, and simply fails.

Validating the token
// Validate the connector's token like any other resource server.
builder.Services.AddAuthentication().AddJwtBearer("McpBearer", o =>
{
    o.Authority = "https://acme.authagonal.io";              // your tenant
    o.TokenValidationParameters.ValidAudience = "https://your-app.example/mcp";
});

// Unauthenticated? Point the connector at the metadata rather than just refusing.
http.Response.Headers.WWWAuthenticate =
    "Bearer resource_metadata=\"https://your-app.example/.well-known/oauth-protected-resource\"";
return Results.Unauthorized();

Check the audience, not just the signature

Validate that the token was issued for your server. The connector names your MCP server as the resource, so the token's audience is your resource URL. A resource server that checks only the signature and the issuer will accept a token minted for a different resource in the same tenant, which is how one connector's access becomes another's.

Scopes, plans and revocation

Define a scope named mcp on your Scopes page, without role restrictions, and a self-registering connector may request it; it appears on the consent screen so the user can see what they are approving. The token's subject is the person who signed in, so your server can decide what this particular person may do rather than treating every connector alike. A self-registered connector cannot request the roles or groups scopes, so look those up on your side rather than expecting them in the token.

Because it is an ordinary OAuth grant, revocation works the ordinary way: the user removes the connector on the Authorized Apps page of their account, which drops its grant and refresh token so it cannot renew. Its existing access tokens are refused at once by /connect/introspect and /connect/userinfo; a server that only validates the JWT locally accepts one until it expires. The audit log records each sign-in with the client it was for.

SettingWhat happens
Dynamic client registrationPortal setting that allows connectors to register themselves. Off by default.
mcpThe one scope beyond the OIDC built-ins that a self-registering client may request. Define it in your tenant, without role restrictions, to offer it.
resourceThe parameter a connector sends to name your MCP server, which narrows the token's audience to it.

Build a custom login UI

Replace Authagonal's hosted login, registration, password-reset and MFA screens with your own UI, while Authagonal keeps handling authentication, MFA, SSO, sessions and token issuance. Two routes: use our React component library, or call the auth API directly from any framework. It's opt-in: enable Custom login UI under Settings → Session first.

A fully customized hosted login page with a tenant logo, brand color, and support email in the footer

Prerequisite: a custom domain on your root

The login session is a first-party cookie, so your UI and the Authagonal auth server must share a registrable domain. Point a custom auth domain at Authagonal on the same root your app runs on — e.g. auth at login.acme.com, app at app.acme.com. The Custom login UI setting stays disabled until an active custom domain exists.

Your UIAuth hostWorks?
app.acme.comlogin.acme.com✅ same root
acme.comauth.acme.com✅ same root
app.acme.comacme.authagonal.io❌ cross-site
myapp.iologin.acme.com❌ cross-site

Why a custom domain is required

A cross-site session cookie would be a third-party cookie — which browsers (Safari, Chrome) are phasing out. Keeping auth on your own root makes the cookie first-party and future-proof, and it's what the platform enforces: cross-origin auth calls are only honoured from an origin that shares the auth host's root domain.

No client CORS setting is needed for the /api/auth calls: once Custom login UI is on, any origin on the same registrable domain as your auth domain is admitted automatically. Add your UI's origin (e.g. https://app.acme.com) to the client's Allowed CORS Origins (Clients → URIs) only if the browser also performs the token exchange.

React: @authagonal/login

npm i @authagonal/login ships the auth logic and UI as one package — the same one Authagonal's hosted login is built on. Pick your altitude:

  • Full app — drop in App and theme it via branding.
  • Compose pages — use LoginPage, MfaChallengePage, ResetPasswordPage… inside your own layout.
  • Primitives + logic — build your own screens with AuthLayout/Button/Input and the API client (login, mfaVerify, forgotPassword, …).
A custom screen using the @authagonal/login API
import { AuthLayout, Input, Button, login, ApiRequestError } from '@authagonal/login';

// returnUrl: the /connect/authorize URL your login page was opened with
function MyLogin({ returnUrl }: { returnUrl: string }) {
  async function onSubmit(email: string, password: string) {
    try {
      const res = await login(email, password, returnUrl);  // POST /api/auth/login (sets the session cookie)
      if (res.mfaRequired) {/* render your MFA step, then mfaVerify(res.challengeId!, 'totp', code) */}
      else if (res.mfaSetupRequired) {/* enrol first: mfaTotpSetup(res.setupToken) */}
      else window.location.href = returnUrl;                 // resume /connect/authorize
    } catch (e) {
      if (e instanceof ApiRequestError) {/* show e.message; e.error is the error code */}
    }
  }
  return <AuthLayout>{/* your own markup + <Input/> <Button/> */}</AuthLayout>;
}

Any framework: call the auth API

Not on React? Call the auth-flow endpoints directly (under /api/auth), then hand off to the standard OIDC /connect/authorize flow. Send credentials: 'include' so the session cookie is stored.

EndpointPurpose
POST /api/auth/loginAuthenticate; returns mfaRequired or a return URL
POST /api/auth/registerSelf-service registration (when enabled)
POST /api/auth/forgot-passwordStart a password reset
POST /api/auth/reset-passwordComplete a password reset
GET /api/auth/password-policyPassword policy (to render the rules)
POST /api/auth/mfa/*MFA setup + verification (TOTP, WebAuthn, recovery)

Use credentials: 'include'

The session is a cookie, so your fetches must send credentials. Cross-origin calls only succeed when Custom login UI is enabled and your origin shares the auth host's root domain — otherwise they're refused with 403.
Authenticate, then hand off to OIDC
# 1. Authenticate (browser fetch; credentials:'include' so the session cookie is stored)
curl -i -X POST https://login.acme.com/api/auth/login \
  -H "Content-Type: application/json" \
  -H "Origin: https://app.acme.com" \
  --data '{"email":"[email protected]","password":"..."}'
# (handle {"mfaRequired":true} → POST /api/auth/mfa/verify, then continue)

# 2. Hand off to the OAuth flow: top-level navigation to the authorize endpoint with PKCE:
# https://login.acme.com/connect/authorize?client_id=my-app&redirect_uri=...&response_type=code
#   &scope=openid%20profile%20email&code_challenge=...&code_challenge_method=S256
# The session cookie (same-site) authenticates the user; you get back a code → exchange at /connect/token.

One Tenant, Many Customers

A pattern for one integrator, one tenant, and N branded customers — each with their own login domain and their own users — without a tenant per customer. This is what declarative provisioning and organizations exist to turn into a config change instead of an engineering task.

The shape

  • One organization per customer, inside a single tenant.
  • One branded login domain per customer, each pinned to that customer's organization — signing in on it can only ever mint a token for that organization.
  • The application is served per customer host too, all from the same deployment — nothing about the code, build or image differs between customers.
  • The relying party picks its authority from the host it's running on, not from a build-time constant — the same bundle wires itself to a different login domain depending on which customer host served it.

Step by step

Provision the customer. Call the declarative provisioning endpoint with a spec naming the new organization and its domain — the call is idempotent and section-scoped.

Provisioning spec
{
  "organizations": [
    { "slug": "acme", "name": "Acme Pty Ltd" }
  ],
  "customDomains": [
    { "domain": "login.acme.example", "organizationSlug": "acme" }
  ]
}

Point the customer's DNS at the tenant. One CNAME from the customer's own zone, proving control and intent — no TXT token.

CNAME record
_authagonal-challenge.login.acme.example.  CNAME  <slug>.<platformDomain>.

Same Cloudflare account as the platform?

The record must be DNS-only (unproxied) — a proxied CNAME between two zones in the same account never reaches the platform's SaaS custom hostname. A customer zone in a different account has no proxy toggle to get wrong at all.

Point the application at the customer's host. The relying party resolves its own OIDC authority per request, by the host it's currently serving — a host with no entry in the customer map falls back to the classic single-tenant derivation, so adding a customer is purely additive.

oidc.ts
export function resolveCustomer(): { authority: string; clientId: string } {
  const cfg = getConfig();
  const customer = cfg.customers?.[window.location.host];
  if (customer) return customer;
  const baseDomain = cfg.baseDomain || window.location.hostname.replace(/^consumer\./, '');
  return { authority: `https://${cfg.tenantSlug}.${baseDomain}`, clientId: cfg.clientId };
}

The client asks for nothing organization-specific — no organization parameter, no per-customer scope. The domain pin does the organization selection entirely server-side, which is what makes "the app is the same code for every customer" actually true.

What the customer sees

The login page renders each organization's own branding merged over the tenant's — see Branding override. Each customer can have its own app name, logo, color and support email without touching the others.

A user of customer A who tries to sign in on customer B's host is refused, not shown customer B's data — in one of two shapes depending on how the conflict arose:

  • The relying party sends nothing and just lands on the pinned domain — the pin supplies the organization on the user's behalf, selection is then explicit, and a customer-A user with no membership in B is refused at the point the token would be minted.
  • The relying party names a different organization itself — refused directly by the domain-pinning middleware with a 400, before any login page is shown.

The membership refusal redirects back to the app

The first shape above isn't a broken login page — it's an ordinary error=access_denied OIDC redirect back to the relying party's own redirect_uri, with a description naming exactly which organization membership was missing.

Onboarding customer N+1

With Terraform driving the provisioning call, onboarding another customer is an edit to one file: another entry in the organizations array, another domain entry pinning it, another DNS record. Nothing about the application, its deployment or its build changes — the host-based resolver picks the new customer up the moment its config entry exists.

Reference implementation

A persistent dev tenant exercises this whole pattern end to end against two real customer domains, with an end-to-end test suite that provisions members, checks branding and the org_id claim, and asserts both refusal shapes above — executable documentation, not just a description.

Run it locally
cd e2e-consumer && DOMAIN=authagonal.dev npx playwright test organizations-domains

The suite runs by default like every other spec; it can be skipped in an environment where the reference domains aren't provisioned, since it's the one spec in the suite that depends on infrastructure outside the repo's own control.

Plans & Limits

Authagonal offers a Free plan and four paid tiers. Every plan includes every authentication feature. Plans differ in the Monthly Active User (MAU) limit and overage pricing, and the Free plan has community support only, without the tenant support desk.

Plan Tiers

PlanMAU LimitOverageOverage Cost/User
Free250No—
Starter1,000No—
Pro5,000Yes$0.04/user
Scale25,000Yes$0.025/user
Enterprise100,000Yes$0.015/user

Monthly Active Users (MAU)

A Monthly Active User is any unique user who successfully authenticates at least once during a calendar month (UTC). Users provisioned via SCIM but who haven't logged in don't count toward your MAU total.

Overage: if your plan supports overage (Pro and above) and overage is enabled for your tenant (it is off by default), users beyond the MAU limit are billed at the per-user rate shown in the plan table above. An overage cap sets the maximum number of extra users allowed beyond the limit.

Enforcement: if your plan doesn't support overage (Free, Starter) or overage isn't enabled, users who have already signed in this month always keep access. When the tenant first exceeds its limit, a 10-day grace period starts during which new users can still sign in. After it, users who haven't signed in this month are refused until the next month or until you upgrade.

Full Feature Set on Every Plan

All plans include the full feature set — SSO, SCIM, MFA, custom domains, branding, webhooks, audit log, and the portal.