Auth0 · Okta · Microsoft Entra ID · Any OIDC-compliant IdP
Every federation deployment has exactly these five components.
Identity Provider
Auth0, Okta, Entra
Browser app
Login + claim extraction
Holds secrets
Token exchange hub
Token endpoint
APIs + Unity Catalog
Tool server
SQL, Genie, Serving
Human ──→ [SPA] ──→ [Backend Server] ──→ [Databricks] ──→ [MCP Server]
↕ ↕
[IdP] [IdP]
(login) (M2M token)
Nothing works without these. Set them up first, verify each one.
| P1 | SPA Application (public client, PKCE) |
| P2 | M2M Application (confidential, has secret) |
| P3 | API Resource Server (audience = workspace URL) |
| P4 | Post-login hook (injects role claims) |
| P5 | Test users with role metadata |
| P6 | Service Principals (one per role) |
| P7 | Federation policy on each SP (iss/aud/sub) |
| P8 | SP group memberships (for UC row filters) |
| P9 | SQL Warehouse (conditional, only if running SQL) |
| P10 | M2M client_id (public config) |
| P11 | M2M client_secret (secret store only) |
| P12 | Role-to-SP mapping (JSON lookup table) |
Browser loads. Nothing authenticated yet.
PKCE authorization code flow. Human sees IdP login page.
Post-login hook writes role, groups, company into namespace claims.
IdP "sales-west" → app role "west_sales". Token stays in browser.
client_credentials grant. JWT has iss, aud, sub that Databricks validates.
RFC 8693: POST /oidc/v1/token with JWT + SP application_id. Databricks validates iss/aud/sub against federation policy.
SP token used for SQL/Genie/Serving. UC row filters fire per SP group membership.
The SPA sends to the IdP:
client_id = SPA app client_id (public) redirect_uri = callback URL audience = https://adb-xxx.azuredatabricks.net scope = openid profile email code_challenge = PKCE challenge
{
"https://your-ns.com/groups": ["sales-west"],
"https://your-ns.com/role": "sales-west",
"https://your-ns.com/company": "PartnerCorp",
"https://your-ns.com/email": "sarah@partner.com"
}
The SPA maps IdP role names to internal keys. This isolates your system from IdP naming changes.
| IdP Claim | → | App Role |
|---|---|---|
sales-west | → | west_sales |
sales-east | → | east_sales |
executives | → | executive |
finance | → | finance |
admin | → | admin |
The human's IdP token never leaves the browser. The backend authenticates independently via M2M credentials. The SPA only sends the mapped role string, not the token.
POST https://<idp-domain>/oauth/token { "client_id": "<M2M client_id>", "client_secret": "<M2M secret>", "audience": "https://adb-xxx...net", "grant_type": "client_credentials" }
Response JWT contains: iss, aud, sub
POST /oidc/v1/token grant_type=urn:ietf:params:oauth: grant-type:token-exchange subject_token=<JWT from Step 5> subject_token_type=urn:ietf:params: oauth:token-type:jwt client_id=<SP application_id> scope=all-apis
POST /mcp Authorization: Bearer <db_token> X-Databricks-Token: <db_token> X-Caller-Role: west_sales X-Caller-Email: sarah@partner.com { "jsonrpc": "2.0", "method": "tools/call", "params": { "name": "query" } }
Authorization is consumed by the Databricks Apps proxy. X-Databricks-Token is read by MCP server code for downstream SQL/Genie/Serving calls. UC row filters fire per SP group membership.
Every deployment needs both. They serve completely different purposes.
| Type | Public client (no secret) |
| Grant | Authorization Code + PKCE |
| Used by | Browser |
| Purpose | Authenticate human, get ID token with role claims |
| Talks to | IdP only (never Databricks) |
| Type | Confidential (has client_secret) |
| Grant | Client Credentials |
| Used by | Backend server |
| Purpose | Get JWT for Databricks token exchange |
| Talks to | IdP + Databricks |
The human's token never touches Databricks. The SPA handles login. The M2M app handles federation. Different credentials, different grant types, different trust boundaries.
Every error you will encounter, why it happens, and how to fix it.
| Error | Step | Cause | Fix |
|---|---|---|---|
| Custom claims missing | 3 | Post-login hook not firing, or token_dialect wrong | Verify hook is deployed AND bound. Auth0: set token_dialect: access_token_authz |
aud mismatch | 6 | JWT audience ≠ federation policy | M2M token request must use audience = exact workspace URL (with https://) |
iss mismatch | 6 | JWT issuer ≠ federation policy | Check trailing slashes. Auth0: .com/. Okta: /oauth2/default. Decode a real JWT. |
sub mismatch | 6 | JWT subject ≠ federation policy | Auth0 appends @clients. Entra uses Object ID (not Client ID). Decode real JWT. |
unknown role | 7 | Role string not in role-to-SP map | Check SPA mapping table. Ensure every IdP role has a map entry. |
401 from /oidc/v1/token | 6 | No federation policy, or SP not found | Verify SP exists + federation policy attached + JWT is fresh. |
| 403 from SQL/Genie | 7 | SP lacks warehouse/Genie permissions | Grant CAN USE on warehouse, CAN RUN on Genie to SP/group. |
| Wrong row filter results | 7 | SP not in correct workspace group | Verify with SELECT is_member('group_name') using each SP token. |
If any is violated, the system is broken.
| SPA | Single Page Application, PKCE, no secret |
| M2M | Machine to Machine, client_credentials |
| API | Identifier = workspace URL |
| Hook | Actions → Post-Login trigger |
ON post-login(user, api): ns = "https://your-namespace.com" groups = user.app_metadata.groups role = FIRST match in hierarchy SET claim "{ns}/role" = role SET claim "{ns}/groups" = groups
token_dialect defaults to access_token which silently drops all custom claims. You MUST set it to access_token_authz on the API Resource Server.
M2M tokens have sub = {client_id}@clients. Federation policy must include the @clients suffix.
iss = https://dev-xxx.auth0.com/ (with slash). Federation policy must include it.
| SPA | OIDC SPA, PKCE |
| M2M | API Services (Client Credentials) |
| Auth Server | Custom (NOT default org server) |
| Hook | Token Inline Hook, or built-in Groups claim |
Claims tab > Add Claim: Name: https://your-ns.com/groups Include in: Access Token Value type: Groups Filter: Matches regex .*
Resolve primary role in SPA or backend instead of in the hook.
iss includes the auth server ID: https://org.okta.com/oauth2/{server-id}. Do NOT use just https://org.okta.com/.
sub = {client_id} (no @clients suffix, unlike Auth0).
Okta emits JWTs by default for custom auth servers.
Custom claims are NOT supported on the default org authorization server. You must create a custom one.
| SPA | App Registration, SPA platform redirect |
| M2M | App Registration, Web type, client secret |
| API | Expose an API (Application ID URI = workspace URL) |
| Roles | App Roles (native, no custom hook needed) |
App Registration > App Roles > Create: Display: "West Sales" Value: "west_sales" Members: Users/Groups Enterprise App > Users and Groups: Assign user/group to role
The roles claim appears automatically in the access token.
v2.0: https://login.microsoftonline.com/{tenant}/v2.0
v1.0: https://sts.windows.net/{tenant}/
Check accessTokenAcceptedVersion in manifest.
sub in M2M tokens is the App Registration's Object ID, NOT the Application/Client ID. Decode a real token.
M2M scope: https://{audience}/.default. The /.default suffix is mandatory.
Groups claim has a 200-member limit. For large orgs, use App Roles instead.
Same process regardless of IdP. Repeat for each SP.
PUT /api/2.0/accounts/{account_id}/ servicePrincipals/{sp_id}/ credentials/federation-policies/{name} { "oidc_federation_policy": { "issuer": "<exact iss from JWT>", "audiences": ["<exact aud>"], "subject": "<exact sub from JWT>", "subject_claim": "sub" } }
Get a real M2M JWT from your IdP
Base64-decode the payload (middle segment)
Copy iss, aud, sub exactly into the policy
Never guess these values. Trailing slashes, @clients suffixes, and version paths all matter. Always decode a real token.
A single Python script that validates Steps 5 and 6 without needing a browser, SPA, or MCP server.
Can the M2M app get a JWT from the IdP?
Does the JWT contain expected iss, aud, sub?
Can Databricks exchange that JWT for each SP token?
Does current_user() return the correct SP?
export IDP_TOKEN_URL="https://<idp>/oauth/token" export M2M_CLIENT_ID="your-m2m-client-id" export M2M_CLIENT_SECRET="your-secret" export DB_HOST="https://adb-xxx...net" export ROLE_SP_MAP='{"west_sales":"aaa..."}' python3 test_federation.py
[PASS] HTTP 200 [PASS] iss (https://dev-xxx.auth0.com/) [PASS] aud (https://adb-xxx...net) [PASS] sub (client_id@clients) Role: west_sales SP: aaaa... [PASS] exchange HTTP 200 [PASS] current_user = SP Results: 6/6 roles passed
Complete this before writing any code.
| → / Space | Next slide |
| ← | Previous slide |
| Home | First slide |
| End | Last slide |
| Swipe | Touch navigation |