Protocols

OAuth2 & OIDC

Authalla implements OAuth2 and OpenID Connect for standards-based authentication. For the high-level flow and security model, start with Authentication overview.

These are protocol endpoints, described by the standards below and by each tenant's discovery document rather than by an OpenAPI spec. The REST APIs are covered in Choosing an API.


Create an app

If you have an Authalla account, you already have a default tenant. Create your app in Apps → Create App, add redirect URIs, and save the client ID (and, for a confidential app, the client secret, shown once).

The OAuth2 specs call an app a client; that's why its ID is client_id. The same goes for client_secret, the client_credentials grant and the client type (public or confidential).

Redirect URIs

The redirect_uri your app sends must match a registered redirect URI exactly, with one exception for local development: an http URI on localhost, 127.0.0.1 or [::1] matches on any port. Register http://localhost/callback once, and http://localhost:3000/callback, http://localhost:5173/callback and any other port work for every app. The host, path and query must still match. localhost, 127.0.0.1 and [::1] are separate hosts, so register only the one your app uses; for most apps that is a single http://localhost/... entry. This is the loopback rule of RFC 8252, which OAuth 2.1 also requires. It doesn't apply to https URIs or to logout URIs, which always match exactly.

Preview deployments

Wildcards aren't supported. A redirect or logout URI with a * in its host or path, such as https://*.preview.acme.dev/callback, is refused when you save the app, because an exact match would only ever accept that literal string. Instead:

  • Give previews stable hostnames. If your host can serve each branch on a predictable address (https://staging.acme.dev), register those once.
  • Add each preview as it's deployed. Have your deploy pipeline add the preview's redirect URI with the Management API (PUT /management/v1/tenants/{tenant_id}/apps/{app_id} replaces the whole list, so send the existing URIs too), and remove it when the preview is torn down.
  • Use a separate app for previews, in a separate tenant if you want preview sign-ins kept apart from production, so the production app's list stays short.

For browser-based apps, configure tenant Allowed origins in Tenants → (select tenant) → General → Allowed origins.

After creating the app, use the AI Integration Assistant prompt in the post-create screen or Apps → (select app) → Integration. It includes the discovery URL and client ID (but not the client secret) and generates OAuth 2.1-compliant setup code.


Rotate a client secret

A confidential app can hold up to three secrets, and every one that has not expired authenticates it. That is what makes a rotation downtime-free:

  1. Add a secret in Apps → (select app) → Overview → Secrets. Its value is shown once — Authalla stores only a hash and cannot show it again.
  2. Deploy the new secret to your application. Both secrets work while you do.
  3. Expire the old one, immediately or with a grace period of 24 hours, 7 days or 30 days. An expired secret stops authenticating the app at once.
  4. Delete the expired secret to free its slot. Deleting is only possible once a secret has expired, so a live credential cannot be removed by accident. Authalla deletes secrets that expired more than 30 days ago on its own.

Secrets look like ath_cs_ followed by 43 letters and digits, so they survive copy-paste, shell quoting and .env files intact. Secrets issued in the older format keep working; nothing forces you to rotate.

The same lifecycle is available over the Management API (/apps/{app_id}/secrets) and to agents over the MCP server.


Common endpoints

  • https://{tenant-id}.authalla.com/oauth2/authorize
  • https://{tenant-id}.authalla.com/oauth2/token
  • https://{tenant-id}.authalla.com/oauth2/userinfo
  • https://{tenant-id}.authalla.com/oauth2/revoke (token revocation)
  • https://{tenant-id}.authalla.com/oauth2/introspect (token introspection)

Authorization request (PKCE)

import * as client from 'openid-client'

const issuer = new URL('https://{tenant-id}.authalla.com')
const clientId = 'client_123'
const redirectUri = 'https://app.example.com/oauth/callback'

const config = await client.discovery(issuer, clientId)

// Generate per-login and store these in the user session
const codeVerifier = client.randomPKCECodeVerifier()
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier)
const state = client.randomState()

const authorizationUrl = client.buildAuthorizationUrl(config, {
  response_type: 'code',
  client_id: clientId,
  redirect_uri: redirectUri,
  scope: 'openid profile email',
  state,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
})

