Federation Token Exchange Blueprint
01 / 15

Federation Token Exchange
Implementation Blueprint

IdP-Agnostic · Step-by-Step · Error-Proof

Auth0 · Okta · Microsoft Entra ID · Any OIDC-compliant IdP

The System

Five Actors. No More, No Less.

Every federation deployment has exactly these five components.

🌐

IdP

Identity Provider
Auth0, Okta, Entra

💻

SPA

Browser app
Login + claim extraction

Backend Server

Holds secrets
Token exchange hub

Databricks

Token endpoint
APIs + Unity Catalog

🔧

MCP Server

Tool server
SQL, Genie, Serving

Human ──→ [SPA] ──→ [Backend Server] ──→ [Databricks] ──→ [MCP Server]
              ↕              ↕
           [IdP]          [IdP]
        (login)       (M2M token)
Before You Start

12 Prerequisites

Nothing works without these. Set them up first, verify each one.

IdP Side (5)

P1SPA Application (public client, PKCE)
P2M2M Application (confidential, has secret)
P3API Resource Server (audience = workspace URL)
P4Post-login hook (injects role claims)
P5Test users with role metadata

Databricks Side (4)

P6Service Principals (one per role)
P7Federation policy on each SP (iss/aud/sub)
P8SP group memberships (for UC row filters)
P9SQL Warehouse (conditional, only if running SQL)

Backend Side (3)

P10M2M client_id (public config)
P11M2M client_secret (secret store only)
P12Role-to-SP mapping (JSON lookup table)
The Flow

7 Steps. Every Request. No Shortcuts.

1
Human opens SPA

Browser loads. Nothing authenticated yet.

2
SPA redirects to IdP

PKCE authorization code flow. Human sees IdP login page.

3
IdP authenticates + injects claims

Post-login hook writes role, groups, company into namespace claims.

4
SPA extracts role, maps to internal key

IdP "sales-west" → app role "west_sales". Token stays in browser.

5
Backend gets M2M JWT from IdP

client_credentials grant. JWT has iss, aud, sub that Databricks validates.

6
Token exchange: JWT → Databricks SP token

RFC 8693: POST /oidc/v1/token with JWT + SP application_id. Databricks validates iss/aud/sub against federation policy.

7
MCP call with SP token

SP token used for SQL/Genie/Serving. UC row filters fire per SP group membership.

Phase 1

Login + Claim Injection + Role Mapping

Step 2: SPA → IdP

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

Step 3: Claims Injected

{
  "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"
}

Step 4: Role Mapping

The SPA maps IdP role names to internal keys. This isolates your system from IdP naming changes.

IdP ClaimApp Role
sales-westwest_sales
sales-easteast_sales
executivesexecutive
financefinance
adminadmin

Key Invariant

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.

Phase 2

M2M JWT + Token Exchange + MCP Call

Step 5: M2M JWT from IdP

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

Step 6: Token Exchange (RFC 8693)

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

Step 7: MCP Server Call

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" }
}

Why Two Token Headers?

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.

Architecture Decision

Two IdP Apps, Not One

Every deployment needs both. They serve completely different purposes.

SPA App

Human Login

TypePublic client (no secret)
GrantAuthorization Code + PKCE
Used byBrowser
PurposeAuthenticate human, get ID token with role claims
Talks toIdP only (never Databricks)
M2M App

Federation Exchange

TypeConfidential (has client_secret)
GrantClient Credentials
Used byBackend server
PurposeGet JWT for Databricks token exchange
Talks toIdP + 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.

Troubleshooting

Error Catalog

Every error you will encounter, why it happens, and how to fix it.

