Integration

Integrating a third-party application

Sign users in with auth-service from any web app, API or service, verify tokens, and use roles, plans and organizations in your own code.

Any application that speaks OAuth 2.1 / OpenID Connect can use auth-service as its identity provider. This guide walks through the whole path: register the application, sign users in, verify the tokens in your backend, and use the claims for authorization.

Browser / app ──(1) authorization code + PKCE──▶ auth-service
      ▲                                              │
      │                (2) JWT access token (EdDSA)  │
      │                                              ▼
      └──(3) Authorization: Bearer ──▶ your API ──(4) verify with the JWKS, read roles / plan / org_id

Choose a client type

Your applicationClient typeToken endpoint authentication
Single-page or native app, no backend of its ownPublic (isPublic: true)None. PKCE is mandatory
Web application with a server side (recommended for anything that can hold a secret)Confidential (default)HTTP Basic (client_secret_basic), plus PKCE
A service calling another service, no userConfidential, client_credentials grantHTTP Basic
An API that only receives bearer tokensNo client needed: it only verifies tokens—

1. Register the application

In the admin interface, Applications → Create Application. Once saved, the OAuth / Integration tab shows the client ID, the resource identifier, the endpoints and ready-to-paste code:

Or with the API:

POST /api/admin/applications
Content-Type: application/json

{
  "name": "Acme App",
  "slug": "acme-app",
  "url": "https://app.acme.com",
  "isPublic": false,
  "redirectUris": ["https://app.acme.com/callback"],
  "allowedScopes": ["openid", "profile", "email", "roles", "permissions", "features", "org", "offline_access"],
  "skipConsent": true,
  "allowRegister": false
}

What matters:

  • slug is your client_id.
  • url is the application's resource identifier. A JWT access token is issued only when the authorization request carries resource=<url> (or the instance adds it, see the callout in step 3); its aud is then that URL. It is also added to the allowed CORS origins.
  • redirectUris are matched exactly. Any other redirect_uri is refused.
  • skipConsent: turn it on for first-party applications; leave it off to show a consent screen.
  • A confidential application receives a clientSecret once, at creation. Store it in your secret manager; rotating it is POST /api/admin/applications/:id/rotate-secret and revokes the tokens issued with the previous secret.
  • To let a user sign out of every application from yours, turn on enableEndSession (see Sign out). To be told when the session ends elsewhere, set backchannelLogoutUri (see Back-channel logout).

The full field list is in Managing applications.

2. Give users access, roles and plans

Unless the application is public (isPublic: true) or has allowRegister: true, a user can only obtain a token for an application they have been granted: the request is refused otherwise, so your code never has to check "does this user belong here". A public or open-registration application instead gives access to any account on its first token request, and assigns the default role and plan. A user whose access was revoked stays refused.

  1. Define the roles and permissions of your application. Mark one role as isDefault to hand it to every user you grant access to.
  2. Optionally define subscription plans: their features end up in the token.
  3. Grant users: POST /api/admin/applications/:id/users with { "userId", "roleId?" }, or the bulk variant …/users/bulk. With allowRegister: true, users can also create their own account from your sign-in page.

The access record takes a single roleId, so give each user one role per application. If you need several levels, model them as roles whose permissions add up.

3. Sign users in

Use a maintained OIDC library; nothing here is specific to auth-service. The only points to respect are the discovery URL (/.well-known/openid-configuration at the issuer root), PKCE with S256, and the resource parameter.

With oauth4webapi (v3 API):

import * as oauth from "oauth4webapi";

const issuer = new URL("https://auth.example.com");
const as = await oauth
  .discoveryRequest(issuer)
  .then((r) => oauth.processDiscoveryResponse(issuer, r));

const client: oauth.Client = { client_id: "acme-app" };
const redirectUri = "https://app.acme.com/callback";
const resource = "https://app.acme.com"; // the application's url

// Step 1: send the user to auth-service
const codeVerifier = oauth.generateRandomCodeVerifier();
const state = oauth.generateRandomState();
sessionStorage.setItem("pkce", JSON.stringify({ codeVerifier, state }));

