OAuth 2.1 / OIDC endpoints
Auth Service exposes an OAuth 2.1 / OIDC authorization server powered by @better-auth/oauth-provider. The OAuth endpoints are served under the /api/auth/ prefix; the two discovery documents are served at the issuer root.
Discovery
OIDC discovery document
GET /.well-known/openid-configuration
Returns the OIDC Provider Metadata document (OIDC Discovery 1.0). Clients and libraries should auto-configure themselves from this endpoint: it lists the endpoints below, the supported scopes and the ID token signing algorithm. It is not served under /api/auth/ (/api/auth/.well-known/… answers 404). The issuer is the instance root URL (BETTER_AUTH_URL) without a trailing slash, and it is the iss of every token.
Authorization server metadata (RFC 8414)
GET /.well-known/oauth-authorization-server
Equivalent OAuth 2.0 authorization server metadata document.
JSON Web Key Set
GET /api/auth/jwks
The public key set used to verify JWT access tokens and ID tokens. Keys are Ed25519 (kty: OKP, alg: EdDSA) by default: configure your JWT library for EdDSA, an RS256-only verifier rejects every token. With JWT_PRIMARY_ALG=RS256 (the Okta, Auth0 and Keycloak compatibility profiles) the keys are RSA. Keys rotate every 30 days and the previous key stays published for a further 30 days, so cache the set by kid and refetch when a kid is unknown.
Core endpoints
Authorization endpoint
GET/POST /api/auth/oauth2/authorize
Initiates the authorization code flow. Required parameters:
| Parameter | Description |
|---|---|
client_id | Application slug |
response_type | Must be code |
redirect_uri | Must match one of the application's redirectUris |
scope | Space-separated scope list |
code_challenge | PKCE code challenge (required for every client, public or confidential) |
code_challenge_method | Must be S256 |
resource | RFC 8707 resource indicator — set to a configured audience URL to receive a JWT |
prompt | Optional. The discovery document advertises login, consent, create, select_account and none. login forces a new authentication even when an SSO session exists |
When resource is included and matches a registered protected resource (an application URL) the client is allowed to access (its own URL or an entry of its allowedResources), the issued access token is a signed JWT containing custom claims. Without resource, an opaque token is issued (unless the instance sets DEFAULT_AUDIENCE or AUDIENCE_PARAM, see Compatibility profiles).
When the user has no session, BetterAuth redirects to /login (the login page) carrying the complete signed OAuth parameters in the query string.
Token endpoint
POST /api/auth/oauth2/token
Exchanges an authorization code or refresh token for access/ID tokens.
Authorization code grant:
POST /api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)> # confidential clients
grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=<redirect_uri>
&client_id=<slug> # public clients (no Authorization header)
&code_verifier=<verifier> # PKCE, required for every client
Confidential clients authenticate with HTTP Basic (client_secret_basic); sending the secret in the body is refused, unless the instance sets TOKEN_ENDPOINT_AUTH_METHOD=post (then the body is the only accepted way). Add resource=<audience_url> to get a JWT when the authorization request carried several resources and you want one of them.
Client credentials grant:
POST /api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>
grant_type=client_credentials
&resource=<audience_url>
The default scope is m2m. Without resource the token is opaque.
UserInfo endpoint
GET /api/auth/oauth2/userinfo
Authorization: Bearer <access_token>
Returns claims about the authenticated user, computed for the scopes granted to the access token (see Supported scopes and OIDC claims). The application's client_attrs claim is included when its metadata is set.
Introspection and revocation
POST /api/auth/oauth2/introspect
POST /api/auth/oauth2/revoke
Both authenticate the client with client_secret_basic, client_secret_post or private_key_jwt, as advertised in the discovery document; a confidential client created by auth-service must use the one method it is registered for (TOKEN_ENDPOINT_AUTH_METHOD). Prefer local JWT verification against the JWKS for access tokens; introspect only opaque tokens or when you need an immediate revocation check.
End-session endpoint (RP-initiated logout)
GET /api/auth/oauth2/end-session?id_token_hint=<id_token>&post_logout_redirect_uri=<url>
GET and POST are accepted. The endpoint ends the central SSO session, for every application: there is no per-application variant. The calling application must have enableEndSession turned on, and post_logout_redirect_uri must match one of its postLogoutRedirectUris exactly. Without a usable id_token_hint (or when the session named by the hint is not the browser's current session), the user is asked to confirm on an auth-service page. See Sessions and sign-out for how this fits with application-only sign-out and back-channel logout.
Ending a session revokes the OAuth tokens bound to it: every access token is revoked, and so is every refresh token except one carrying offline_access, which is preserved on purpose (OIDC offline semantics — an offline client keeps working across a browser logout). Banning a user (POST /api/admin/users/:id/disable) or setting a password for them (POST /api/admin/users/:id/set-password) revokes every OAuth token they hold, offline_access refresh tokens included.
Back-channel logout (OIDC Back-Channel Logout 1.0)
When an application registers a backchannelLogoutUri, every session end — a user sign-out, an administrator ending a session, or an RP-initiated logout — makes the service POST a signed logout token to that endpoint, in addition to the token revocation above. This is how a relying party learns that the shared SSO session is over and drops its own local session. Front-channel logout is not supported. See the third-party integration guide for what an application must implement.
Sessions and sign-out
A user signed in to several applications holds one central auth-service session. "Signing out" can mean three different things:
| Level | What happens | How | Effect on the other applications |
|---|---|---|---|
| 1. Sign out of the application only | The application clears its own session. The central SSO session stays alive. | Done entirely by the application. It can also revoke its refresh token with POST /api/auth/oauth2/revoke. | None. The next authorization request signs the user back in silently, unless the application sends prompt=login to force a new authentication. |
| 2. Sign out of the central session | The auth-service session ends. | RP-initiated logout: GET /api/auth/oauth2/end-session with id_token_hint and a registered post_logout_redirect_uri. Requires enableEndSession on the application. | The session ends for all applications: there is no per-application variant. |
| 3. Propagation to the applications | Every application with a backchannelLogoutUri is told that the session ended. | Back-channel logout: the service POSTs a logout_token (form-encoded) to each registered endpoint whenever a session ends anywhere (user sign-out, end-session, administrator revocation). The application must have stored the sid of the session at login. | Each notified application drops its own local session for that sid. |
Details that matter when implementing these levels:
sidin ID tokens. An ID token carriessidonly when the application hasenableEndSessionor abackchannelLogoutUri. Logout tokens always carrysubandsid, so an application that wants back-channel logout must keep thesidit received at sign-in.- Who is notified. The service sends a logout token to every client that has a
backchannelLogoutUriand holds tokens bound to the ended session. Delivery is a single attempt with a 5-second timeout and is not retried; a status other than200or204is only logged. - Tokens. Ending a session revokes the access tokens bound to it and the refresh tokens that lack
offline_access.offline_accessrefresh tokens survive. - End-session confirmation.
post_logout_redirect_uriis used only when it matches a registered entry exactly; theid_token_hintmust be an ID token issued to the calling application. Otherwise the user lands on a confirmation page instead of being redirected silently. - Access revocation is not a sign-out. Revoking a user's access to one application through the admin API (
isActive: falseorDELETE /api/admin/applications/:id/users/:userId) revokes that application's tokens only. It does not end the SSO session and sends no back-channel logout.
Supported scopes
| Scope | Claims added to token |
|---|---|
openid | Required for OIDC. Adds sub. |
profile | name, company, picture, updated_at (each only when the user has a value) |
email | email, email_verified |
phone | phone_number (when the user has one) |
offline_access | Issues a refresh token (requires the refresh_token grant, which every application has). The refresh token survives a session end |
roles | roles — array of the user's role names in the application |
permissions | permissions — array of permission strings: resource for a read permission, resource.write for a write permission |
features | features — JSON object from the user's active subscription plan ({} when there is none or it expired); also plan, the plan's name, when a non-expired plan exists |
wallet:self:read | No extra claim; authorizes the signed-in user's wallet read API when resource=<BETTER_AUTH_URL> |
org | org_id — the organization the authorization is bound to, in the access token (a JWT, so resource is needed). A user in one organization gets it implicitly, a user in several is sent to /select-org at sign-in to choose, and a user in none gets no org_id |
An application can only be granted scopes listed in its allowedScopes. Instances using a compatibility profile may add scopes through CLAIM_MAPPINGS.
JWT token structure
When resource is included in the authorization request and matches a configured audience, the access token is a signed JWT:
{
"iss": "https://auth.example.com",
"sub": "<user_id>",
"aud": ["https://api.example.com", "https://auth.example.com/api/auth/oauth2/userinfo"],
"azp": "my-app",
"client_id": "my-app",
"sid": "<session_id>",
"jti": "<token_id>",
"exp": 1234567890,
"iat": 1234567890,
"email": "user@example.com",
"email_verified": true,
"name": "Alice Smith",
"roles": ["editor"],
"permissions": ["documents.write", "reports"],
"features": { "maxProjects": 50, "apiAccess": true },
"plan": "pro",
"scope": "openid profile email roles permissions features wallet:self:read org",
"org_id": "org_01hwxyz..."
}
The header is {"typ":"at+jwt","alg":"EdDSA","kid":"…"} (alg is RS256 when the instance signs with RS256) and the lifetime is one hour. aud is an array: the requested resource, plus the userinfo endpoint when the openid scope is present, so check that it contains your resource URL (JOSE libraries do this when you pass audience). azp is the client_id of the application that obtained the token. plan is the name of the user's active subscription plan for that application. The claims shown depend on the granted scopes; the application's client_attrs claim is added when its metadata is set.
Consent page
When skipConsent is false for an application, BetterAuth redirects to /oauth2/consent after authentication. The Vue admin SPA handles this route when the embedded frontend is present.
Login pages
BetterAuth redirects to /login when the authorization endpoint is called without a session. When TEMPLATES_DIR is set and the request carries a client_id, Auth Service renders the first template found among:
TEMPLATES_DIR/<appSlug>/login.htmlTEMPLATES_DIR/default/login.html- The built-in template shipped with auth-service
Without TEMPLATES_DIR, for the service's own sign-in (no client_id), or when the template cannot be rendered, the Vue SPA index.html is served instead.
The login page receives the full OAuth query string so the authorization flow can be resumed after authentication.