ErrorStepCauseFix
Custom claims missing3Post-login hook not firing, or token_dialect wrongVerify hook is deployed AND bound. Auth0: set token_dialect: access_token_authz
aud mismatch6JWT audience ≠ federation policyM2M token request must use audience = exact workspace URL (with https://)
iss mismatch6JWT issuer ≠ federation policyCheck trailing slashes. Auth0: .com/. Okta: /oauth2/default. Decode a real JWT.
sub mismatch6JWT subject ≠ federation policyAuth0 appends @clients. Entra uses Object ID (not Client ID). Decode real JWT.
unknown role7Role string not in role-to-SP mapCheck SPA mapping table. Ensure every IdP role has a map entry.
401 from /oidc/v1/token6No federation policy, or SP not foundVerify SP exists + federation policy attached + JWT is fresh.
403 from SQL/Genie7SP lacks warehouse/Genie permissionsGrant CAN USE on warehouse, CAN RUN on Genie to SP/group.
Wrong row filter results7SP not in correct workspace groupVerify with SELECT is_member('group_name') using each SP token.
Security

6 Invariants. Always True.

If any is violated, the system is broken.

1
The human's IdP token never leaves the browser. The backend authenticates independently via M2M.
2
The M2M client_secret is never in code, config files, or git. It lives only in the deployment platform's secret store.
3
The role-to-SP map is the single source of truth for which roles exist. If a role is not in the map, it cannot access Databricks.
4
One SP per role, not per user. You do not create SPs for individual humans. You create SPs for roles.
5
UC governance is the enforcement layer, not the application. Even if the MCP server has a bug, the SP token can only see data UC allows for that SP's groups.
6
All federation policies use the M2M app's subject, not the human's. Human identity is carried in metadata headers for audit, not for authentication.
IdP Reference

Auth0

Setup

SPASingle Page Application, PKCE, no secret
M2MMachine to Machine, client_credentials
APIIdentifier = workspace URL
HookActions → Post-Login trigger

Post-Login Action (pseudo)

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

Auth0 Gotchas

Critical

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.

sub format

M2M tokens have sub = {client_id}@clients. Federation policy must include the @clients suffix.

Trailing slash

iss = https://dev-xxx.auth0.com/ (with slash). Federation policy must include it.

IdP Reference

Okta

Setup

SPAOIDC SPA, PKCE
M2MAPI Services (Client Credentials)
Auth ServerCustom (NOT default org server)
HookToken Inline Hook, or built-in Groups claim

Simpler Alternative: 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.

Okta Gotchas

iss format

iss includes the auth server ID: https://org.okta.com/oauth2/{server-id}. Do NOT use just https://org.okta.com/.

sub format

sub = {client_id} (no @clients suffix, unlike Auth0).

No token_dialect fix

Okta emits JWTs by default for custom auth servers.

Default org server

Custom claims are NOT supported on the default org authorization server. You must create a custom one.

IdP Reference

Microsoft Entra ID

Setup

SPAApp Registration, SPA platform redirect
M2MApp Registration, Web type, client secret
APIExpose an API (Application ID URI = workspace URL)
RolesApp Roles (native, no custom hook needed)

Native App Roles (no 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.

Entra Gotchas

iss depends on token version

v2.0: https://login.microsoftonline.com/{tenant}/v2.0
v1.0: https://sts.windows.net/{tenant}/
Check accessTokenAcceptedVersion in manifest.

sub = Object ID

sub in M2M tokens is the App Registration's Object ID, NOT the Application/Client ID. Decode a real token.

Scope format

M2M scope: https://{audience}/.default. The /.default suffix is mandatory.

Groups limit

Groups claim has a 200-member limit. For large orgs, use App Roles instead.

Databricks Setup

SP Federation Policy (IdP-Agnostic)

Same process regardless of IdP. Repeat for each SP.

Federation Policy Structure

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"
  }
}

How to Get the Exact Values

1

Get a real M2M JWT from your IdP

2

Base64-decode the payload (middle segment)

3

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.

Validation

Smoke Test: Verify Before You Build

A single Python script that validates Steps 5 and 6 without needing a browser, SPA, or MCP server.

What It Tests (in order)

1

Can the M2M app get a JWT from the IdP?

2

Does the JWT contain expected iss, aud, sub?

3

Can Databricks exchange that JWT for each SP token?

4

Does current_user() return the correct SP?

Configuration (5 env vars)

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

Expected Output (all passing)

[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
Go/No-Go

Decision Checklist

Complete this before writing any code.

IdP Setup

Databricks Setup

Backend + Validation