Works with Okta · Microsoft Entra ID · any OIDC-compliant IdP
Your channel partners, external auditors, and consultants need governed access to Databricks. They have their own IDP. They are not in your directory. Two architectural options:
Each external user is provisioned as a Databricks identity
How it works: External user authenticates → their token is exchanged for a Databricks token that represents them as an individual. current_user() returns their email. Row filters can use current_user() = email.
current_user() = real personWHERE owner = current_user())N external users map to O(roles) SPs, no Databricks provisioning
How it works: External user authenticates with their IDP → JWT exchanged for Databricks token scoped to a role-SP. Governance fires via is_member('group'). Real identity preserved in JWT claims + audit.
is_member() on SP groupscurrent_user() returns SP UUID, not individual emailis_member() groups, not current_user() = emailThe SP handles authorization. The JWT handles attribution. Together they give you governed access + full audit without provisioning a single Databricks user.
Every OIDC IDP has these three components. The names differ, the roles don't.
"Databricks exists at this address"
Declares the audience, the workspace URL. Goes into the JWT aud claim.
| Generic | API / resource server (audience) |
| Okta | Authorization Server |
| Entra | App Reg → "Expose an API" |
// IDP config { "identifier": "https://" } // JWT claim produced "aud": "https:// " // Databricks federation policy "audiences": ["https:// "]
"The machine identity of your app server"
App server uses client_credentials grant. client_id becomes JWT sub claim.
| Generic | sub = client id (format varies by IdP) |
| Okta | sub = {client_id} |
| Entra | sub = {app_id} |
// App server token request POST /oauth/token { "client_id": "your-m2m-client-id", "client_secret": "***", "audience": "https://", "grant_type": "client_credentials" } // JWT sub claim produced "sub": "your-m2m-client-id@clients" // Databricks federation policy "subject": "your-m2m-client-id@clients"
"Identifies the human at the keyboard"
Browser PKCE login. IDP injects role, email, groups into ID token. Never talks to Databricks.
// You write this inside your IDP: // OIDC IdP: post-login / claims hook // Okta → Token Inline Hook // Entra → Claims Mapping Policy // It runs at token issuance time. token.claims["/role"] = user.role; // → "west_sales" token.claims["/email"] = user.email; // → "user@partner.example" // ID token the browser receives { "email": "user@partner.example", "https://fed/role": "west_sales", "https://fed/groups": ["sales"] } // Your app reads role → picks SP "west_sales" → SP sp-west-sales-id
The handshake: IDP produces a JWT with iss, aud, sub. Databricks federation policy validates all three. They're mirror images, if any field doesn't match: 401 / 403.
The external user authenticates with their own IDP. Their JWT is exchanged for a Databricks OAuth token scoped to a role-specific Service Principal. UC row filters, column masks, and connection grants enforce governance. The real external identity is preserved end-to-end for audit.
/oidc/v1/token exchange endpointPKCE / Authorization Code flow
role, email, groups
"west_sales" → sp-west-sales-id
IDP JWT → scoped Databricks SP token
DB token + IDP JWT + role header
JWKS check → server-side exchange
Row filter + Column mask + USE CONNECTION
Email, role, tool, latency recorded
Signed by the IdP (RS256). Carries iss, aud, sub, and custom groups / role / email claims. Verifiable via the IdP's JWKS.
POST /oidc/v1/token with the IdP JWT as subject_token and the role SP as client_id. The federation policy validates iss + aud + sub.
Opaque Bearer token scoped to the role SP. current_user() is the SP; is_account_group_member() reflects the SP's groups, so UC row filters and column masks fire.
The eight steps on the left trace one request from login to governed data access. The panel on the right shows what the token looks like at each of the three stages.
Issued by external IDP
{
"iss": "https://{idp-domain}/",
"aud": "https://" ,
"sub": "{client_id}@clients",
"exp": 1710612000,
// Custom claims (namespace varies by IDP)
"https://ns.example.com/groups": ["sales-west"],
"https://ns.example.com/role": "sales-west",
"https://ns.example.com/email": "user@partner.example"
}
Algorithm: RS256 · Signed by IDP's private key · Verifiable via JWKS
From /oidc/v1/token exchange
{
"access_token": "eyJhbG...",
"token_type": "Bearer",
"expires_in": 3600
}
// The access_token is a Databricks
// OAuth token scoped to the role-SP.
// current_user() = SP application_id
// is_member() = SP's group membership
Opaque to the caller · Encodes SP identity · UC enforcement via SP's groups
Derived from SP identity at query time
-- Row filter function SELECT * FROM sales.opportunities -- UC auto-applies: WHERE is_member('west_sales') AND opp_region = 'WEST' -- Column mask function CASE WHEN is_member('finance') THEN margin_pct ELSE NULL END
Fires automatically · No application code needed · Can't be bypassed
| # | Enforcement Point | Layer | What Fires | Failure Mode |
|---|---|---|---|---|
| 1 | IDP Authentication | External IDP | PKCE / password / SSO → JWT issued | Login failed → no JWT issued |
| 2 | Claims Injection | IDP Action/Hook | Groups, role, email injected into JWT | Missing claims → role resolution fails at step 5 |
| 3 | Federation Policy Validation | Databricks /oidc/v1/token | Issuer + audience + subject checked against SP's policy | 400/401/403 → no Databricks token |
| 4 | JWT Signature Verification | API Server (Databricks Apps) | JWKS-based RS256 verification + audience + issuer | 401 → request rejected |
| 5 | Role → SP Mapping | API Server (Databricks Apps) | JWT role claim → SP application_id lookup | 403 → unknown role |
| 6 | Tool Access Control | API Server (Databricks Apps) | TOOL_ACCESS dict: role ∈ allowed_roles? | ACCESS_DENIED logged to audit |
| 7 | UC Governance | Databricks SQL Engine | Row filters (is_member()), column masks, USE CONNECTION grants | Filtered/masked silently, never errors, just restricts |
Points 1-5 are loud, they return HTTP errors and block the request. Point 6 is semi-loud, it returns a structured ACCESS_DENIED response and logs to audit. Point 7 is silent, UC row filters and column masks never error; they just restrict what data you see. A query that returns 0 rows is governance working, not a bug.
| HTTP | Source | Error | Cause | Fix |
|---|---|---|---|---|
400 | App Server (customer) | Missing tool or role | Request body missing tool or role field | Include both fields in POST body |
400 | App Server (customer) | Unknown role | Role not in ROLE_SP_MAP | Check role key matches config exactly |
400 | /oidc/v1/token | invalid_request | Malformed JWT, missing fields | Verify JWT is well-formed, all params present |
401 | /oidc/v1/token | invalid_grant | JWT signature invalid, issuer mismatch | Check JWKS keys, issuer URL (trailing slash matters!) |
401 | API Server (Databricks) | Invalid IDP token | JWKS verification failed (expired, wrong key, tampered) | Check IDP JWKS endpoint, token expiry, algorithm |
403 | /oidc/v1/token | OIDC policy validation failed | No federation policy on SP, or subject claim mismatch | Create/update federation policy: issuer + audience + subject |
403 | API Server (Databricks) | No role determined | JWT has no role claim, no X-Federation-Role header | Add role to JWT claims or pass header |
403 | API Server (Databricks) | Unknown role: '{role}' | Role not in server's ROLE_SP_MAP | Add role to ROLE_SP_MAP env var |
429 | Federation Policy API | Rate limited | Too many policy creation requests | Exponential backoff: 2n+1 seconds |
502 | App Server / API Server | Token exchange failed | Databricks rejected the exchange | Check federation policy, SP status, JWT claims |
200 | MCP Tool | ACCESS_DENIED | Role not in TOOL_ACCESS for this tool | Expected behavior, logged to audit table |
200 | SQL Engine | 0 rows returned | UC row filter excluded all rows for this SP's groups | Expected behavior, governance working correctly |
200 | SQL Engine | NULL in column | UC column mask returned NULL for non-privileged group | Expected behavior, column masking active |
Test: curl POST {idp}/oauth/token
Check: client_id, client_secret, audience, grant_type
Test: echo $JWT | cut -d. -f2 | base64 -d | jq
Check IDP's claim injection mechanism: Action, Hook, or Policy
Test: curl POST {workspace}/oidc/v1/token
Check: policy exists on SP, issuer matches (trailing slash!), audience = workspace URL, subject matches sub claim
Check: HTTP status from API endpoint
Check: JWKS endpoint accessible, RS256 algorithm, audience + issuer match server config
Check: JWT has role claim, role key exists in ROLE_SP_MAP config
0 rows = row filter active
NULL values = column mask active
ACCESS_DENIED = tool access control
Verify: SELECT is_member('group') as the SP
Register a custom API with audience = Databricks workspace URL. This tells the IDP: "JWTs for this audience are meant for Databricks."
audience: https://
For the external app server to get JWTs via client_credentials grant. This app's client_id becomes the sub claim in the JWT.
For end-users to authenticate via PKCE in the browser. Redirect URI = your app's callback URL.
Users typically already exist in the IDP. Assign them to groups that map to Databricks roles. Group membership drives which SP the user's token exchanges for.
Groups are native objects in most IdPs (Okta, Entra). Some IdPs keep group membership in user metadata that a claims hook copies into the token.
Inject groups, role, email into the access token. This is the IDP-specific part:
| Generic | Claims hook at token issuance |
| Okta | Authorization Server custom claims |
| Entra | Claims Mapping Policy + App Roles |
One SP per role (not per user). Each SP gets a unique application_id and numeric_id.
sp-west-sales, sp-finance, etc.
Create workspace groups (e.g., west_sales) and add each SP. These groups are what is_member() checks in row filters and column masks.
On each SP, register the IDP's issuer, audience, and subject. This is what Databricks checks when validating the JWT during token exchange.
Account-level API, requires Databricks account admin.
Set up row filters (is_member('group')), column masks, and GRANT USE CONNECTION on tables. Grant SPs SELECT on data tables and SQL warehouse access.
API server on Databricks Apps (JWT verification + server-side exchange). External app anywhere (your infra). Configure both with the IDP's domain, client_id, JWKS URI, and the role → SP mapping.
| IDP Config | Produces | Databricks Field |
|---|---|---|
| IDP Tenant / Domain | iss claim | Policy issuer |
| API / Resource Server | aud claim | Policy audiences |
| M2M App client_id | sub claim | Policy subject |
| Claims injection | role, groups | App-level ROLE_SP_MAP |
// Databricks Account API POST accounts/{account_id} /servicePrincipals/{sp_id} /federationPolicies { "oidc_policy": { "issuer": "https://{idp-domain}/", "audiences": ["https://"], "subject": "{m2m_client_id}@clients" } } // Same 3 fields on all role SPs. // Swap IDP → update these 3 → done.
The federation policy is the only config that connects the IDP to Databricks. Everything else (SPs, groups, UC governance) is IDP-independent.
Static config map - created once when you set up the role SPs. Lives in the app server's env config.
// App server config (env var) ROLE_SP_MAP = { "west_sales": "sp-west-sales-id", "east_sales": "sp-east-sales-id", "managers": "sp-managers-id", "executive": "sp-executive-id", "finance": "sp-finance-id", "admin": "sp-admin-id" } // Runtime flow: // 1. SPA login → IDP token: role="west_sales" // 2. App looks up ROLE_SP_MAP["west_sales"] // 3. Token exchange: client_id=sp-west-sales-id
These are Databricks SP application_ids - not IDP client_ids. Created during setup, static thereafter. Only changes when you add/remove roles.
| Grant | On | To |
|---|---|---|
| Federation Policy | Each role-SP | , |
| CAN_USE | SQL Warehouse | All SPs |
| USE CATALOG | Data catalog | All SPs |
| USE SCHEMA | Data schema(s) | All SPs |
| SELECT | Data tables | Role SPs |
| SELECT | VS index | App SP |
| MODIFY + SELECT | Audit table | App SP only |
| USE CONNECTION | external_api_conn | Exec + Admin |
| CAN_QUERY | Serving endpoint | Exec + Admin |
| CAN_RUN | Genie Agent | All role SPs |
| Workspace group | Group per role | Each role SP |
Currently: scope=all-apis. Minimum required per tool:
| Role SPs | sql + genie + serving |
| App SP | sql + vector-search |
Policy is scoped to a specific Service Principal. Only that SP can exchange tokens from this IDP.
POST /accounts/{account_id} /servicePrincipals/{sp_numeric_id} /federationPolicies
When to use:
Visibility: Account Console → Service principals → {SP} → Credentials & secrets → Federation policies.
Policy applies to any identity in the account. Any SP or user can exchange tokens from this IDP.
POST /accounts/{account_id}
/federationPolicies
When to use:
Visibility: Visible in Account Console → Security → Authentication → Federation policies.
Both levels validate the same 3 fields against the JWT:
{
"oidc_policy": {
"issuer": "https://{idp-domain}/",
"audiences": ["https://" ],
"subject": "{jwt-sub-claim}"
}
}
The issuer URL must exactly match the iss claim in the JWT. Most IDPs include a trailing slash: https://login.example.com/. Omitting it = policy validation failure.
| OIDC IdP (varies) | {client_id} or {client_id}@clients |
| Okta Service | {client_id} |
| Entra App | {app_id} (GUID) |
| GitHub Actions | repo:{org}/{repo}:ref:refs/heads/main |
grant_type=urn:ietf:params:oauth:grant-type:token-exchange subject_token={IDP_JWT} subject_token_type=urn:ietf:params:oauth:token-type:jwt client_id={SP_APPLICATION_ID} scope=all-apis
| Endpoints (from the IdP's discovery document) | |
|---|---|
| Token | https://{idp-domain}/oauth/token |
| JWKS | https://{idp-domain}/.well-known/jwks.json |
| Issuer | https://{idp-domain}/ (match the iss claim exactly) |
| Subject (M2M) | the machine client's id (format varies by IdP) |
Every IdP has a hook that runs when it issues a token. Use it to copy the user's group membership and role into the access token.
// pseudocode, IdP-specific hook const groups = user.groups; token.setClaim("groups", groups); token.setClaim("role", resolveRole(groups));
iss claim exactly, trailing slash includedsub string before writing the federation policy| M2M | client_credentials flow for the external app server |
| SPA | PKCE flow for browser login |
| ADMIN | management API access (automation only, optional) |
| API / resource server | audience = workspace URL |
| Claims hook | inject groups, role, email |
| Users and groups | assign users to groups that map to roles |
| Connection | the IdP's user directory or SSO source |
"subject": "{M2M_CLIENT_ID}" // exact format is IdP-specific
| SPA Login | openid profile emailStandard OIDC scopes → ID token + user info |
| M2M Token | audience = workspace URL Some IdPs use an audience parameter rather than explicit scopes; the token carries the permissions granted to the M2M app. |
| Token Exchange | scope=all-apisDatabricks-side scope. UC governance restricts at the data layer regardless. |
| Endpoints | |
|---|---|
| Token | https://{org}.okta.com/oauth2/{auth_server_id}/v1/token |
| JWKS | https://{org}.okta.com/oauth2/{auth_server_id}/v1/keys |
| Issuer | https://{org}.okta.com/oauth2/{auth_server_id} |
| Subject (M2M) | {client_id} (no @clients suffix) |
Admin → Security → API → Authorization Server → Claims tab
// Custom claim: "groups" Name: groups Value: user.getGroups().filter( g, g.name.startsWith("fed_")) Include in: Access Token Scope: Any scope
/oauth2/{server_id} - NOT just the org URL{client_id} (no @clients)| Service | OAuth 2.0 Service app (client_credentials) |
| SPA | OIDC SPA app (PKCE, authorization code) |
| Authorization Server | Custom server with audience = workspace URL |
| Scopes | Add custom scope if needed |
| Claims | groups, role, email (custom claims on access token) |
| Access Policies | Rule for service app + SPA app |
| Groups | Assign users to Okta groups (mapped to Databricks roles) |
"subject": "{SERVICE_APP_CLIENT_ID}" // No @clients suffix for Okta
| SPA Login | openid profile email + custom scopesCustom scopes gate which claims are included in token |
| Service App | scope={custom_scope} on Auth ServerOkta requires at least one scope. Create a custom scope on the Authorization Server (e.g., federation). Access policy rules filter by scope. |
| Token Exchange | scope=all-apisSame Databricks-side scope regardless of IDP |
| Endpoints | |
|---|---|
| Token | https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token |
| JWKS | https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys |
| Issuer | https://login.microsoftonline.com/{tenant_id}/v2.0 |
| Subject (M2M) | {app_id} (GUID of the App Registration) |
PowerShell / MS Graph API → Claims Mapping Policy
// Claims Mapping Policy (JSON) { "ClaimsMappingPolicy": { "Version": 1, "IncludeBasicClaimSet": "true", "ClaimsSchema": [{ "Source": "user", "ID": "groups", "JwtClaimType": "groups" }] } }
.../v2.0 (v2 endpoints) vs .../ (v1), must match exactlygroupMembershipClaims: "SecurityGroup" in manifest| App Reg (M2M) | Client credentials with secret/cert |
| App Reg (SPA) | PKCE redirect, SPA platform |
| API Scope | Expose an API → Application ID URI = workspace URL |
| Claims Policy | Assign to Service Principal |
| Security Groups | Map to Databricks roles |
"subject": "{APP_REGISTRATION_APP_ID}" // The Application (client) ID GUID // NOT the Object ID
| SPA Login | openid profile email User.ReadMS Graph delegated permissions for user info |
| M2M (App) | scope={app_id_uri}/.defaultEntra uses .default scope pattern. Requests all permissions pre-consented for the app. The app_id_uri = workspace URL. |
| Token Exchange | scope=all-apisSame Databricks-side scope regardless of IDP |
Option A: Map Entra Security Group GUIDs to roles in app config
Option B: Use App Roles (defined in App Registration manifest), appear as roles claim
Option C: Use Claims Mapping to emit group names instead of GUIDs
| Configuration | OIDC IdP (generic) | Okta | Entra ID |
|---|---|---|---|
| Issuer URL | https://{idp-domain}/trailing slash |
https://{org}.okta.com/oauth2/{server_id}no trailing slash |
https://login.microsoftonline.com/{tenant}/v2.0v2.0 suffix |
| JWKS URL | .../.well-known/jwks.json |
.../v1/keys |
.../discovery/v2.0/keys |
| M2M Sub Claim | {client_id}@clients |
{client_id} |
{app_id} (GUID) |
| Claims Injection | Token / claims hook | Authorization Server custom claims | Claims Mapping Policy + manifest |
| Namespace Required? | Sometimes Some IdPs need a non-vendor namespace | No Top-level claims OK | No Top-level claims OK |
| Token Dialect Fix? | Yes access_token_authz |
No Custom claims included by default | No Claims policy handles it |
| Group Format | String names (from app_metadata) | String names (Okta groups) | GUIDs (Security Groups) by default |
| M2M App Type | Machine-to-Machine Application | OAuth 2.0 Service App | App Registration + Client Secret/Cert |
| SPA App Type | Single Page Application | OIDC SPA App | App Registration (SPA platform) |
| Automation | Management API v2 | Okta Admin API | MS Graph API |
| SPA Scopes | openid profile email |
openid profile email + custom |
openid profile email User.Read |
| M2M Scopes | Implicit via audienceNo explicit scope param |
scope={custom_scope}Must create on Auth Server |
scope={app_id_uri}/.default.default = all pre-consented |
| Exchange Scope | scope=all-apis ← SAME for all IDPs (Databricks-side) |
||
| Free Tier | Varies by IdP and plan | 100 MAU (Workforce), limited M2M | Part of Azure AD (included with M365) |
Create M2M + SPA apps in new IDP
Configure groups/role/email claims in new IDP's mechanism
Update issuer and subject on each SP's policy
Update IDP domain, client_id, JWKS URI, token endpoint, claims namespace
Update IDP_DOMAIN, IDP_AUDIENCE, IDP_ISSUER, JWKS_URI env vars
Create test users with groups/roles in new IDP
Same SPs, same application_ids, same group membership
Same is_member() functions, same tables
Same GRANT/REVOKE USE CONNECTION SQL
Same ROLE_SP_MAP config (roles are app-defined, not IDP-defined)
Same /oidc/v1/token with same grant_type
Same schema, same tags, same correlation
The IDP provides the JWT. Databricks validates it via federation policy. Swap the JWT issuer, update the policy, everything downstream stays the same. The governance layer (UC) is completely IDP-agnostic.
External app exchanges IDP JWT → DB token (for proxy auth). MCP server independently exchanges the same IDP JWT → DB token (for SQL execution). Both resolve to the same role-SP. The external user's identity is preserved in JWT claims, audit records, and MLflow traces.
UC row filters and column masks fire at the SQL engine level. They can't be bypassed by changing the application, the IDP, or the query. is_member() checks the SP's group membership, not the caller's headers.
Auth failures (401/403) are loud and visible. Governance enforcement (0 rows, NULL columns) is silent and correct. Know which one you're looking at before debugging.
90% of federation failures are a mismatch between the JWT's sub claim and the federation policy's subject field. Every IdP formats this differently: {client_id} or {client_id}@clients for OAuth apps, {app_id} (GUID) for Entra. Decode the JWT first, then write the policy.
A working IDP token + a working federation policy can still fail if the subject claim doesn't match. Test the full chain: get IDP JWT → exchange → call /api/2.0/preview/scim/v2/Me → verify SP identity.
| Role label | Role | Group | Regions | Margins | Identity | Regions | Sales | Genie Agents | KB | Supervisor | GitHub | Ext API | Audit |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| West Territory Rep | west_sales | west_sales | WEST only | NULL | Y | Y | Y | Y | Y | - | - | - | - |
| East Territory Rep | east_sales | east_sales | EAST only | NULL | Y | Y | Y | Y | Y | - | - | - | - |
| Manager | managers | managers | ALL | NULL | Y | Y | Y | Y | Y | - | - | - | - |
| Executive | executive | executives | ALL | Real | Y | Y | Y | Y | Y | Y | Y | Y | Y |
| External Auditor | finance | finance | ALL | Real | Y | Y | Y | Y | Y | - | - | - | Y |
| Platform Admin | admin | admin | ALL | Real | Y | Y | Y | Y | Y | Y | Y | Y | Y |
For the business: Partners, auditors, and consultants get self-service access to exactly the data they need, no IT tickets, no shared credentials, no waiting for provisioning.
For security: Every query runs through UC row filters and column masks. Every action is audited with the real external identity. Access is revocable with one SQL statement.
For engineering: Swap the IDP by updating the federation policy, no code changes. Add a new role by creating one SP and one workspace group. The architecture scales horizontally.
For compliance: External identity preserved end-to-end. Correlatable audit trail across custom tables, MLflow traces, and system.access.audit. No identity gaps.