Skip to content

OAuth 2.1 and OpenID Connect server

Tested with: supabase-js 2.117 · coreos/go-oidc 3 · golang.org/x/oauth2

Your project can act as an OAuth 2.1 authorization server and OpenID Connect provider for its own users. Other apps send your users to your project to sign in, your consent page asks them to approve, and the app receives tokens that act as that user, under your row-level security. AI agents and MCP clients use the same flow, and can register themselves.

The paths are Supabase Auth’s OAuth server paths, so supabase.auth.oauth.* (consent) and supabase.auth.admin.oauth.* (clients) work unchanged.

  1. The app redirects the user to GET https://<ref>.us-east.databasezy.com:8443/auth/v1/oauth/authorize with response_type=code, its client_id, an exact redirect_uri, a scope, a state and a PKCE code_challenge (S256).
  2. The project redirects the browser to your consent page with ?authorization_id=.... The page, where the user is signed in to your app, shows who is asking and for what, and approves or denies.
  3. The browser goes back to the app’s redirect_uri with code, state and iss.
  4. The app exchanges the code at POST /auth/v1/oauth/token with its code_verifier (and its secret if it has one), and receives an access token, a refresh token and, with openid, an ID token.
  1. Build the consent page in your app (any URL on your domain), with supabase-js:

    app/oauth/consent/page.ts
    const id = new URLSearchParams(location.search).get("authorization_id")!;
    const { data, error } = await supabase.auth.oauth.getAuthorizationDetails(id);
    if (data && "redirect_url" in data) {
    location.assign(data.redirect_url); // the user already granted these scopes
    } else if (data) {
    // Show data.client.name, data.client.logo_uri and data.scope; then on "Allow":
    await supabase.auth.oauth.approveAuthorization(id);
    // or on "Deny": await supabase.auth.oauth.denyAuthorization(id);
    }

    Clients that registered themselves (client.registration_type === "dynamic") have not been checked by you: say so on the page.

  2. Enable the server in the portal (Platform → Auth → OAuth server) with the consent page URL, or with the API:

    shell
    zb api PUT /v1/orgs/$ORG/projects/$PROJECT/auth/oauth-server \
    '{"enabled": true, "authorization_url": "https://app.example.com/oauth/consent"}'
  3. Register a client (portal: New client). A confidential client (a server) gets a secret, shown once; a public client (a single-page app, a mobile or desktop app, a CLI, an MCP client) has none and proves itself with PKCE.

EndpointPath (under /auth/v1)Credentials
Discovery/.well-known/openid-configuration, /.well-known/oauth-authorization-server (also /.well-known/oauth-authorization-server/auth/v1 at the host root)none
JWKS/.well-known/jwks.jsonnone
AuthorizationGET /oauth/authorizenone (a browser)
TokenPOST /oauth/token (authorization_code, refresh_token)client secret (Basic or form) or PKCE only
Revocation (RFC 7009)POST /oauth/revokethe client’s
UserInfoGET /oauth/userinfothe access token
Dynamic registration (RFC 7591)POST /oauth/clients/registernone, when allowed
Consent APIGET /oauth/authorizations/{id}, POST /oauth/authorizations/{id}/consentthe signed-in user
The user’s grantsGET /user/oauth/grants, DELETE /user/oauth/grants?client_id=the signed-in user
Client management/admin/oauth/clients[/{id}[/regenerate_secret]]secret key

The issuer is https://<ref>.us-east.databasezy.com:8443/auth/v1.

  • Access token: an ES256 JWT signed with the project’s key, like a session token: role authenticated, sub, session_id, aal, plus client_id, scope and an audience that names the client (and the resource the client asked for, RFC 8707). It works with the Data API, Storage and Realtime under your RLS policies. Restrict what a client can do with auth.jwt() ->> 'client_id' in a policy.
  • ID token (scope openid): audience = the client id, with nonce, at_hash, auth_time and the claims of the granted scopes. It is not a credential: the project endpoint refuses it.
  • Scopes: openid, email (email, email_verified), profile (name, picture, preferred_username, …), phone (phone_number, phone_number_verified).
  • Refresh tokens rotate on every use; reusing an old one ends the session. They refresh only at /oauth/token with the client they were issued to.

An MCP client needs only the discovery URL. With Allow dynamic client registration on, it registers itself as a public client, runs the authorization code flow with PKCE against your consent page and calls your MCP server (for example a function) with the access token; the server verifies it with the project’s JWKS and checks that aud contains its own URL.

  • Authorization code flow only (no implicit or password grant); PKCE S256 required for every client.
  • Redirect URIs are matched exactly: https, http://localhost / 127.0.0.1 for native apps, or a reverse-domain custom scheme; no wildcards, no fragments. A wrong client or redirect URI is answered on the project host, never redirected.
  • Authorization codes are single use, valid two minutes and bound to the client, the redirect URI and the PKCE challenge; a replayed code ends the session the first exchange created.
  • Client secrets are stored as SHA-256 digests and compared in constant time; failed client authentication is rate limited and repeated failures raise a security event.
  • Consent is recorded per user, client and scope, and revocable by the user (DELETE /user/oauth/grants); revoking ends the client’s sessions. Deleting a client ends all of them.
  • Dynamic registration is off by default; when on, it is limited to 10 registrations per IP per hour and 200 per project per day, at most 1,000 clients per project.
  • A token issued to a client cannot approve consents for another client.