const authorizeUrl = new URL(as.authorization_endpoint!);
authorizeUrl.searchParams.set("client_id", client.client_id);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
authorizeUrl.searchParams.set("scope", "openid profile email roles permissions features offline_access");
authorizeUrl.searchParams.set("resource", resource);
authorizeUrl.searchParams.set("code_challenge", await oauth.calculatePKCECodeChallenge(codeVerifier));
authorizeUrl.searchParams.set("code_challenge_method", "S256");
authorizeUrl.searchParams.set("state", state);
location.href = authorizeUrl.toString();

// Step 2: in the /callback page
const saved = JSON.parse(sessionStorage.getItem("pkce")!);
const params = oauth.validateAuthResponse(as, client, new URL(location.href), saved.state);
const response = await oauth.authorizationCodeGrantRequest(
  as,
  client,
  oauth.None(),
  params,
  redirectUri,
  saved.codeVerifier,
  { additionalParameters: new URLSearchParams({ resource }) },
);
const tokens = await oauth.processAuthorizationCodeResponse(as, client, response);
// tokens.access_token, tokens.id_token, tokens.refresh_token
Without resource, the access token is an opaque string that only the introspection endpoint can read. Always send the application's url as resource to get a JWT your API can verify locally. (An instance started with DEFAULT_AUDIENCE enabled, as the Okta and Keycloak compatibility profiles do, adds the application's url when a request carries no resource.)

What the access token contains

{
  "iss": "https://auth.example.com",
  "sub": "<user id>",
  "aud": ["https://app.acme.com", "https://auth.example.com/api/auth/oauth2/userinfo"],
  "azp": "acme-app",
  "client_id": "acme-app",
  "email": "alice@example.com",
  "email_verified": true,
  "name": "Alice Smith",
  "roles": ["admin"],
  "permissions": [],
  "features": {},
  "plan": "free",
  "scope": "openid profile email roles permissions features offline_access",
  "sid": "<session id>",
  "iat": 1792835793,
  "exp": 1792839393,
  "jti": "…"
}

The header is {"typ":"at+jwt","alg":"EdDSA","kid":"…"}. The token lives for one hour. The claims present depend on the granted scopes: roles, permissions, features and plan need their own scope, and org_id needs the org scope. Claims are described in OIDC claims.

4. Verify tokens in your API

Verify the signature locally against the key set, with any JOSE library. Never decode a token without verifying it.

import { createRemoteJWKSet, jwtVerify } from "jose";

const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/api/auth/jwks"));

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    algorithms: ["EdDSA"],
    issuer: "https://auth.example.com",
    audience: "https://app.acme.com", // matches when the array `aud` contains it
  });
  return payload;
}

Checklist for a resource server:

  • Accept only the algorithm of the instance (EdDSA by default, RS256 with a compatibility profile), and pin iss to BETTER_AUTH_URL exactly (no trailing slash).
  • Require aud to contain your own URL: a token minted for another application must be refused.
  • Cache the key set by kid, refetch when you meet an unknown kid (keys rotate monthly, the previous key stays published for another month).
  • Use azp if only some client applications may call your API.
  • Do not call /userinfo per request: everything you need is in the token.

5. Authorize with the claims

const payload = await verifyAccessToken(bearer);

const roles = (payload.roles as string[]) ?? [];
const permissions = (payload.permissions as string[]) ?? [];
const features = (payload.features as Record<string, unknown>) ?? {};

// "documents" = read, "documents.write" = write
if (!permissions.includes("documents.write")) throw new Forbidden();

// plan and feature flags
if (features.apiAccess !== true) throw new PaymentRequired();

// multi-tenant data: scope every query by the organization
const orgId = payload.org_id as string | undefined; // request the `org` scope

Roles, permissions and plan changes apply to the next token, up to one hour later for a token already issued. Keep sessions short, or refresh often, where that matters.

6. Sign out