console.log(authorizationUrl.href)

Authalla requires the openid scope and a state parameter. Public apps must use PKCE.

The authorize endpoint takes the parameters in the query string of a GET or as a form POST (application/x-www-form-urlencoded). Request objects, the request and request_uri parameters, are not supported: a request with either is refused with request_not_supported or request_uri_not_supported.

To send the user straight to one of your organizations' single sign-on, add organization with its ID (org_…). The user skips the email step, and the tokens carry the organization as org_id.

The ID token carries only the user claims the scopes ask for: email and email_verified with email, name and picture with profile. See ID token claims.


Discovery

Every tenant publishes an OpenID Connect discovery document. Point your OAuth library at the issuer and it will find the endpoints itself:

https://{tenant-id}.authalla.com/.well-known/openid-configuration

On a custom domain the discovery URL, and the iss claim in the tokens, use that domain instead.

What the document advertises:

FieldValue
response_types_supportedcode
grant_types_supportedauthorization_code, refresh_token, client_credentials
code_challenge_methods_supportedS256
scopes_supportedopenid, profile, email, offline_access, and the User API scopes sessions:read, sessions:write, connected-apps:read, connected-apps:write
id_token_signing_alg_values_supportedRS256
token_endpoint_auth_methods_supportedclient_secret_post, client_secret_basic, none
revocation_endpoint/oauth2/revoke
revocation_endpoint_auth_methods_supportedclient_secret_post, client_secret_basic, none
introspection_endpoint/oauth2/introspect
introspection_endpoint_auth_methods_supportedclient_secret_post, client_secret_basic
claims_supportedsub, iss, aud, exp, iat, auth_time, sid, org_id, email, email_verified, name, picture, external_id
request_parameter_supportedfalse
request_uri_parameter_supportedfalse

Signing keys are published at /.well-known/jwks.json.


Machine-to-machine tokens (client_credentials)

A confidential app can exchange its own credentials for an access token with no user involved. Use this for server-to-server calls to the Application API. (SCIM provisioning doesn't use this grant: it takes an organization's SCIM token.)

curl -X POST https://{tenant-id}.authalla.com/oauth2/token \
  -d grant_type=client_credentials \
  -d client_id={client-id} \
  -d client_secret={client-secret} \
  -d scope="users:read users:write"

The rules:

  • The app must be confidential (a public app cannot use this grant) and must have client_credentials among its allowed grant types.
  • A token is issued with the scopes the app is configured to hold: users:read and users:write for the Application API, and the scopes of your own resource servers.
  • The control-plane scopes (tenants:*, apps:*, domains:*, sender-addresses:*, theme:*, social-logins:*, resource-servers:*) belong to the Management API's account API key. Requesting one on this grant is rejected — see Account API keys.
  • If you request no scope at all, the token is issued with every scope the app is allowed to hold.

By default a registered app gets an authorization code as soon as the user has signed in. Turn on Require consent on the app (Apps → (select app) → Overview, or require_consent in the Management API) to show the user a consent screen first. It names the application and lists every requested permission in plain language. Approvals are remembered per user until the requested scopes change; a denial redirects to the app with error=access_denied. To withdraw an approval, and the app's tokens with it, open the user's Connected apps tab and choose Revoke (see Revoke access).

Clients that identify themselves with a Client ID Metadata Document (see below) always go through consent.


AI agents and Client ID Metadata Documents

MCP clients and other AI agents do not have an administrator who can register them in advance. Instead they identify themselves with an OAuth Client ID Metadata Document: the client_id they send is an https URL pointing at a JSON document that names the client and lists its redirect URIs. Authalla fetches the document, validates it (the document must name itself, describe a public client, and use only the authorization code flow with PKCE), and treats the URL as the client.

This is off by default. Turn on Let AI agents such as Claude and ChatGPT register themselves on a tenant (Tenants → (select tenant) → Resource servers, or allow_client_id_metadata_documents) to let such clients sign that tenant's users in. When it is on, the tenant's discovery document advertises client_id_metadata_document_supported: true and clients such as Claude Code, Claude Desktop and claude.ai use it automatically. Self-declared clients receive the standard OIDC scopes, plus the scopes of the resource server they request a token for. They always see the consent screen, and are shown to the user with a note that their name has not been verified.

