apps/docs/content/guides/self-hosting/self-hosted-saml-sso.mdx
SAML 2.0 SSO lets your users authenticate through an enterprise Identity Provider (IdP) such as Okta, Azure AD (Entra ID), Google Workspace, or any SAML 2.0-compliant provider. Unlike OAuth providers, SAML IdPs are not configured through environment variables - they are managed dynamically at runtime through the Auth admin API.
This guide covers the full setup: generating a signing key, enabling SAML in your Supabase instance, registering an IdP, and integrating SSO into your application.
<Admonition type="note">Client-side integration uses the same supabase.auth.signInWithSSO() method documented in the SSO with SAML 2.0 guide. This guide focuses on the self-hosted server configuration.
You need:
SERVICE_ROLE_KEY from your .env file (needed for admin API calls)API_EXTERNAL_URL set to the publicly-accessible URL of your Supabase Auth service (e.g., https://<your-domain>). This URL is used as the SAML Service Provider entity ID and for constructing the ACS endpoint URLSAML SSO is configured in two layers:
.env and docker-compose.yml.The login flow works as follows:
POST /auth/v1/sso with a domain or provider_idAuthnRequest and returns a redirect URL to the IdPPOST /sso/saml/acsSAML requests must be signed. The value expected by GOTRUE_SAML_PRIVATE_KEY is a Base64-encoded PKCS#1 DER RSA private key (with a 2048-bit key as the minimum requirement). Generate it with:
openssl genpkey -algorithm RSA -out pk_pkcs8.pem -quiet && \
openssl pkey -in pk_pkcs8.pem -out pk_rsa1.der -outform DER -traditional && \
base64 -w 0 -i pk_rsa1.der
The commands above:
GOTRUE_SAML_PRIVATE_KEYSave the Base64 output - make sure to copy it as single line, ignoring the trailing newline. Remove the temporary files.
<Admonition type="caution">Keep this key secret. Anyone with the private key can forge SAML requests on behalf of your Service Provider. Do not commit it to the version control system.
</Admonition> <Admonition type="tip">For a production deployment, consider using a 4096-bit key by adding -pkeyopt rsa_keygen_bits:4096 to the openssl genpkey command above.
Add the following to your .env file:
############
# SAML SSO
############
SAML_ENABLED=true
SAML_PRIVATE_KEY=<your-base64-encoded-private-key>
# Optional: accept encrypted SAML assertions from IdPs (default: false)
# SAML_ALLOW_ENCRYPTED_ASSERTIONS=false
# Optional: how long relay state tokens remain valid (default: 2m0s)
# SAML_RELAY_STATE_VALIDITY_PERIOD=2m0s
# Optional: override the SAML entity ID / ACS base URL
# Defaults to API_EXTERNAL_URL if not set
# SAML_EXTERNAL_URL=https://supabase.example.com:8000
# Optional: rate limit on the ACS endpoint (requests per second, default: 15)
# SAML_RATE_LIMIT_ASSERTION=15
In docker-compose.yml, add the SAML environment variables to the auth service. Auth expects the GOTRUE_ prefix for all of its configuration variables:
auth:
environment:
# ... existing variables ...
# SAML SSO
GOTRUE_SAML_ENABLED: ${SAML_ENABLED}
GOTRUE_SAML_PRIVATE_KEY: ${SAML_PRIVATE_KEY}
# GOTRUE_SAML_ALLOW_ENCRYPTED_ASSERTIONS: ${SAML_ALLOW_ENCRYPTED_ASSERTIONS}
# GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD: ${SAML_RELAY_STATE_VALIDITY_PERIOD}
# GOTRUE_SAML_EXTERNAL_URL: ${SAML_EXTERNAL_URL}
# GOTRUE_SAML_RATE_LIMIT_ASSERTION: ${SAML_RATE_LIMIT_ASSERTION}
Apply the configuration changes:
docker compose down && \
docker compose up -d
Verify the Auth service is healthy:
docker compose ps auth
Once SAML is enabled, your Supabase instance exposes service provider (SP) metadata at {API_EXTERNAL_URL}/sso/saml/metadata.
Verify it using curl:
curl http://<your-domain>/sso/saml/metadata
This returns an XML document containing your SP entity ID, ACS endpoint URL, and signing certificate. You will need to provide this to your IdP.
<Admonition type="tip"> Add `?download=true` to the request URL to get the metadata as a downloadable XML file with a 5-year validity period - this is useful for IdPs that require a file upload instead of a URL. </Admonition>Key values in the metadata:
| Field | Value |
|---|---|
| Entity ID | {API_EXTERNAL_URL}/sso/saml/metadata |
| ACS URL | {API_EXTERNAL_URL}/sso/saml/acs |
| NameID formats | persistent, emailAddress |
| Signing certificate | Derived from your SAML_PRIVATE_KEY |
Use the Auth admin API to register your IdP. You need the SERVICE_ROLE_KEY for authentication.
If your IdP provides a metadata URL, Auth will fetch and cache the metadata automatically and refresh it when it becomes stale:
curl -X POST 'http://<your-domain>/auth/v1/admin/sso/providers' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'Content-Type: application/json' \
-H 'apikey: your-service-role-key' \
-d '{
"type": "saml",
"metadata_url": "https://idp.example.com/saml/metadata",
"domains": ["example.com"],
"attribute_mapping": {
"keys": {
"email": {
"name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
},
"name": {
"name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
}
}
}
}'
If you have the IdP metadata as an XML string:
curl -X POST 'http://<your-domain>/auth/v1/admin/sso/providers' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'Content-Type: application/json' \
-H 'apikey: your-service-role-key' \
-d '{
"type": "saml",
"metadata_xml": "<EntityDescriptor ...>...</EntityDescriptor>",
"domains": ["example.com"]
}'
The response includes the provider id (UUID) - save this for use in your application or for later management:
{
"id": "d3f5a1b2-...",
"resource_id": null,
"disabled": false,
"saml": {
"entity_id": "https://idp.example.com/saml",
"metadata_url": "https://idp.example.com/saml/metadata"
},
"domains": [{ "domain": "example.com" }],
"created_at": "...",
"updated_at": "..."
}
| Parameter | Required | Description |
|---|---|---|
type | Yes | Must be "saml" |
metadata_url | One of these | HTTPS URL to the IdP's SAML metadata (auto-refreshed) |
metadata_xml | One of these | Raw IdP metadata XML string |
domains | No | Array of email domains to associate (e.g., ["acme.com"]). Used for domain-based SSO lookup. |
attribute_mapping | No | Map SAML attributes to user claims (see Attribute mapping) |
name_id_format | No | Request a specific NameID format: persistent, emailAddress, transient, or unspecified |
resource_id | No | A custom external identifier for the provider |
disabled | No | Set to true to register but disable the provider |
On the IdP side, create a new SAML application and configure it with your SP details:
| IdP setting | Value |
|---|---|
| SP Entity ID / Audience | {API_EXTERNAL_URL}/sso/saml/metadata |
| ACS URL / Reply URL | {API_EXTERNAL_URL}/sso/saml/acs |
| NameID format | persistent (recommended) or emailAddress |
| Signing certificate | Upload from the SP metadata XML or provide the metadata URL |
<Tabs scrollable size="small" type="underlined" defaultActiveId="okta"
<TabPanel id="okta" label="Okta">
Okta setup:
{API_EXTERNAL_URL}/sso/saml/acs{API_EXTERNAL_URL}/sso/saml/metadataPersistentAzure AD (Entra ID):
{API_EXTERNAL_URL}/sso/saml/metadata{API_EXTERNAL_URL}/sso/saml/acsGoogle Workspace:
{API_EXTERNAL_URL}/sso/saml/acs{API_EXTERNAL_URL}/sso/saml/metadataPERSISTENTAttribute mapping lets you control how SAML assertion attributes are translated into Supabase user claims. If no mapping is provided, Auth uses sensible defaults:
Default email detection order:
urn:oid:0.9.2342.19200300.100.1.3 (LDAP mail OID)http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddresshttp://schemas.xmlsoap.org/claims/EmailAddressmail, Mail, or emailDefault user ID detection:
urn:oasis:names:tc:SAML:attribute:subject-id attributepersistent)Map IdP-specific attributes to user metadata:
{
"attribute_mapping": {
"keys": {
"email": {
"name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
},
"name": {
"name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"
},
"department": {
"name": "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/department",
"default": "unknown"
},
"groups": {
"name": "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups",
"array": true
},
"role": {
"names": ["http://schemas.microsoft.com/ws/2008/06/identity/claims/role", "role", "Role"],
"default": "member"
}
}
}
}
Each mapping key supports:
| Field | Description |
|---|---|
name | Primary SAML attribute name to look for (matched against both Name and FriendlyName, case-insensitive) |
names | Array of fallback attribute names to try in order |
default | Default value if the attribute is not present in the assertion |
array | Set to true to collect all values (for multi-valued attributes like groups) |
Mapped attributes are stored in the user's raw_user_meta_data and are available via user.user_metadata in your application.
curl 'http://<your-domain>/auth/v1/admin/sso/providers' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'apikey: your-service-role-key'
Filter by resource ID using exact match:
curl 'http://<your-domain>/auth/v1/admin/sso/providers?resource_id=my-idp' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'apikey: your-service-role-key'
or prefix match:
curl 'http://<your-domain>/auth/v1/admin/sso/providers?resource_id_prefix=prod-' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'apikey: your-service-role-key'
curl 'http://<your-domain>/auth/v1/admin/sso/providers/{provider_id}' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'apikey: your-service-role-key'
curl -X PUT 'http://<your-domain>/auth/v1/admin/sso/providers/{provider_id}' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'Content-Type: application/json' \
-H 'apikey: your-service-role-key' \
-d '{
"domains": ["example.com", "subsidiary.com"],
"attribute_mapping": {
"keys": {
"email": {
"name": "mail"
}
}
}
}'
curl -X PUT 'http://<your-domain>/auth/v1/admin/sso/providers/{provider_id}' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'Content-Type: application/json' \
-H 'apikey: your-service-role-key' \
-d '{ "disabled": true }'
curl -X DELETE 'http://<your-domain>/auth/v1/admin/sso/providers/{provider_id}' \
-H 'Authorization: Bearer your-service-role-key' \
-H 'apikey: your-service-role-key'
import { createClient } from '@supabase/supabase-js'
const supabase = createClient('http://<your-domain>', 'your-anon-key')
// Option 1: SSO by email domain
const { data, error } = await supabase.auth.signInWithSSO({
domain: 'example.com',
})
// Option 2: SSO by provider ID
const { data, error } = await supabase.auth.signInWithSSO({
providerId: 'd3f5a1b2-...',
})
// Redirect the user to the IdP
if (data?.url) {
window.location.href = data.url
}
Both methods return an object with a url property - redirect the user to this URL to begin authentication at the IdP.
By domain:
curl -X POST 'http://<your-domain>/auth/v1/sso' \
-H 'Content-Type: application/json' \
-H 'apikey: your-anon-key' \
-d '{
"domain": "example.com",
"skip_http_redirect": true
}'
By provider ID:
curl -X POST 'http://<your-domain>/auth/v1/sso' \
-H 'Content-Type: application/json' \
-H 'apikey: your-anon-key' \
-d '{
"provider_id": "d3f5a1b2-...",
"skip_http_redirect": true
}'
Both return { "url": "https://idp.example.com/sso?SAMLRequest=..." }.
| Method | Use case |
|---|---|
domain | Extract the domain from the user's email and let Auth find the right IdP. Best for login forms where the user enters their email first. |
providerId | Use when you know the exact provider - for example, a dedicated "Sign in with Okta" button. |
SITE_URL (or redirect_to URL) with session tokensTo verify the session was created:
curl 'http://<your-domain>/auth/v1/user' \
-H 'Authorization: Bearer user-session-token' \
-H 'apikey: your-anon-key'
The response should include app_metadata.provider: "sso:saml" and any mapped attributes in user_metadata.
| Variable | Default | Description |
|---|---|---|
SAML_ENABLED | false | Enable the SAML SSO engine |
SAML_PRIVATE_KEY | - | Base64-encoded PKCS#1 RSA private key (min 2048-bit). Used to sign SAML requests and optionally decrypt assertions. |
SAML_ALLOW_ENCRYPTED_ASSERTIONS | false | Accept encrypted SAML assertions from IdPs |
SAML_RELAY_STATE_VALIDITY_PERIOD | 2m0s | How long relay state tokens remain valid. Increase if users on slow networks time out during the IdP redirect. |
SAML_EXTERNAL_URL | API_EXTERNAL_URL | Override the base URL used for the SAML entity ID and ACS endpoint. Only needed if the SAML endpoints are served on a different URL than the rest of the Auth API. |
SAML_RATE_LIMIT_ASSERTION | 15 | Maximum ACS requests per second. Protects against assertion replay floods. |
The GOTRUE_SAML_ENABLED variable is not set to true, or the Auth container did not pick up the change. Verify the env var is passed through docker-compose.yml and restart:
docker compose down && docker compose up -d
The GOTRUE_SAML_PRIVATE_KEY value is malformed. Ensure it is:
openssl pkey ... -traditional output)Regenerate if needed:
openssl genpkey -algorithm RSA -out pk_pkcs8.pem -quiet && \
openssl pkey -in pk_pkcs8.pem -out pk_rsa1.der -outform DER -traditional && \
base64 -w 0 -i pk_rsa1.der
API_EXTERNAL_URL is set to a URL the IdP can reach (not localhost unless testing locally)/sso/saml/acs and /sso/saml/metadata are configured as open (no key-auth plugin).docker compose logs authdomains arrayExample.com matches example.commetadata_url, Auth automatically refreshes stale metadata (after ValidUntil, CacheDuration, or 24 hours). Force a refresh by updating the provider.NotBefore / NotOnOrAfter)attribute_mapping configuration. Use the IdP's SAML assertion viewer (most IdPs have one) to see the exact attribute names being sent.Name and FriendlyName fields in the assertion.user.user_metadata.The user took too long between initiating SSO and completing authentication at the IdP. Increase GOTRUE_SAML_RELAY_STATE_VALIDITY_PERIOD (default is 2 minutes).