Two levels, to choose consciously:

  • Local sign-out: clear your own session and tokens, and revoke your refresh token with POST /api/auth/oauth2/revoke (form-encoded token and token_type_hint=refresh_token, with the same client authentication as the token endpoint). The central session is untouched: the user stays signed in to auth-service and to the other applications, and no back-channel notification is sent. This is the default and the right choice for most applications.
  • Global sign-out (OIDC RP-initiated logout): send the browser to GET /api/auth/oauth2/end-session. It ends the central session for all applications; there is no per-application variant. It needs enableEndSession: true on your application.
Query parameterMeaning
id_token_hintThe ID token you received at sign-in. It must be signed by this instance, have your client_id as aud, and carry sid and sub. Its expiry is not checked
client_idYour client_id. Optional when id_token_hint is sent
post_logout_redirect_uriWhere to send the browser afterwards. It must match, exactly, an entry of the application's postLogoutRedirectUris
stateAdded as a state query parameter to the redirect
GET /api/auth/oauth2/end-session?id_token_hint=<id_token>&client_id=acme-app&post_logout_redirect_uri=https://app.acme.com/&state=<state>

What happens:

  • With a valid id_token_hint for the session the browser currently holds (or for a session the browser no longer holds), that session is ended right away.
  • Without a valid id_token_hint, or when the hint names another session than the browser's current one, the user gets a confirmation page instead and the session ends only once they confirm.
  • The browser is redirected to post_logout_redirect_uri only when it exactly matches a registered entry. Otherwise the session still ends and a plain confirmation page is shown. When you create an application with a url and no postLogoutRedirectUris, <url>/ is registered for you.

Whichever way the central session ends (end-session, or a sign-out in auth-service itself), the service:

  1. revokes the access tokens bound to that session. A JWT that your API verifies locally is not re-checked and stays valid until its exp (one hour); introspection and /userinfo do report it as inactive;
  2. revokes the refresh tokens that have no offline_access scope, and keeps the offline_access ones on purpose. A refresh token is only issued when offline_access is granted, so in practice your refresh token survives the logout: delete or revoke it yourself if it must not outlive the user's session;
  3. sends a logout token to the backchannelLogoutUri of every application that holds tokens bound to that session, including the one that started the sign-out (see Back-channel logout).

The request that ends the session waits for these deliveries (at most 5 seconds each): a slow back-channel endpoint delays the sign-out response.

7. Back-channel logout

The session is central: a user who signs out anywhere ends it for every application, and a post_logout_redirect_uri only covers the browser that made the call. To be told when the session ends, whichever device or application ended it, implement OIDC Back-Channel Logout. The service then makes a server-to-server POST to your endpoint.

Set up

  1. Have an administrator set backchannelLogoutUri on your application: in the admin interface, the Advanced step of the application form (Back-channel logout endpoint), or the backchannelLogoutUri field of POST / PATCH /api/admin/applications. The admin API accepts only a public https:// URL (no credentials, no fragment, no loopback or private address), and the auth-service server itself must be able to reach it. backchannelLogoutSessionRequired is optional and does not change what is sent: every logout token carries both sub and sid.
  2. At sign-in, store the sid and sub claims of the ID token next to your local session. sid is present in ID tokens only when the application has enableEndSession or a backchannelLogoutUri. If an ID token has no sid, no logout could ever reach that session: treat it as a configuration error.
  3. Expose the endpoint. It is called with no cookie and no credentials, so it must not sit behind your session or CSRF protection.

The request your endpoint receives

POST /backchannel-logout HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Accept: application/json

logout_token=<JWT>

The JWT header has typ: logout+jwt; it is signed with the same key set as ID tokens. Its payload:

{
  "iss": "https://auth.example.com",
  "aud": "acme-app",
  "sub": "<user id>",
  "sid": "<session id>",
  "iat": 1792835793,
  "exp": 1792835913,
  "jti": "…",
  "events": { "http://schemas.openid.net/event/backchannel-logout": {} }
}

aud is your client_id and exp is 120 seconds after iat. There is no nonce.

What your endpoint must do