Authalla's own MCP server works this way. To let agents connect to your own MCP server, see Build an MCP server with Authalla.


Resource indicators

Authalla supports RFC 8707 resource indicators for your own APIs and MCP servers. Register each one as a resource server in Tenants → (select tenant) → Resource servers, with its URL as the identifier and its own scopes under a prefix, such as crm:read.

Send resource=https://api.example.com on the authorization and token requests, and the access token's aud is that resource alone:

  • The resource must be a resource server registered in the tenant. Otherwise the request fails with invalid_target.
  • A resource server's scopes are issued only in tokens for that resource server, and only to users who hold them. A scope the user doesn't hold is dropped from the token and isn't shown on the consent screen.
  • A registered app still gets only the scopes allowed on the app. A self-declared client can request any of the resource server's scopes.
  • A refresh keeps a resource server scope only while the scope exists and the user still holds it.
  • Without resource, aud is the client ID and the token carries no resource server scopes.
  • A token issued for a resource server works only there. Authalla's own endpoints (userinfo, the Application API, the User API) accept only tokens whose aud is the client ID, so request those without resource.

Token revocation

An app ends its own grant with an RFC 7009 request, for example when the user signs out of it:

curl -X POST https://{tenant-id}.authalla.com/oauth2/revoke \
  -u {client-id}:{client-secret} \
  -d token={refresh-or-access-token} \
  -d token_type_hint=refresh_token
  • The app authenticates as at the token endpoint. A public app, including a self-declared client, sends client_id instead of a secret.
  • Revoking either kind of token revokes the whole grant: the refresh token and every access token issued from the same sign-in. A client_credentials access token is revoked on its own.
  • The user's remembered consent stays, so reconnecting the app skips the consent screen. To withdraw the consent too, revoke the app from the user's Connected apps tab.
  • An app can revoke only its own tokens. An unknown token, one already revoked, or another app's token gets 200 and nothing changes, so the endpoint doesn't reveal whether a token exists.
  • token_type_hint (access_token or refresh_token) is optional; Authalla looks for the token as either kind.
  • Failed client authentication gets 401 with invalid_client, and a request without token gets 400 with invalid_request.

A revoked access token keeps passing a local JWT check until it expires. Resource servers that introspect refuse it at once.


Token introspection

A resource server asks whether a token is still good with an RFC 7662 request. Unlike a local JWT check, the answer reflects revocation at once. Revoke access compares the two.

The caller is a confidential app of the same tenant that holds the tokens:introspect scope. Create one in Apps, add tokens:introspect on its Scopes tab, and authenticate with its secret directly; there is no token to fetch first.

curl -X POST https://{tenant-id}.authalla.com/oauth2/introspect \
  -u {client-id}:{client-secret} \
  -d token={access-token}

An active access token:

{
  "active": true,
  "token_type": "Bearer",
  "scope": "openid crm:read",
  "client_id": "https://agent.example.com/oauth/client-metadata.json",
  "sub": "user_abc123xyz",
  "aud": ["https://mcp.acme.com/mcp"],
  "iss": "https://acme.authalla.com",
  "exp": 1790000900,
  "iat": 1790000000,
  "jti": "5b0c…"
}
  • The values are the token's own claims. act is included when the token carries one (impersonation), and email and email_verified when it was granted the email scope.
  • A refresh token is answered too, with token_type: refresh_token and without aud and jti.
  • A token is inactive when it is revoked or expired, when its app or user has been deleted, or when its user isn't active. The answer is {"active": false} and nothing else. So is the answer for an ID token, a token from another tenant, and any other string.
  • Check aud and scope yourself: active: true says only that the token is live, not that it was issued for your server.
  • An app without tokens:introspect, a public app, or a wrong secret gets 401 with invalid_client.
  • Every call looks the token up, and the response carries Cache-Control: no-store.

Access token format

Access tokens are RS256-signed JWTs that follow RFC 9068. The header has typ: at+jwt, and the granted scopes are in a space-separated scope claim. The claims are listed in Sessions & tokens.

Authorization responses carry the RFC 9207 iss parameter.


Standards

Previous
Impersonation