Concepts

Protected resources and CORS

How auth-service manages OAuth protected resources and CORS origins at runtime without a server restart.

Since BetterAuth 1.7, OAuth protected resources (RFC 8707 audiences) are first-class rows in the oauth_resource table instead of an in-memory list. Each application URL is its own resource identifier, and every client is linked to its resource through oauth_client_resource. CORS origins remain dynamic runtime state.

How it works

Protected resources

  • Creating an application with a url creates an oauth_resource row whose identifier is that URL, and links the application's OAuth client to it.
  • Updating the URL re-points the resource link; clearing the URL removes the client's links (the oauth_resource row itself is kept).
  • Deleting an application removes the client's links.
  • Clients can send resource=<app url> on authorization and token requests to receive a signed JWT access token whose aud is that URL. Without a matching resource, an opaque token is issued, unless DEFAULT_AUDIENCE is enabled (by default only in the okta and keycloak compatibility profiles), in which case the application's url is used as the resource.

CORS origins

At startup, allowed origins are seeded from CORS_ORIGINS and from every applications.url. Creating, updating, or deleting an application updates the origin set immediately — no restart needed. An origin stays trusted while any registration still names it: if two applications share an origin, or an application uses one listed in CORS_ORIGINS, removing or changing one of them does not withdraw it.

This allow-list governs every authenticated route. The two discovery documents at the root — /.well-known/openid-configuration and /.well-known/oauth-authorization-server — are the one exception: a client application's browser has to read them before any credential exists, and a client that is not registered yet still has to resolve the issuer, so they are served with Access-Control-Allow-Origin: * and without credentials. They return public metadata only, so nothing is exposed by that, and no other route is widened.

Configuration

# Comma-separated list of allowed CORS origins (static seed)
CORS_ORIGINS=https://app.example.com,https://admin.example.com

There is no free-form environment seed for audiences: resources are derived from registered applications, and auth-service bootstraps its own BETTER_AUTH_URL as a hidden system application so other clients can request a JWT for the wallet self-service API.

Example

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

{
  "name": "My API",
  "slug": "my-api",
  "url": "https://api.example.com",
  "redirectUris": ["https://app.example.com/callback"]
}

After this request:

  • https://api.example.com exists as a protected resource, is linked to the my-api client and is a valid CORS origin (the application url).
  • https://app.example.com is only a redirect URI: redirect URIs are not added to the CORS origins. Add that origin to CORS_ORIGINS, or give the application that serves it a url.
  • Clients can include resource=https://api.example.com in authorization requests to receive a JWT.

A client can only request a resource it is linked to: its own url, plus every entry of its allowedResources list. Requesting any other resource fails with invalid_target. To let an application call https://api.example.com, add that URL to the application's allowedResources:

PATCH /api/admin/applications/:id

{ "allowedResources": ["https://api.example.com"] }

Every entry must be the url of another existing application (a registered resource), and must not be the application's own url, which is always linked. Omitting allowedResources in an update leaves the list unchanged.

Runtime state

The configured integrations are exposed via the admin services endpoint. Only non-secret configuration state is returned — provider client secrets never leave the server.

GET /api/admin/services
Cookie: <admin session cookie>
{
  "stripe": true,
  "compat": {
    "profile": "default",
    "tokenAuthMethod": "basic",
    "signingAlg": "EdDSA",
    "defaultAudience": false,
    "audienceParam": false,
    "claimMappings": [],
    "aliases": []
  },
  "providers": {
    "google": { "enabled": false },
    "github": { "enabled": true, "clientId": "gh_..." },
    "linkedin": { "enabled": false },
    "microsoft": { "enabled": false },
    "apple": { "enabled": false }
  }
}

Security considerations

  1. Audience validation — Always validate the aud claim in received JWTs to ensure the token was intended for your service.
  2. Per-client resources — A client may only request a resource it is linked to, so a compromised client cannot mint tokens for another application's audience.
  3. CORS origins — Only include origins you trust. Wildcard * is not supported on authenticated routes; it applies solely to the two public discovery documents described above.
  4. Cross-subdomain cookies — Set SESSION_DOMAIN to enable cross-subdomain session cookies (e.g., SESSION_DOMAIN=example.com).
Copyright © 2026