Validate the token before acting on it. Reject it with 400 if any check fails:

  1. Verify the signature against the JWKS (/api/auth/jwks), with the algorithm of your instance pinned and none refused.
  2. iss is the issuer exactly, and aud is your client_id.
  3. iat is present and exp is not in the past.
  4. The header typ is logout+jwt.
  5. events is an object that contains the key http://schemas.openid.net/event/backchannel-logout.
  6. There is no nonce claim.
  7. jti has not been seen before. Keep each jti until its exp, and refuse a replay.
  8. sid (or sub) is present. This service always sends both.

Then:

  1. End every local session whose stored sid equals the token's sid. If you also keep per-user state, use sub.
  2. Answer 200 or 204 within 5 seconds. The service makes a single attempt, never retries, does not follow redirects, and only logs any other status or a timeout as a delivery failure.

Make the handler idempotent

The service posts to every application that holds tokens bound to the ended session, and that includes the application that started the sign-out. When your own global sign-out (end-session) triggers it, your local session is probably already gone by the time the logout token arrives. Answer 200 anyway when no session matches, as long as the token is valid and unused.

Tokens and grants after the logout

The service has already revoked the access tokens bound to the session. A JWT your API verifies locally stays valid until its exp; use introspection if you need the revocation to take effect at once. Refresh tokens that were issued with offline_access (which, in practice, is every refresh token) are not revoked: the application decides what to do with its own grant. Delete the stored refresh token, and revoke it with POST /api/auth/oauth2/revoke, unless you deliberately keep it for background work.

Worked example

The gine project implements this recipe as a confidential Python (Authlib) backend-for-frontend: the endpoint is POST /api/v1/auth/backchannel-logout in services/orchestrator/app/api/auth_routes.py, and the checks above are in verify_logout_token of services/orchestrator/app/api/oauth_client.py.

8. Keep the session alive

Request offline_access to receive a refresh token, then exchange it before the access token expires:

POST /api/auth/oauth2/token
Authorization: Basic <base64(client_id:client_secret)>   # omit for a public client, send client_id in the body instead
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&refresh_token=<token>&resource=https://app.acme.com

Store the refresh token returned by each call. Sending resource (and optionally a narrower scope) restricts the new access token to that audience; it can never widen what was granted at sign-in.

9. Service-to-service calls

A confidential application can authenticate as itself, with no user:

POST /api/auth/oauth2/token
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&resource=https://other-api.example.com

The token carries scope: m2m, sub equal to your client_id, and no user claims. The target API must be your own url or the url of a registered application listed in your allowedResources. Use it, for example, to record consumption from your backend. Wallet scopes (wallet:read, wallet:debit) and the billing scope (billing:read, to read your users' subscription, metered usage and invoices — see the Stripe integration) are granted by a superadmin only.

Troubleshooting

SymptomCause
Error page invalid_redirectredirect_uri does not match a registered URI exactly (scheme, host, port, path)
invalid_request: pkce is required for public clients (or … for this client)Missing code_challenge / code_challenge_method=S256. PKCE is required for every application, confidential ones included
invalid_target: requested resource … is not configured or client … is not linked to resource(s) …resource is not the url of any application, or it is not your application's own url nor one of its allowedResources
invalid_client … cannot use client_secret_postA confidential client authenticates with one method: HTTP Basic by default, or the body with TOKEN_ENDPOINT_AUTH_METHOD=post (see Compatibility profiles)
The access token is not a JWTThe authorization request had no resource (and the instance does not set DEFAULT_AUDIENCE)
Signature errors in your APIThe verifier expects another algorithm than the instance's JWT_PRIMARY_ALG (EdDSA by default, RS256 with the Okta, Auth0 and Keycloak profiles)
aud mismatchaud is an array: use a library option that checks containment, and compare with the application url
Browser blocked by CORS on the token endpointThe page origin is not the origin of a registered application url nor in CORS_ORIGINS
No token after sign-in for a valid userThe user has no active access to the application, or MFA is required by the application and not enabled on the account

Next steps

Copyright © 2026