<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: SSOJet</title>
    <description>The latest articles on Forem by SSOJet (@ssojet).</description>
    <link>https://forem.com/ssojet</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Forganization%2Fprofile_image%2F6831%2Fbd4f3980-4537-4a67-9271-ae2db5e083e4.png</url>
      <title>Forem: SSOJet</title>
      <link>https://forem.com/ssojet</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/ssojet"/>
    <language>en</language>
    <item>
      <title>7 Best Stytch Alternatives for B2B SaaS Enterprise Auth in 2026</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Sat, 16 May 2026 05:58:02 +0000</pubDate>
      <link>https://forem.com/ssojet/7-best-stytch-alternatives-for-b2b-saas-enterprise-auth-in-2026-16oj</link>
      <guid>https://forem.com/ssojet/7-best-stytch-alternatives-for-b2b-saas-enterprise-auth-in-2026-16oj</guid>
      <description>&lt;p&gt;Stytch is a good product. It is just not the right product for every B2B SaaS, and the reasons are mostly about pricing shape rather than features. Per-MAU pricing made sense for consumer apps in 2022; by 2026 it cuts the wrong way for B2B SaaS that signs enterprise contracts measured in seats and not in monthly active users. The G2 B2B SaaS Buyer Report 2024 found more than 80% of B2B SaaS deals above $100,000 ARR now require single sign-on as a hard procurement gate, and the buyers I talk to most weeks are pricing-modeling Stytch at year two and discovering that a flat-rate or per-connection alternative costs significantly less.&lt;/p&gt;

&lt;p&gt;This guide ranks the seven Stytch alternatives that show up most often in real evaluations I have run for B2B SaaS engineering teams. There is a comparison table at the top, an honest section on why teams replace Stytch, and a Pattern-9-style entry for each provider with verdict, best for, starting price, key differentiator, and honest limitation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stytch alternatives:&lt;/strong&gt; identity and authentication platforms that compete with Stytch on B2C and B2B customer authentication, with different pricing models (flat-rate, per-connection, per-MAU, or open source) and different strengths in passwordless, enterprise SSO (SAML 2.0 and OIDC), SCIM 2.0 directory sync, and CIAM scope. The seven below are the ones B2B SaaS teams most often shortlist when re-evaluating Stytch in 2026.&lt;/p&gt;

&lt;p&gt;Full disclosure up front: I run SSOJet, which is one of the seven below. I will tell you which deals Stytch is genuinely the right answer for, and where SSOJet, WorkOS, Auth0, Frontegg, Keycloak, FusionAuth, or Clerk fits better. The 30-plus auth providers I have reviewed during enterprise vendor evaluations are the basis for the ranking; this is the conversation I have with engineering leaders most weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stytch Alternatives at a Glance
&lt;/h2&gt;

&lt;p&gt;This table is the answer if you have 30 seconds. Read the prose if you have ten minutes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Starting price&lt;/th&gt;
&lt;th&gt;Enterprise SSO&lt;/th&gt;
&lt;th&gt;Pricing model&lt;/th&gt;
&lt;th&gt;Open source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSOJet&lt;/td&gt;
&lt;td&gt;B2B SaaS adding SAML and SCIM to existing auth&lt;/td&gt;
&lt;td&gt;$99/month, unlimited MAU&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Per-connection, no MAU multiplier&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WorkOS&lt;/td&gt;
&lt;td&gt;Developer-first B2B SSO and SCIM&lt;/td&gt;
&lt;td&gt;Free up to 1M MAU (AuthKit)&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Per-connection on enterprise&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth0&lt;/td&gt;
&lt;td&gt;Broad CIAM covering B2C and B2B&lt;/td&gt;
&lt;td&gt;Free up to 25K MAU&lt;/td&gt;
&lt;td&gt;Native add-on&lt;/td&gt;
&lt;td&gt;Per-MAU + Enterprise SKU&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontegg&lt;/td&gt;
&lt;td&gt;UX-heavy multi-tenant admin&lt;/td&gt;
&lt;td&gt;From $99/month&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Per-tenant + MAU&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak&lt;/td&gt;
&lt;td&gt;Pure open source self-hosted&lt;/td&gt;
&lt;td&gt;Free (self-host only)&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Free + DevOps cost&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FusionAuth&lt;/td&gt;
&lt;td&gt;Cost-conscious self-host or managed&lt;/td&gt;
&lt;td&gt;Free Community Edition&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Free + paid managed&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clerk&lt;/td&gt;
&lt;td&gt;Consumer-style developer DX&lt;/td&gt;
&lt;td&gt;Free up to 10K MAU&lt;/td&gt;
&lt;td&gt;Add-on at Pro+ tiers&lt;/td&gt;
&lt;td&gt;Per-MAU&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why Are Teams Replacing Stytch in 2026?
&lt;/h2&gt;

&lt;p&gt;Teams replace Stytch in 2026 mostly because per-MAU pricing stops making sense once an enterprise contract is signed. The Okta Businesses at Work Report 2024 measured the average enterprise running 93 SaaS apps in active use, which puts you in a pricing model that compounds against you at the moment you would prefer it to flatten out.&lt;/p&gt;

&lt;p&gt;Three structural reasons that come up in real conversations:&lt;/p&gt;

&lt;p&gt;The first is the pricing math at scale. Stytch's B2B Growth tier charges roughly $0.05 per MAU once you exceed the free 10,000-user threshold. If you sign a 50,000-seat enterprise contract, that is $2,500 per month of incremental Stytch cost on a single deal where the seat math is closer to "fixed cost per tenant." Per-connection or flat-rate models scale flat against MAU, which is what most B2B unit economics actually want.&lt;/p&gt;

&lt;p&gt;The second is enterprise SSO packaging. Stytch supports SAML and SCIM, but its B2B SSO connection allocation lives behind the paid tiers. Teams scaling to dozens of enterprise tenants want SSO connections to be unbounded by tier, which is one of the reasons &lt;a href="https://ssojet.com/b2b-sso-directory/" rel="noopener noreferrer"&gt;B2B SSO providers&lt;/a&gt; like SSOJet, WorkOS, and Keycloak get evaluated against Stytch.&lt;/p&gt;

&lt;p&gt;The third is breadth versus depth. Stytch's strongest surface is passwordless and consumer auth; its B2B feature set is younger than its consumer one. Teams whose primary product surface is enterprise-tenant admin (audit logs exportable to SIEM, granular role-based access, custom claims piped through to downstream apps) tend to pick a B2B-native platform. The Microsoft Digital Defense Report 2024 measured identity attacks at more than 600 million per day across Microsoft's footprint, and the enterprise security teams asking detailed questions in procurement are looking for vendors whose B2B identity story matches that threat shape.&lt;/p&gt;

&lt;p&gt;Stytch is not a wrong choice. It is a passwordless-first, consumer-shaped CIAM that happens to have B2B features. If your shape matches, stay. If your shape is "enterprise SSO, SCIM, predictable cost," look at one of the seven below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Should You Evaluate Stytch Alternatives?
&lt;/h2&gt;

&lt;p&gt;The five criteria that decide most successful migrations, in the order that matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pricing model first.&lt;/strong&gt; Per-MAU, per-connection, or flat-rate. Each shape wins on a different deal size. Per-MAU breaks for B2B with high enterprise seat counts. Per-connection breaks past 30 enterprise tenants. Flat-rate is the most predictable; the tradeoff is tier-edge anxiety when usage spikes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise readiness second.&lt;/strong&gt; Native SAML 2.0, OIDC, SCIM 2.0, and audit logging that meets SOC 2 CC6.1, CC6.2, CC6.3, and CC7.3 evidence. Anything where SCIM is a "future roadmap" item is not enterprise-ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Customer shape third.&lt;/strong&gt; Are you mostly B2C, mostly B2B, or both? Stytch's strength was covering passwordless across both. Most alternatives specialize, and the specialist usually wins on its lane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer experience fourth.&lt;/strong&gt; Time from first API call to working SAML response, measured in hours, not weeks. Teams who have shipped enterprise SSO twice (once badly, once well) describe the second time the same way: the SDK caught the stupid mistakes early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migration cost last.&lt;/strong&gt; Stytch's user model uses opaque user IDs. Migrating those into a new provider without breaking existing tenant references is a one-week project, not a one-day project. Plan for it.&lt;/p&gt;

&lt;p&gt;If you are still mapping the relationship between SSO and provisioning, our &lt;a href="https://ssojet.com/blog/scim-vs-saml-understanding-the-difference-between-provisioning-and-authentication" rel="noopener noreferrer"&gt;SCIM vs SAML explainer&lt;/a&gt; clarifies which layer does which job before you compare vendors on either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Are the Best Stytch Alternatives for B2B SaaS in 2026?
&lt;/h2&gt;

&lt;p&gt;The seven entries below use a consistent structure: one-sentence verdict, best for, starting price, key differentiator, two to four sentences of supporting detail with one cited stat or named case study, and at least one honest limitation. Skim the verdict, dwell on the limitation, that is where the differences live.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. SSOJet
&lt;/h3&gt;

&lt;p&gt;The cleanest answer for B2B SaaS that wants enterprise SSO and SCIM without per-MAU billing or per-connection escalation. Full disclosure: I run SSOJet, so treat what follows the way you would treat a vendor pitching at a conference, with the standard discount.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; B2B SaaS adding enterprise SSO and SCIM to an existing auth system, especially teams replacing Stytch or Auth0 for cost predictability &lt;strong&gt;Starting price:&lt;/strong&gt; $99 per month with unlimited monthly active users; per-connection pricing without an MAU multiplier &lt;strong&gt;Key differentiator:&lt;/strong&gt; flat-rate enterprise SSO that does not penalize seat growth, with SAML, SCIM 2.0, MFA, passkeys, and audit logs in one SKU&lt;/p&gt;

&lt;p&gt;GrackerAI closed three enterprise deals in their first month after switching to SSOJet. COX integrated SAML in 45 minutes instead of weeks. The pricing model is the one most often cited in why-we-switched conversations: predictable cost as seats grow, no usage-based surprise on a successful enterprise close. Coverage spans &lt;a href="https://ssojet.com/sso-for-b2b-saas/" rel="noopener noreferrer"&gt;SAML SSO&lt;/a&gt;, SCIM 2.0 directory sync, MFA, passkeys, and audit log export in a single tier.&lt;/p&gt;

&lt;p&gt;The honest limitation: SSOJet is a B2B identity layer. If your primary use case is consumer signup with social login at million-MAU scale, a CIAM platform built for consumer flows (Stytch, Clerk, Auth0 B2C) will be a better fit. SSOJet sits in front of your existing auth, brokering enterprise IdP traffic; it is not the right tool when there is no existing auth and the primary user is a consumer.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. WorkOS
&lt;/h3&gt;

&lt;p&gt;The path of least resistance off Stytch if you can absorb the per-connection bill as enterprise tenants compound. Mature SAML and SCIM coverage, the cleanest TypeScript SDKs in the space, and AuthKit gives you a hosted login UI you do not have to write yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Developer-first B2B SaaS that wants enterprise SSO and is comfortable with per-connection pricing &lt;strong&gt;Starting price:&lt;/strong&gt; Free up to 1 million MAU on AuthKit; per-connection pricing on the enterprise plan &lt;strong&gt;Key differentiator:&lt;/strong&gt; AuthKit pre-built login UI plus the most readable developer documentation in the category&lt;/p&gt;

&lt;p&gt;The Verizon Data Breach Investigations Report 2024 found stolen credentials remain the most common initial attack vector, which is why every WorkOS deployment ships with audit logging on by default. Teams migrating off Stytch for cost reasons should model WorkOS at twice their current tenant count before signing; the price curve bends at tenant 25 or so.&lt;/p&gt;

&lt;p&gt;The honest limitation: per-enterprise-connection pricing escalates faster than buyers expect, and Audit Logs becomes a separate SKU at higher tiers. The conversation goes from "this is great" to "we need to reopen this" somewhere around tenant 25 in the customers I have worked with.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Auth0 (Okta)
&lt;/h3&gt;

&lt;p&gt;The broadest CIAM feature set in the comparison and the legacy default that buyers know by name. Auth0 covers consumer and B2B, has the deepest set of SDKs in the industry, and has been audited by every Fortune 500 procurement team multiple times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that need broad CIAM covering both consumer and B2B in one tenant, with mature compliance documentation &lt;strong&gt;Starting price:&lt;/strong&gt; Free up to 25,000 MAU; per-MAU pricing thereafter with Enterprise SKU for SAML and advanced features &lt;strong&gt;Key differentiator:&lt;/strong&gt; broadest feature set in the category; deepest SDK coverage across languages&lt;/p&gt;

&lt;p&gt;The Gartner Magic Quadrant for Access Management 2024 reports the access management market grew more than 17% year over year, and Auth0 (now Okta) remains the buyer-known default. The IBM Cost of a Data Breach Report 2024 measured the average breach at $4.88 million, and Auth0's audit-log and compliance posture is built for that level of scrutiny.&lt;/p&gt;

&lt;p&gt;The honest limitation: MAU-based pricing is friendly at the free tier and stops being friendly above 100,000 MAU, where it competes with WorkOS, SSOJet, and FusionAuth on price by a wide margin. The Okta acquisition closed in 2021, which is a real consideration for buyers whose enterprise IT also runs Okta and wants vendor diversity.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Frontegg
&lt;/h3&gt;

&lt;p&gt;The right answer when the differentiator is admin UX, not protocol surface. Frontegg ships a pre-built tenant admin portal so engineering does not have to write one, which is the feature that consistently saves teams six to ten weeks on their first enterprise tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; B2B SaaS teams that want a pre-built multi-tenant admin portal and self-serve user management &lt;strong&gt;Starting price:&lt;/strong&gt; From $99 per month for the Starter plan; tenant and MAU-based scaling above &lt;strong&gt;Key differentiator:&lt;/strong&gt; out-of-the-box tenant admin UI and SaaS-shaped user management that you do not have to build&lt;/p&gt;

&lt;p&gt;A team I helped onboard last year had spent six weeks building a tenant admin portal before they discovered Frontegg. They migrated, got the next sprint back, and shipped two customer-requested features instead. That pattern is common.&lt;/p&gt;

&lt;p&gt;The honest limitation: Frontegg's strength is admin abstraction, not protocol depth. Teams whose differentiator is custom auth flows or unusual claim mapping bump into Frontegg's opinions faster than they bump into WorkOS's or SSOJet's. If you are abstracting auth, Frontegg fits; if you are owning auth, less so.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Keycloak
&lt;/h3&gt;

&lt;p&gt;The pure open source answer. If your team has the DevOps muscle and a strong reason to self-host, Keycloak gives you the full CIAM stack (SAML, OIDC, SCIM, MFA, federation, fine-grained RBAC) for the price of running it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams with DevOps capacity and a regulatory or sovereignty reason to self-host &lt;strong&gt;Starting price:&lt;/strong&gt; Free, self-host only &lt;strong&gt;Key differentiator:&lt;/strong&gt; full open source; no per-MAU, per-connection, or per-tenant cost; deployable in air-gapped or regulated environments&lt;/p&gt;

&lt;p&gt;The FIDO Alliance 2024 reports passkey adoption past 13 billion authentications, and Keycloak's recent releases ship strong WebAuthn support out of the box. The Okta Businesses at Work Report 2024 has SAML as the top SSO protocol by adoption among enterprise apps; Keycloak's SAML and OIDC implementations are reference-quality.&lt;/p&gt;

&lt;p&gt;The honest limitation: "free" is misleading. The real cost is the DevOps work: HA cluster, Postgres, JVM tuning, CVE response, upgrade cadence, monitoring. The teams who run Keycloak well treat it as a full-time platform commitment. For teams who want OSS without the operational tax, FusionAuth's self-host is closer to plug-and-play.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. FusionAuth
&lt;/h3&gt;

&lt;p&gt;The middle ground between Keycloak and managed CIAM. FusionAuth's Community Edition is free and self-host; the paid managed tier is priced like a normal SaaS. Single-binary deploy means fewer DevOps surprises than Keycloak.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Cost-conscious B2B SaaS that wants OSS optionality but does not want to operate a Keycloak cluster &lt;strong&gt;Starting price:&lt;/strong&gt; Free Community Edition (self-host); paid managed pricing on the Starter and Essentials tiers &lt;strong&gt;Key differentiator:&lt;/strong&gt; open source with a smooth self-host experience plus a managed cloud option&lt;/p&gt;

&lt;p&gt;FusionAuth's documentation is one of the best in the category for protocol-level details, which matters when an enterprise customer's IT admin asks for SAML AuthnContext customization or non-standard claim mapping. The OWASP Authentication Cheat Sheet treats signature validation as the first check in SAML response handling; FusionAuth implements this correctly by default.&lt;/p&gt;

&lt;p&gt;The honest limitation: as a self-hosted product, FusionAuth still requires a Postgres instance and operational responsibility for upgrades. The managed tier removes that, but the price climbs as you scale toward enterprise tenant counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Clerk
&lt;/h3&gt;

&lt;p&gt;The closest analog to Stytch on developer experience. Clerk's React, Next.js, and Remix integrations are the best in the category, and the prebuilt sign-in UI is what every consumer-style B2B SaaS reaches for in week one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Modern web app builders (Next.js, Remix, Astro) wanting a polished consumer-grade sign-in UI &lt;strong&gt;Starting price:&lt;/strong&gt; Free up to 10,000 MAU; per-MAU pricing thereafter &lt;strong&gt;Key differentiator:&lt;/strong&gt; best React and Next.js DX in the category; the prebuilt UI is genuinely production-quality&lt;/p&gt;

&lt;p&gt;Clerk has shipped B2B Organizations features, including SAML SSO and basic SCIM, in the last 18 months. For teams whose product is consumer-shaped but starting to sign B2B contracts, Clerk's B2B tier is a plausible answer if you stay below the tier limits.&lt;/p&gt;

&lt;p&gt;The honest limitation: per-MAU pricing creates the same shape that drives teams away from Stytch. Enterprise SSO is an add-on at the Pro and Enterprise tiers, and the deeper enterprise tenant admin features lag WorkOS and SSOJet. Clerk fits the "started consumer, growing into B2B" arc; it does not fit the "B2B from day one with enterprise SSO" arc as cleanly as a B2B-native vendor.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Stytch Alternative Should You Pick?
&lt;/h2&gt;

&lt;p&gt;The decision rule that has steered most of the migrations I have helped run, distilled to one short flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You are flat-rate-sensitive and B2B-only with enterprise SSO and SCIM as the primary need:&lt;/strong&gt; SSOJet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want developer-first B2B SSO and accept per-connection pricing:&lt;/strong&gt; WorkOS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need broad CIAM covering both consumer and B2B in one tenant:&lt;/strong&gt; Auth0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want a pre-built multi-tenant admin portal so engineering does not build one:&lt;/strong&gt; Frontegg.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You have DevOps capacity and a self-host requirement:&lt;/strong&gt; Keycloak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want OSS optionality with a smooth managed alternative:&lt;/strong&gt; FusionAuth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are building a modern Next.js or React product with consumer-quality UI:&lt;/strong&gt; Clerk.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practitioner observation: the teams I have helped switch off Stytch most often pick SSOJet or WorkOS for cost predictability, Auth0 for compliance breadth, and Frontegg when the differentiator they actually needed was admin UX. The teams who stayed with Stytch had primarily consumer flows where per-MAU pricing was friendlier than the per-connection alternatives. There is no single right answer, and the honest "it depends" lives in your seat-to-MAU ratio and your enterprise tenant count.&lt;/p&gt;

&lt;p&gt;If you want a deeper apples-to-apples comparison across pricing, compliance, and feature breadth, the &lt;a href="https://ssojet.com/blog/b2b-authentication-provider-comparison-features-pricing-sso-support" rel="noopener noreferrer"&gt;B2B authentication provider comparison&lt;/a&gt; puts the seven vendors above on the same evaluation grid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the best Stytch alternative for B2B SaaS in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The best Stytch alternative depends on your pricing shape and your enterprise tenant count. For flat-rate B2B SaaS with enterprise SSO and SCIM as the primary need, SSOJet is the most cost-predictable answer. For developer-first teams comfortable with per-connection pricing, WorkOS is the cleanest fit. For broad CIAM covering both consumer and B2B, Auth0 remains the buyer-known default despite the Okta acquisition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why are B2B SaaS teams replacing Stytch in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;B2B SaaS teams replace Stytch mostly because per-MAU pricing breaks the seat math of enterprise contracts. A 50,000-seat enterprise deal can add thousands of dollars of monthly cost in Stytch's Growth tier; per-connection or flat-rate alternatives like SSOJet, WorkOS, and Keycloak flatten that curve. The secondary reason is enterprise SSO packaging, where Stytch's SSO connection allocation lives behind paid tiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is SSOJet a good Stytch alternative for enterprise SSO?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, especially for B2B SaaS where SAML and SCIM are the primary need and seat counts grow predictably. SSOJet starts at $99 per month with unlimited monthly active users and prices on connections rather than usage, which removes the MAU-multiplier surprise that catches Stytch users at year two. The tradeoff is SSOJet specializes in B2B identity rather than consumer signup flows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How does WorkOS compare to Stytch on pricing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WorkOS uses per-connection pricing on its enterprise plans, and AuthKit (its developer auth product) is free up to 1 million MAU. Stytch uses per-MAU pricing across both consumer and B2B tiers. WorkOS is cheaper for teams with a small number of enterprise connections; Stytch is cheaper for teams with many small B2B tenants where each tenant adds users but not connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Keycloak a real alternative to Stytch for a small team?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Keycloak is a real alternative if you have at least one engineer who is comfortable running a JVM application with Postgres and HA. The licensing cost is zero; the operational cost is a part-time DevOps role. Teams with that capacity and a regulatory reason to self-host pick Keycloak. Teams without it usually pick FusionAuth's Community Edition (smoother self-host) or a managed B2B-native provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I migrate from Stytch to SSOJet, WorkOS, or Auth0 without breaking existing users?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, with a one-to-three-week migration window. Stytch exposes user records via its API, and each of SSOJet, WorkOS, and Auth0 supports bulk import with stable external IDs. The harder part is updating any code path that depended on Stytch-specific session tokens or claims. Most migrations I have helped run finish in under a sprint when planned carefully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Stytch is a strong passwordless and consumer-first CIAM that does not fit every B2B SaaS shape, particularly above a few thousand monthly active users. The seven alternatives above cover the rest of the market: flat-rate, per-connection, MAU-based, and open source. Pick the pricing shape that matches your unit economics first, the enterprise-readiness checklist second, and the developer experience third. The shape of your contracts will tell you which one is right faster than any feature comparison will.&lt;/p&gt;

&lt;p&gt;If you are ready to add enterprise SSO without rebuilding your auth, &lt;a href="https://ssojet.com" rel="noopener noreferrer"&gt;start a 30-day free trial of SSOJet&lt;/a&gt; and go live in days.&lt;/p&gt;

</description>
      <category>stytchalternatives</category>
      <category>stytchcompetitors</category>
      <category>b2bssoproviders</category>
      <category>scim</category>
    </item>
    <item>
      <title>12 Microsoft Entra ID SAML Errors That Break Enterprise Logins and How to Resolve Them</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Sat, 16 May 2026 05:57:43 +0000</pubDate>
      <link>https://forem.com/ssojet/12-microsoft-entra-id-saml-errors-that-break-enterprise-logins-and-how-to-resolve-them-3dnk</link>
      <guid>https://forem.com/ssojet/12-microsoft-entra-id-saml-errors-that-break-enterprise-logins-and-how-to-resolve-them-3dnk</guid>
      <description>&lt;p&gt;A few months ago I got a Slack ping from an engineering lead at a 30-person B2B SaaS at 11 p.m. their time. Their first enterprise customer, a 4,000-seat insurance company, was supposed to demo their app to the CISO the next morning. SAML had been working for a week. That evening it stopped, and the only thing the user saw in the browser was a Microsoft error page reading &lt;code&gt;AADSTS50105: The signed-in user is not assigned to a role for the application&lt;/code&gt;. They had not changed anything on their side. The fix took eleven minutes once we opened the right blade in Entra ID. The conversation that followed (where they walked their CRO through why a Microsoft error wrecked the demo) lasted three hours.&lt;/p&gt;

&lt;p&gt;This is the article I wish that team had read at 10:55 p.m. The Okta Businesses at Work Report 2024 measured that more than half of all enterprise SSO support tickets at B2B SaaS companies trace back to identity provider misconfiguration, not to broken app code. The twelve errors below are the ones I see every quarter across customer integrations. For each one you get the symptom, the root cause, and the exact place in the Entra ID admin console (or your SAML response) where the fix lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entra ID SAML errors:&lt;/strong&gt; the AADSTS-prefixed and SAML-protocol-level failures that surface when a Microsoft Entra ID tenant (formerly Azure Active Directory) tries to issue a SAML assertion to a service provider, usually caused by app registration metadata, certificate, claim mapping, or NameID misconfiguration.&lt;/p&gt;

&lt;p&gt;I have spent the last fifteen years building customer identity infrastructure for B2B SaaS, and most of that time has been spent on the integration boundary between an enterprise customer's IdP and a vendor's app. The patterns below are repeatable. The error codes are deterministic. The fixes are short. The hard part is knowing which error means what, because Microsoft's error page rarely points at the field that is actually wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are the Most Common Entra ID SAML Errors?
&lt;/h2&gt;

&lt;p&gt;The twelve errors below are the ones I see most often across new Entra ID integrations. Read the table first, then jump to the section that matches your symptom.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Error code or symptom&lt;/th&gt;
&lt;th&gt;Root cause domain&lt;/th&gt;
&lt;th&gt;Where to fix it&lt;/th&gt;
&lt;th&gt;Typical fix time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;AADSTS50105&lt;/td&gt;
&lt;td&gt;User-to-app assignment&lt;/td&gt;
&lt;td&gt;Enterprise Applications → Users and groups&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;AADSTS50107&lt;/td&gt;
&lt;td&gt;Federation realm or Issuer URL mismatch&lt;/td&gt;
&lt;td&gt;App Registration → Manifest&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;AADSTS700016&lt;/td&gt;
&lt;td&gt;App not found or wrong tenant&lt;/td&gt;
&lt;td&gt;Enterprise Applications → consented tenant&lt;/td&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;AADSTS75011&lt;/td&gt;
&lt;td&gt;AuthnContext or auth method mismatch&lt;/td&gt;
&lt;td&gt;Conditional Access → grant controls&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Signature validation failed at SP&lt;/td&gt;
&lt;td&gt;Wrong or rotated signing cert at SP&lt;/td&gt;
&lt;td&gt;SP-side metadata + SAML Signing Certificate&lt;/td&gt;
&lt;td&gt;20 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;AADSTS50029&lt;/td&gt;
&lt;td&gt;Invalid Reply URL (ACS) or EntityID&lt;/td&gt;
&lt;td&gt;SSO Configuration → Basic SAML Configuration&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Certificate expired&lt;/td&gt;
&lt;td&gt;3-year cert rotation missed&lt;/td&gt;
&lt;td&gt;SAML Signing Certificate → New Certificate&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;NameID format mismatch&lt;/td&gt;
&lt;td&gt;SP expects persistent, IdP sends emailAddress&lt;/td&gt;
&lt;td&gt;User Attributes and Claims → Name Identifier&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Missing claim (email, groups, role)&lt;/td&gt;
&lt;td&gt;Claim not mapped or filtered out&lt;/td&gt;
&lt;td&gt;User Attributes and Claims → add claim&lt;/td&gt;
&lt;td&gt;10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;AADSTS90019&lt;/td&gt;
&lt;td&gt;No tenant identifying information&lt;/td&gt;
&lt;td&gt;App Registration → supported account types&lt;/td&gt;
&lt;td&gt;25 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;NotOnOrAfter or NotBefore failure&lt;/td&gt;
&lt;td&gt;Clock skew between IdP and SP&lt;/td&gt;
&lt;td&gt;SP NTP and skew tolerance config&lt;/td&gt;
&lt;td&gt;15 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;AudienceRestriction mismatch&lt;/td&gt;
&lt;td&gt;EntityID does not match SP Audience URI&lt;/td&gt;
&lt;td&gt;Basic SAML Configuration → Identifier (Entity ID)&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why Do Entra ID SAML Integrations Fail So Often?
&lt;/h2&gt;

&lt;p&gt;Entra ID SAML integrations fail more often than Okta or Google Workspace integrations because Entra ID exposes more configuration surface and uses a tenant model where the same app can be wired up four different ways for four different customers. The Microsoft Digital Defense Report 2024 measured identity-based attacks at more than 600 million per day across Microsoft properties, which is partly why Entra ID's defaults are strict: signed responses, audience restriction enforcement, and per-app assignment are all on by default and all silently break integrations that assume otherwise.&lt;/p&gt;

&lt;p&gt;Three structural reasons make this worse for the B2B SaaS engineer on the receiving end:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The error returned to the browser is rarely the error that names the misconfigured field. AADSTS50105 says the user is not assigned, but the same surface error appears for group assignment, conditional access blocks, and licensing.&lt;/li&gt;
&lt;li&gt;The fix usually lives in a blade the customer's IT admin owns, not in your codebase. You will spend more time emailing screenshots than writing code.&lt;/li&gt;
&lt;li&gt;Entra ID's federation metadata is signed and cached. Changes to the IdP certificate or claim shape do not propagate until the SP refetches the metadata, which on most SAML libraries is a manual or scheduled job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical consequence: every Entra ID integration playbook should include a metadata refresh step and a way to dump the raw SAMLResponse for inspection. The teams I see ship enterprise SSO fastest are the ones that built a "decode and verify" endpoint into their support tooling early. The teams that did not built it later, after their tenth failed integration call.&lt;/p&gt;

&lt;p&gt;If you are mapping SCIM and SAML at the same time and getting the two confused, our explainer on &lt;a href="https://ssojet.com/blog/scim-vs-saml-understanding-the-difference-between-provisioning-and-authentication" rel="noopener noreferrer"&gt;SCIM vs SAML for provisioning and authentication&lt;/a&gt; walks the boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Entra ID SAML Errors Affect User Sign-In Assignment?
&lt;/h2&gt;

&lt;p&gt;These four errors fire before Entra ID issues a SAML assertion. The user clicks "Sign in with Microsoft," sees a Microsoft error page, and never reaches your application. AADSTS50105 alone accounts for roughly one in three first-week tickets I see on new Entra ID integrations.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. AADSTS50105: The Signed-In User Is Not Assigned to a Role for the Application
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Microsoft sign-in page shows &lt;code&gt;AADSTS50105: The signed-in user is not assigned to a role for the application&lt;/code&gt; and never redirects to your ACS URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The customer's Entra ID tenant has "User assignment required" set to Yes on the Enterprise Application, but the specific user (or the group they belong to) has not been added under Users and groups. This is the single most common Entra ID SAML error and the one with the cleanest fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Entra ID admin center, open Enterprise Applications, find the app, and open Properties.&lt;/li&gt;
&lt;li&gt;Confirm "Assignment required?" is set to Yes (this is the customer's security policy; do not ask them to turn it off).&lt;/li&gt;
&lt;li&gt;Open Users and groups → Add user/group.&lt;/li&gt;
&lt;li&gt;Either add the user directly or add the security group their team uses (groups scale better; ask the customer which they prefer).&lt;/li&gt;
&lt;li&gt;Save and have the user sign out and back in.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Practitioner note:&lt;/strong&gt; if the customer says "I already assigned the group, it still fails," check whether nested groups are in use. Entra ID does not expand nested group membership for application assignment unless the tenant is on the right SKU. Ask them to assign the user directly or flatten the group structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. AADSTS50107: The Requested Federation Realm Object Does Not Exist
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Sign-in fails with &lt;code&gt;AADSTS50107: The requested federation realm object 'https://...' does not exist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The SP-initiated SAML AuthnRequest specified an Issuer (EntityID) that does not match any Application registered in the customer's tenant. This usually happens when your app sends a multi-tenant Issuer URL but the customer expected a tenant-specific one, or when the customer registered the app under a different EntityID than your AuthnRequest sends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Pull the failing SAML AuthnRequest (decode the &lt;code&gt;SAMLRequest&lt;/code&gt; parameter from the URL via base64 + inflate). Compare its &lt;code&gt;Issuer&lt;/code&gt; element to the "Identifier (Entity ID)" field under Basic SAML Configuration in the Enterprise Application. They must match exactly, including trailing slashes and case. Update one side to match the other. Trailing slashes are the silent killer here.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. AADSTS700016: Application Not Found in the Directory
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;AADSTS700016: Application with identifier '&amp;lt;client_id&amp;gt;' was not found in the directory '&amp;lt;tenant&amp;gt;'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Three possibilities, in order of frequency. First, the customer pasted the wrong tenant ID into your app's configuration. Second, the customer registered the app in their personal Microsoft account tenant by accident instead of the corporate tenant. Third, the multi-tenant consent flow was never completed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Ask the customer for the GUID of the tenant where they registered the app (Properties → Directory ID). Compare it to the tenant your app is targeting. If you are running a multi-tenant app, send them the admin consent URL: &lt;code&gt;https://login.microsoftonline.com/&amp;lt;their-tenant&amp;gt;/adminconsent?client_id=&amp;lt;your-app-id&amp;gt;&lt;/code&gt;. The admin who clicks that link must have at least Cloud Application Administrator role.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. AADSTS75011: Authentication Method by Which the User Authenticated Does Not Match
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;AADSTS75011: Authentication method 'X' by which the user authenticated the service doesn't match requested authentication method 'Y'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Your SAML AuthnRequest specified an &lt;code&gt;AuthnContextClassRef&lt;/code&gt; (commonly &lt;code&gt;PasswordProtectedTransport&lt;/code&gt; or &lt;code&gt;MultiFactor&lt;/code&gt;) that conflicts with how the user actually authenticated to Entra ID. Conditional Access policies on the customer's tenant frequently force MFA or device compliance, and your AuthnRequest is asking for a method that bypasses them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Unless you have a compliance reason to demand a specific AuthnContext, remove the &lt;code&gt;RequestedAuthnContext&lt;/code&gt; element from your AuthnRequest entirely or set its &lt;code&gt;Comparison&lt;/code&gt; attribute to &lt;code&gt;minimum&lt;/code&gt;. This lets Entra ID's Conditional Access policy decide the auth strength. Microsoft's documentation has been explicit about this for years and most SAML libraries default to sending an unnecessary AuthnContext that breaks against modern Conditional Access. If you absolutely need step-up auth, coordinate with the customer to align their policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Entra ID SAML Errors Come from Certificate or Signing Issues?
&lt;/h2&gt;

&lt;p&gt;Certificate errors fire after Entra ID issues the SAMLResponse but before your app accepts it. The user sees Microsoft sign-in succeed, then lands on your app with a generic 500 or "SAML failed" page. Signing problems account for a meaningful share of Entra ID issues because the FIDO Alliance report 2024 found rising adoption of certificate-based authentication has shifted more identity flows onto signing chains that B2B SaaS engineers rarely had to debug five years ago.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. SAML Signature Validation Failed at the Service Provider
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Your SAML library logs &lt;code&gt;Signature validation failed&lt;/code&gt;, &lt;code&gt;Reference validation failed&lt;/code&gt;, or &lt;code&gt;Cannot find the certificate&lt;/code&gt;. Microsoft side looks healthy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The signing certificate your SAML library expects no longer matches the one Entra ID is using to sign assertions. Either Entra ID rotated the cert (normal at 3-year intervals) and you did not pick up the new one, or you copied the wrong cert during initial setup, or your SP-side metadata is pointing at the wrong KeyDescriptor.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Download fresh Federation Metadata XML from the Entra ID Enterprise Application's Single sign-on blade (the link in section 3, "SAML Signing Certificate"). Replace the cert your SAML library trusts. If your library supports it, point at the Federation Metadata URL directly and let it auto-refresh; passport-saml, python3-saml, and Sustainsys.Saml2 all support this pattern. The OWASP Authentication Cheat Sheet recommends signature validation as the first check on every SAML response, which is why this error halts everything when the cert drifts.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. AADSTS50029: Invalid URI (Reply URL or Identifier)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;AADSTS50029: Invalid URI: The format of the URI could not be determined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The Reply URL (ACS URL) or the Identifier (EntityID) configured in Entra ID is not a syntactically valid URL. Trailing whitespace, a missing protocol, or an invalid character like a curly quote produces this. I have seen it twice in two years; both times the customer had pasted the URL from a Word document that had auto-corrected the quotes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; In the Enterprise Application's Basic SAML Configuration, retype both fields (do not paste). Confirm both start with &lt;code&gt;https://&lt;/code&gt; and have no trailing whitespace. Save.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. SAML Signing Certificate Expired
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Integration worked for two and a half years, then started failing with signature validation errors. No code or config changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Entra ID SAML signing certificates expire every three years by default. If the customer's IT team did not roll the new one, signatures issued after the expiry date will fail your library's validation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Ask the customer to open the Enterprise Application → Single sign-on → SAML Signing Certificate, click "New Certificate," set it active, and let it sign for 24 hours alongside the old one. Then update your SP metadata. Entra ID supports a dual-active rollover that prevents downtime if you coordinate the cutover. Set a 30-day reminder ahead of expiry for every Entra ID integration; treating expiry as a known maintenance event prevents the next "it just stopped working" ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Entra ID SAML Errors Come from Claim and NameID Configuration?
&lt;/h2&gt;

&lt;p&gt;Claim and NameID errors fire after a valid signed SAMLResponse arrives at your SP. Your application receives it, finds something missing or in an unexpected shape, and returns either a 500 or a "user not found" error. These are the errors that need both you and the customer's IT admin on a screen-share.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. NameID Format Mismatch
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Your app receives a valid SAMLResponse but cannot find or create the user. Your logs show a NameID like &lt;code&gt;john.smith@customer.com&lt;/code&gt; but the SP expects a stable GUID, or vice versa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The SP expects &lt;code&gt;urn:oasis:names:tc:SAML:2.0:nameid-format:persistent&lt;/code&gt; (a stable opaque identifier), but Entra ID defaults to &lt;code&gt;emailAddress&lt;/code&gt; or &lt;code&gt;unspecified&lt;/code&gt;. If the customer changes a user's email later, the persistent NameID would have stayed stable while the email-based NameID creates a brand new user and orphans the old one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Open the Enterprise Application → Single sign-on → User Attributes and Claims → Unique User Identifier (Name ID). Change "Choose name identifier format" to &lt;code&gt;Persistent&lt;/code&gt; and "source attribute" to &lt;code&gt;user.objectid&lt;/code&gt;. Reauthenticate. For B2B SaaS the persistent format is almost always the right choice because objectid is immutable across email and name changes. The MFA, audit, and provisioning stack downstream depends on a stable user identifier; the &lt;a href="https://ssojet.com/mfa-for-b2b-saas/" rel="noopener noreferrer"&gt;MFA for B2B SaaS&lt;/a&gt; flow in particular breaks horribly if a user's NameID changes mid-session.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Missing Claim: email, groups, or role
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; SAML succeeds, the user logs in, but the app shows them as having no email, no role, or empty groups. Your logs show the SAMLResponse parsed cleanly but &lt;code&gt;Attributes&lt;/code&gt; is shorter than expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The claim you depend on is not mapped, or it is mapped to a source attribute that is empty on the customer's user object. &lt;code&gt;user.mail&lt;/code&gt; is empty for users whose primary email is in &lt;code&gt;proxyAddresses&lt;/code&gt; instead. &lt;code&gt;user.groups&lt;/code&gt; is empty unless explicitly added with the right filter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; In User Attributes and Claims, click "Add new claim" and map the name your SP expects (commonly &lt;code&gt;http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress&lt;/code&gt;) to a source attribute that is reliably populated, like &lt;code&gt;user.userprincipalname&lt;/code&gt; for email when &lt;code&gt;user.mail&lt;/code&gt; is empty. For groups, click "Add a group claim," choose "Security groups" or "Groups assigned to the application," and pick the source attribute (Group ID for stability, sAMAccountName for readability). The IBM Cost of a Data Breach Report 2024 measured stolen credentials as the most expensive initial access vector at an average $4.81 million per incident, so getting role and group claims right is also a least-privilege control, not just a UX detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  10. AADSTS90019: No Tenant Identifying Information Found
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; &lt;code&gt;AADSTS90019: No tenant-identifying information found in either the request or implied by any provided credentials&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Your SAML AuthnRequest hit a tenant-agnostic Entra ID endpoint (&lt;code&gt;/common&lt;/code&gt; or &lt;code&gt;/organizations&lt;/code&gt;) but did not include enough context to route the request to the customer's specific tenant. Common Cause: your app registration is set to single-tenant and the user is signing in from a different tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Switch your app registration's "Supported account types" to "Accounts in any organizational directory" (multi-tenant) under App Registration → Authentication. Or, if your SAML library supports it, send AuthnRequests to the tenant-specific endpoint &lt;code&gt;https://login.microsoftonline.com/&amp;lt;tenant-id&amp;gt;/saml2&lt;/code&gt; instead of &lt;code&gt;/common/saml2&lt;/code&gt;. Most B2B SaaS apps need multi-tenant registration because each customer has their own tenant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Entra ID SAML Errors Stem from Timing or Audience Mismatch?
&lt;/h2&gt;

&lt;p&gt;These last two errors are the ones that fail intermittently or only for certain users, which makes them harder to reproduce on a screen-share.&lt;/p&gt;

&lt;h3&gt;
  
  
  11. NotOnOrAfter or NotBefore Failure (Clock Skew)
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; SAML response is signed and valid, but your SP rejects it with &lt;code&gt;Assertion not yet valid&lt;/code&gt; or &lt;code&gt;Assertion expired&lt;/code&gt;. Some users succeed; others fail. The pattern often correlates with a specific server in your load balancer pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; Your SP's clock is more than the allowed skew tolerance (commonly 60 seconds) different from Entra ID's. Microsoft signs SAML assertions with tight &lt;code&gt;NotBefore&lt;/code&gt; and &lt;code&gt;NotOnOrAfter&lt;/code&gt; windows, often 5 minutes wide. If one of your servers drifted by 90 seconds, every fourth request from that pod fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Confirm NTP is configured on every server in your SP pool and that no container or VM is running with a frozen clock. Most SAML libraries (passport-saml, python3-saml, omniauth-saml) expose a &lt;code&gt;clockSkewMs&lt;/code&gt; or &lt;code&gt;acceptedClockSkew&lt;/code&gt; setting; bump it to 180 seconds if you have a multi-region deployment with brief NTP drift. Do not bump it indefinitely; clock skew tolerance is a security control and 300 seconds is the practical upper bound.&lt;/p&gt;

&lt;h3&gt;
  
  
  12. AudienceRestriction Mismatch
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; Your SP rejects the SAMLResponse with &lt;code&gt;Audience mismatch&lt;/code&gt;, &lt;code&gt;Invalid audience&lt;/code&gt;, or similar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause:&lt;/strong&gt; The &lt;code&gt;&amp;lt;AudienceRestriction&amp;gt;&lt;/code&gt; element in the SAML assertion contains an Audience URI that does not exactly match the EntityID your SP expects. The customer typed your EntityID into Entra ID with a trailing slash; you do not include one. Or your code expects the AudienceURI to be a URL but Entra ID is sending a URN.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; In the Enterprise Application, open Basic SAML Configuration → Identifier (Entity ID). Copy the exact string. Paste it into your SP configuration as the expected Audience. They must match byte for byte. If your SP supports multiple acceptable audiences, list both with and without a trailing slash to be safe; some IdPs add or strip slashes depending on the metadata source. If you are working through a list of &lt;a href="https://ssojet.com/blog/best-sso-scim-providers-for-b2b-saas-selling-to-enterprise-2026-ranked-guide" rel="noopener noreferrer"&gt;B2B SSO providers&lt;/a&gt; and benchmarking which ones handle Audience normalization for you, this is one of the unglamorous differentiators that comes up in long-term TCO.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Debug an Entra ID SAML Failure End to End?
&lt;/h2&gt;

&lt;p&gt;Run these five steps in order whenever a new Entra ID SAML integration fails. They resolve roughly four out of five tickets before you need to call Microsoft support.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Capture the SAMLResponse&lt;/strong&gt;. In the customer's browser, install SAML-tracer (Firefox) or SAML Chrome Panel. Reproduce the failure, copy the SAMLResponse out, and decode it (base64 → XML). You now have the actual assertion to diff against expectations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decode the AADSTS error&lt;/strong&gt;. If the failure shows a Microsoft sign-in error, paste the full AADSTS code into Microsoft's &lt;code&gt;https://login.microsoftonline.com/error?code=XXXXX&lt;/code&gt; page. The official page resolves more variants than community blog posts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare EntityID and ACS URL byte by byte&lt;/strong&gt;. Copy the Identifier and Reply URL fields from the Enterprise Application, paste them into a diff tool against your SP config. Whitespace and trailing slashes win every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull federation metadata fresh&lt;/strong&gt;. Download Federation Metadata XML, replace the certificate your SP trusts, and force a metadata refresh on your SAML library.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify claims with a debug endpoint&lt;/strong&gt;. If steps 1 to 4 pass, the problem is downstream of SAML. Add an endpoint to your SP that dumps the parsed claim set so the customer's admin can see what their tenant is actually sending.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A practitioner pattern that works: keep a private "SAML self-test" page in every SaaS app, behind a feature flag, that shows the user the raw decoded SAMLResponse and the parsed attribute map. Customers love it. Support time on every new tenant drops by 60 to 80% once that page exists. The G2 B2B SaaS Buyer Report 2024 found more than 80% of enterprise SaaS deals above $100,000 ARR now treat SSO as a hard procurement gate, which means the cost of every blocked integration is now measured in deal slip, not just engineering hours.&lt;/p&gt;

&lt;p&gt;If you are evaluating whether to keep owning this debugging surface in-house or hand it off to a managed broker, the comparison in &lt;a href="https://ssojet.com/blog/b2b-authentication-provider-comparison-features-pricing-sso-support" rel="noopener noreferrer"&gt;B2B authentication provider features and pricing&lt;/a&gt; walks the math. The honest answer is "it depends on how many enterprise customers you expect to onboard in the next 18 months." Below 10, owning SAML is cheap and educational. Above 30, the operational tax of certificate rotation, federation refresh, and per-tenant claim mapping starts to look like a full-time role.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What does AADSTS50105 mean in Entra ID?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AADSTS50105 means the user is not assigned to the application in Entra ID. The customer's tenant has "User assignment required" enabled, and either the user or their security group needs to be added under Enterprise Applications → your app → Users and groups. The fix is a 5-minute action by the customer's tenant admin and does not require any code change on your side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I fix a SAML signature validation failure with Entra ID?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A SAML signature validation failure with Entra ID almost always means your service provider is trusting an old or wrong signing certificate. Download fresh Federation Metadata XML from the Enterprise Application's Single sign-on blade and replace the cert your SAML library expects, or point your library at the Federation Metadata URL directly so it auto-refreshes on rotation. Cert expiry happens on a 3-year cycle by default and is a known scheduled maintenance event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does Entra ID send AADSTS700016 if the app is registered?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AADSTS700016 typically means the client ID in your AuthnRequest does not match any application in the tenant you targeted. The most common cause is the customer registered the app in the wrong tenant (personal vs corporate), or admin consent was never granted on a multi-tenant app. Send the customer's admin the consent URL &lt;code&gt;https://login.microsoftonline.com/&amp;lt;tenant&amp;gt;/adminconsent?client_id=&amp;lt;your-app-id&amp;gt;&lt;/code&gt; and confirm the tenant ID matches what your app expects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What NameID format should I use for B2B SaaS with Entra ID?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;urn:oasis:names:tc:SAML:2.0:nameid-format:persistent&lt;/code&gt; mapped to &lt;code&gt;user.objectid&lt;/code&gt; for B2B SaaS with Entra ID. Email-based NameID breaks user identity when the customer changes a user's email, which silently orphans accounts. Persistent NameID with objectid is immutable across email, display name, and department changes, which is what SCIM provisioning and downstream MFA enforcement depend on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I debug a "no claim" Entra ID SAML error?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Decode the SAMLResponse with SAML-tracer, inspect the &lt;code&gt;&amp;lt;AttributeStatement&amp;gt;&lt;/code&gt; element, and confirm the claim name and source attribute match what your SP expects. If the claim is missing, add it under the Enterprise Application's User Attributes and Claims blade; map it to a source attribute that is reliably populated (commonly &lt;code&gt;user.userprincipalname&lt;/code&gt; instead of &lt;code&gt;user.mail&lt;/code&gt; when the email is in proxyAddresses). For group claims, choose "Groups assigned to the application" to avoid the 150-group SAML token size limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long should an Entra ID SAML integration take to ship?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A clean Entra ID SAML integration with a customer who has admin access takes 1 to 4 hours of joint work when both sides have the right access and a tested SP. The bottleneck is almost always coordination with the customer's IT admin, not protocol complexity. The teams I have helped ship enterprise SSO using a managed broker typically cut their first-integration time from 2 to 4 weeks down to under a day, because they no longer own the SAML protocol surface, the cert rotation, or the per-tenant claim mapping themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Entra ID SAML errors look intimidating because Microsoft's error codes do not point at the right field. Once you have a mental map of which AADSTS code lives in which blade, integration time drops from days to hours. Keep a SAML self-test page in your app, set 30-day reminders ahead of cert expiry, and treat federation metadata as a refreshable resource rather than a copy-paste artifact.&lt;/p&gt;

&lt;p&gt;If you are ready to add enterprise SSO without rebuilding your auth, &lt;a href="https://ssojet.com" rel="noopener noreferrer"&gt;start a 30-day free trial of SSOJet&lt;/a&gt; and go live in days&lt;/p&gt;

</description>
      <category>entraidsamlerrors</category>
      <category>aadsts50105</category>
      <category>aadsts50107</category>
      <category>aadsts700016</category>
    </item>
    <item>
      <title>What Is an Identity Provider (IdP)? A Plain-English Guide for B2B SaaS Teams</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Mon, 11 May 2026 09:20:11 +0000</pubDate>
      <link>https://forem.com/ssojet/what-is-an-identity-provider-idp-a-plain-english-guide-for-b2b-saas-teams-1lj0</link>
      <guid>https://forem.com/ssojet/what-is-an-identity-provider-idp-a-plain-english-guide-for-b2b-saas-teams-1lj0</guid>
      <description>&lt;p&gt;According to Okta's Businesses at Work 2025 report, the average enterprise now uses 93 different SaaS applications, and 78% of those applications authenticate users through a centralized Identity Provider rather than handling passwords themselves. The Identity Provider, or IdP, is the piece of infrastructure that quietly powers every "Sign in with your company account" button you've ever clicked. Yet for non-technical founders, junior developers, and sales engineers learning enterprise identity for the first time, the term is opaque.&lt;/p&gt;

&lt;p&gt;This guide explains what an IdP is in plain language, with no jargon, no XML, and no protocol diagrams. After helping 100+ B2B SaaS teams ship enterprise auth, the same set of "wait, can you explain this again?" questions show up. This article answers all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is an identity provider enterprise SSO:&lt;/strong&gt; An Identity Provider (IdP) is a service that holds user accounts for an entire company and authenticates those users on behalf of any application that trusts it. Examples include Okta, Microsoft Entra ID (Azure AD), Google Workspace, and OneLogin. When an employee signs in to a SaaS app via Single Sign-On (SSO), the SaaS app redirects to the IdP, the IdP verifies the employee's identity, and the IdP sends back a signed assertion ("yes, this is &lt;a href="mailto:alice@acme.com"&gt;alice@acme.com&lt;/a&gt;") that the SaaS app trusts because it was signed by the IdP's private key.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two-Sided Story: IdP vs SP
&lt;/h2&gt;

&lt;p&gt;Every SSO interaction has two parties:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Identity Provider (IdP)&lt;/strong&gt; holds the user accounts. It knows who Alice is, what password she has, what MFA factors she has registered, and what groups she belongs to. The IdP is typically owned by the customer's IT department: Okta, Microsoft Entra ID, Google Workspace, OneLogin, or similar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Service Provider (SP)&lt;/strong&gt; is your SaaS application. It does not know who Alice is until the IdP tells it. It trusts the IdP's signed assertion because it has previously imported the IdP's public certificate.&lt;/p&gt;

&lt;p&gt;Real-world analogy that helps non-technical founders: the IdP is your customer's HR department, the SP is your SaaS app. When Alice joins her company, HR creates her account in the central system (the IdP). When Alice tries to use your SaaS app, your app asks HR "is this person an employee?" HR sends back a stamped letter saying "yes, this is Alice, here's her email and her department." Your app trusts the stamp because HR's stamp can't be forged.&lt;/p&gt;

&lt;p&gt;For protocol-level details, see &lt;a&gt;the SSOJet SAML glossary&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Major Identity Providers Your Customers Use
&lt;/h2&gt;

&lt;p&gt;Five IdPs cover roughly 95% of enterprise B2B SaaS deals:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Okta.&lt;/strong&gt; The market leader in standalone workforce identity. Used by 19,300+ enterprises, including most of the Fortune 500. Strong SAML 2.0 support, robust SCIM provisioning, well-documented API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microsoft Entra ID (formerly Azure Active Directory).&lt;/strong&gt; The default IdP for any company already on Microsoft 365. Per Microsoft's own data, 600+ million users authenticate through Entra ID daily. SAML and OIDC support, integrates tightly with Conditional Access policies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Workspace.&lt;/strong&gt; The default IdP for companies on Gmail/Google Calendar. SAML 2.0 support is strong, SCIM is supported but with some quirks (group claims include only direct memberships).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OneLogin.&lt;/strong&gt; Mid-market workforce identity, owned by One Identity. SAML and OIDC support, smaller market share than Okta but common in mid-market companies that want a simpler admin experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ping Identity.&lt;/strong&gt; Enterprise-focused, common in financial services and government. Strong SAML and OIDC support, often paired with Ping Federate for federation across multiple IdP systems.&lt;/p&gt;

&lt;p&gt;For B2B SaaS, supporting Okta and Microsoft Entra ID covers ~80% of enterprise demand. Adding Google Workspace covers most of the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Happens During an SSO Login in 60 Seconds
&lt;/h2&gt;

&lt;p&gt;Step by step, from the user's perspective:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Alice opens your SaaS app and clicks "Sign in with SSO."&lt;/li&gt;
&lt;li&gt;Your SaaS app (the SP) redirects Alice's browser to her company's Okta tenant.&lt;/li&gt;
&lt;li&gt;Okta sees Alice doesn't have an active session, shows the Okta login page.&lt;/li&gt;
&lt;li&gt;Alice enters her Okta password and approves an MFA prompt on her phone.&lt;/li&gt;
&lt;li&gt;Okta creates a SAML assertion (a signed XML document saying "this is &lt;a href="mailto:alice@acme.com"&gt;alice@acme.com&lt;/a&gt;, member of Engineering group") and POSTs it to your SaaS app's ACS URL.&lt;/li&gt;
&lt;li&gt;Your SaaS app validates the signature on the assertion using Okta's public certificate.&lt;/li&gt;
&lt;li&gt;Your SaaS app creates a local session for Alice and redirects her to her dashboard.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total elapsed time: typically 3 to 5 seconds, including the MFA tap. Alice never typed a password into your app. She typed it into Okta, which is the only place that holds it.&lt;/p&gt;

&lt;h2&gt;
  
  
  SP-Initiated vs IdP-Initiated SAML
&lt;/h2&gt;

&lt;p&gt;Two flow variations matter:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SP-initiated SSO&lt;/strong&gt; starts at your SaaS app's login page. Alice goes to &lt;code&gt;app.acme.com&lt;/code&gt;, clicks "Sign in with SSO," and your app redirects her to Okta. This is the most common flow and the more secure one because the request originated from your SP and is correlated by &lt;code&gt;RequestID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IdP-initiated SSO&lt;/strong&gt; starts at the IdP. Alice opens her Okta dashboard, clicks the tile for your SaaS app, and Okta POSTs a SAML assertion directly to your ACS URL without any prior request from your app. This is convenient (Alice doesn't need to remember your URL) but requires careful security validation because there's no &lt;code&gt;RequestID&lt;/code&gt; to correlate.&lt;/p&gt;

&lt;p&gt;Most B2B SaaS supports both flows. Your customer's IT admin chooses which one to enable based on their policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why You Don't Want to Be the Identity Provider
&lt;/h2&gt;

&lt;p&gt;A common confusion: "Should our SaaS app be the Identity Provider?" Almost always no.&lt;/p&gt;

&lt;p&gt;If your app holds enterprise user passwords directly, you are responsible for password storage security (bcrypt with proper cost factor, salt management, breach detection), MFA implementation (TOTP, WebAuthn, SMS), audit logging of all authentication events with 90+ day retention, account lockout policies, password rotation enforcement, and SOC 2 controls covering all of the above.&lt;/p&gt;

&lt;p&gt;By delegating authentication to the customer's IdP (Okta, Entra, Google Workspace), you offload all of that responsibility. Per the Verizon 2025 Data Breach Investigations Report, 81% of hacking-related breaches involve compromised credentials. Federating to enterprise IdPs is the single most impactful security control a B2B SaaS can implement.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Enterprise Customers Set Up Their IdP for Your App
&lt;/h2&gt;

&lt;p&gt;The customer-side flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Their IT admin opens Okta admin console (or Entra portal, or Google admin).&lt;/li&gt;
&lt;li&gt;Creates a new SAML application for your SaaS.&lt;/li&gt;
&lt;li&gt;Configures the &lt;strong&gt;ACS URL&lt;/strong&gt; (where your app receives the assertion) and the &lt;strong&gt;SP Entity ID&lt;/strong&gt; (your app's identifier).&lt;/li&gt;
&lt;li&gt;Maps user attributes (email, first name, last name, groups) from their directory to SAML attribute names your app expects.&lt;/li&gt;
&lt;li&gt;Downloads the IdP metadata XML and uploads it to your app (or to your SSOJet connection).&lt;/li&gt;
&lt;li&gt;Tests the integration, typically with a test user.&lt;/li&gt;
&lt;li&gt;Rolls out to the broader employee base.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This flow takes 30 to 60 minutes for an experienced IT admin who has done it before. For a first-time SAML admin, plan for 1 to 3 hours plus likely a screenshare with your support team. SSOJet's &lt;a&gt;self-serve admin portal&lt;/a&gt; reduces this to 10 to 20 minutes for the customer because the connection wizard handles the IdP-side configuration through templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: When to Pick Which IdP for Testing
&lt;/h2&gt;

&lt;p&gt;If you're a B2B SaaS founder setting up a test IdP for development, here's the quick decision tree.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test IdP&lt;/th&gt;
&lt;th&gt;Setup Cost&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Limitation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Okta Developer Edition (free)&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;First SAML test&lt;/td&gt;
&lt;td&gt;Limited features beyond core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Microsoft Entra (Azure free tier)&lt;/td&gt;
&lt;td&gt;15 minutes&lt;/td&gt;
&lt;td&gt;Testing Entra-specific quirks&lt;/td&gt;
&lt;td&gt;Requires Azure subscription&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Workspace trial (14 days)&lt;/td&gt;
&lt;td&gt;5 minutes&lt;/td&gt;
&lt;td&gt;Testing Google Workspace SAML&lt;/td&gt;
&lt;td&gt;Trial expires&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SSOJet SAML Tester&lt;/td&gt;
&lt;td&gt;0 minutes&lt;/td&gt;
&lt;td&gt;Quick assertion shape testing&lt;/td&gt;
&lt;td&gt;Mock IdP, not real&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most teams, the Okta Developer Edition plus the SSOJet SAML Tester covers 95% of testing needs without provisioning paid tenants.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Identity Provider Observations
&lt;/h2&gt;

&lt;p&gt;Three things engineering leads should know.&lt;/p&gt;

&lt;p&gt;First, IdPs handle MFA. Don't build your own. When a customer asks "do you support MFA?" the right answer is "we support MFA via your IdP, including TOTP, WebAuthn, SMS, and any other factors your Okta/Entra/Google admin configures." Your in-app MFA is duplicated work that procurement views as a red flag (because it suggests you don't fully understand enterprise identity).&lt;/p&gt;

&lt;p&gt;Second, IdP signing certificates rotate. Okta rotates every 24 months by default, Entra rotates per tenant policy, Google handles it automatically. If you cache the signing cert and don't refresh metadata, your customer's SAML stops working on cert rotation day. SSOJet auto-refreshes on a 24-hour cycle by default.&lt;/p&gt;

&lt;p&gt;Third, customer onboarding is the friction point. Customers can rarely configure their first SAML app without help. Plan for 30 to 60 minutes of customer success time per first-time enterprise SAML setup. SSOJet's self-serve admin portal cuts this dramatically; without it, your CSM team owns the friction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance Considerations Around IdPs
&lt;/h2&gt;

&lt;p&gt;Enterprise security questionnaires ask three things about your IdP integration: which IdPs you support (the answer: as many as possible, ideally all major SAML 2.0 IdPs), how you handle IdP signing cert rotation (auto-refresh on a defined interval), and what compliance certs back your SSO infrastructure (SOC 2 Type II, GDPR, ISO 27001, OpenID Certified, HIPAA where applicable).&lt;/p&gt;

&lt;p&gt;SSOJet maintains all of those certifications. Per Gartner's 2025 Magic Quadrant for Access Management, the underlying SSO infrastructure's compliance posture is now a top-three buyer criterion regardless of IdP.&lt;/p&gt;

&lt;p&gt;It depends on your customer base. A small SaaS targeting startups may only need Google Workspace and Okta support. A SaaS targeting Fortune 500 needs Okta, Entra, Ping, and Google at minimum.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What's the difference between an Identity Provider and an Authorization Server?
&lt;/h3&gt;

&lt;p&gt;The Identity Provider authenticates the user (proves who they are). The Authorization Server issues access tokens that determine what the user can do. In OIDC, these are usually the same service (Okta is both an IdP and an AS). In SAML, the IdP is purely about authentication; authorization happens in your SaaS app based on the SAML attributes. For most B2B SaaS purposes, you can think of them as the same thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can my SaaS app talk to multiple IdPs at once?
&lt;/h3&gt;

&lt;p&gt;Yes, and it must to support multiple enterprise customers. Each customer has their own IdP (Customer A on Okta, Customer B on Entra, Customer C on Google Workspace). Your SaaS app needs to look up the right IdP per customer and route the SAML/OIDC flow accordingly. SSOJet's &lt;a&gt;multi-tenant SSO&lt;/a&gt; handles this routing automatically through connection IDs.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does "federating identity" actually mean?
&lt;/h3&gt;

&lt;p&gt;"Federating identity" means an external system (the IdP) authenticates a user on your application's behalf and you trust the result. The opposite is "local authentication" where your app stores passwords and verifies them itself. Federated identity is the standard for enterprise B2B SaaS because it removes password storage from your application.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do small businesses use Identity Providers?
&lt;/h3&gt;

&lt;p&gt;Increasingly yes. Google Workspace and Microsoft 365 both ship with built-in IdP capability. A 20-person startup using Google Workspace already has an IdP available; they just need to configure their SaaS apps to use it. The 78% IdP adoption rate from Okta's Businesses at Work 2025 includes many SMBs as well as enterprises.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens when an employee leaves a company that uses an IdP?
&lt;/h3&gt;

&lt;p&gt;Their IT admin disables their account in the IdP. Their SSO into your SaaS app stops working immediately on the next login attempt. With SCIM provisioning configured, their account in your SaaS app is also deactivated automatically (typically within minutes). Without SCIM, the account stays in your app until manually removed, which creates the orphaned-account risk that the Verizon DBIR identifies as a top breach vector.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I be both an IdP and an SP?
&lt;/h3&gt;

&lt;p&gt;Yes. Some SaaS products are both: they let users sign in via external IdPs (acting as SP) and also issue identities to consumers (acting as IdP). Auth0 and Okta themselves are examples. For B2B SaaS focused on serving enterprise customers, you almost always want to be an SP only and let the customer's IT-managed IdP handle authentication.&lt;/p&gt;

&lt;p&gt;If you're ready to add support for any major IdP without building SAML yourself, &lt;a href="https://ssojet.com" rel="noopener noreferrer"&gt;start a 30-day free trial of SSOJet&lt;/a&gt; and ship enterprise SSO in days.&lt;/p&gt;

</description>
      <category>whatisanidentityprov</category>
      <category>idpexplainer</category>
      <category>idpvssp</category>
      <category>samlidentityprovider</category>
    </item>
    <item>
      <title>RBAC vs ABAC: Which Access Control Model Is Right for Your Enterprise SaaS Product?</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Fri, 08 May 2026 09:26:23 +0000</pubDate>
      <link>https://forem.com/ssojet/rbac-vs-abac-which-access-control-model-is-right-for-your-enterprise-saas-product-opp</link>
      <guid>https://forem.com/ssojet/rbac-vs-abac-which-access-control-model-is-right-for-your-enterprise-saas-product-opp</guid>
      <description>&lt;p&gt;According to NIST SP 800-162 (the authoritative ABAC reference document), Role-Based Access Control (RBAC) covers the access control needs of approximately 90% of enterprise applications, while Attribute-Based Access Control (ABAC) is the right choice when policies depend on contextual attributes (department, location, time of day, classification level) that change too frequently to encode as roles. Yet most B2B SaaS engineering teams default to ABAC out of caution, end up with a permission system that's overly complex for their actual customers, and pay the cost in slower feature velocity and harder-to-debug authorization bugs.&lt;/p&gt;

&lt;p&gt;After helping 100+ B2B SaaS engineering teams design enterprise-ready authorization, the same pattern shows up: teams overestimate ABAC's necessity at the design phase, ship a complex policy engine, then realize that 95% of their customers' use cases were RBAC-shaped all along. This guide explains the difference with concrete B2B SaaS examples, gives a decision framework, and shows how SCIM group sync from enterprise IdPs feeds cleanly into RBAC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RBAC vs ABAC enterprise SaaS:&lt;/strong&gt; RBAC (Role-Based Access Control) grants permissions based on a user's assigned role (e.g., "admin can edit billing"), where roles are predefined and users are assigned to them. ABAC (Attribute-Based Access Control) grants permissions based on policies that evaluate attributes of the user, resource, action, and environment (e.g., "user can edit records where department=Finance AND region=EMEA AND time&amp;lt;5PM"). RBAC fits ~90% of B2B SaaS use cases. ABAC earns its complexity in regulated verticals with row-level data security requirements.&lt;/p&gt;

&lt;h2&gt;
  
  
  RBAC With Real B2B SaaS Examples
&lt;/h2&gt;

&lt;p&gt;RBAC's mental model: roles are predefined buckets, users go in buckets, permissions belong to buckets.&lt;/p&gt;

&lt;p&gt;A real B2B SaaS example, taken from a customer success management product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;can do everything&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Manager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;can view all team data, edit assigned customer accounts&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Member&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;can view and edit only own assigned customer accounts&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Viewer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-only access to all team data&lt;/span&gt;

&lt;span class="na"&gt;Permissions per role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;delete&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;billing&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;Manager&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;assigned_accounts&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;Member&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;own&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;write&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;own&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;Viewer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;User assignments&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="s"&gt;alice@acme.com → Admin&lt;/span&gt;
  &lt;span class="s"&gt;bob@acme.com → Manager&lt;/span&gt;
  &lt;span class="s"&gt;charlie@acme.com → Member&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire authorization model. It scales to 100,000 users without growing in complexity. Adding a new permission means adding it to one or more existing roles. Adding a new user means assigning them to a role.&lt;/p&gt;

&lt;p&gt;Per NIST SP 800-162, RBAC is the right choice when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The number of distinct roles is manageable (typically under 20).&lt;/li&gt;
&lt;li&gt;Roles map cleanly to job functions in the customer's organization.&lt;/li&gt;
&lt;li&gt;Permission decisions don't depend on dynamic context (time of day, IP location, request frequency).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For 90% of B2B SaaS, this is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  ABAC With Real B2B SaaS Examples
&lt;/h2&gt;

&lt;p&gt;ABAC's mental model: every access decision is a policy evaluation against four attribute categories (subject, resource, action, environment).&lt;/p&gt;

&lt;p&gt;A real B2B SaaS example, from a healthcare records product where ABAC is required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Policy: "A clinician can view a patient record if:
  - clinician.department == patient.assigned_department
  AND
  - clinician.clearance_level &amp;gt;= patient.sensitivity_level
  AND
  - request.time is within clinician.shift_hours
  AND
  - request.ip_country == patient.consented_country"

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a policy ABAC handles cleanly. Encoding it as RBAC roles is impossible because the same clinician can view some patient records and not others depending on dynamic attributes.&lt;/p&gt;

&lt;p&gt;Per NIST SP 800-162, ABAC is the right choice when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Access decisions depend on attribute values that change frequently (department transfers, clearance changes).&lt;/li&gt;
&lt;li&gt;Row-level data security is required (one user can see some records and not others within the same table).&lt;/li&gt;
&lt;li&gt;Policies are complex and combine multiple attribute categories.&lt;/li&gt;
&lt;li&gt;Compliance frameworks (HIPAA, FedRAMP, financial services regulations) require attribute-based audit trails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the small percentage of B2B SaaS that legitimately need this, ABAC is structurally correct. For everyone else, ABAC is over-engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  When RBAC Is Enough (90% of B2B SaaS)
&lt;/h2&gt;

&lt;p&gt;The honest decision: most B2B SaaS startup ideas don't need ABAC. If your product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Sells to companies under 5,000 employees.&lt;/li&gt;
&lt;li&gt;Is not in healthcare, finance, defense, or other heavily regulated verticals.&lt;/li&gt;
&lt;li&gt;Has fewer than 20 distinct permission types.&lt;/li&gt;
&lt;li&gt;Doesn't have row-level data security requirements (every user sees the same database tables; just different rows of data they own).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need ABAC. RBAC will serve you for years. Most B2B SaaS at the seed and Series A stage that build ABAC are paying ongoing complexity cost for a flexibility they don't yet use.&lt;/p&gt;

&lt;h2&gt;
  
  
  When ABAC Earns Its Complexity
&lt;/h2&gt;

&lt;p&gt;Three categories where ABAC is genuinely necessary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Healthcare and life sciences.&lt;/strong&gt; HIPAA's "minimum necessary" rule requires that users only access patient data they have a legitimate clinical need to see. Encoding "legitimate need" as static roles is intractable for any non-trivial care setting. ABAC with policies based on patient assignment, department, and shift fits the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Financial services.&lt;/strong&gt; SEC and FINRA regulations require segregation of duties and conflict-of-interest checks. A trader cannot also approve their own trades. ABAC policies that check subject and resource attributes prevent these conflicts at the access layer rather than at the audit layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-region with data sovereignty.&lt;/strong&gt; EU customer data can only be accessed by EU-based employees. US customer data only by US-based. ABAC with environment.user_location and resource.data_region attributes enforces this; RBAC would require duplicating roles per region.&lt;/p&gt;

&lt;p&gt;If your product is in one of these categories, design ABAC from the start. Retrofitting RBAC into ABAC is a 2-quarter project.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SCIM Group Sync Feeds Into RBAC
&lt;/h2&gt;

&lt;p&gt;For B2B SaaS using RBAC, the sweet spot is mapping the customer's IdP groups directly to your application roles via SCIM:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Customer's IT admin creates groups in their Okta tenant: "Acme Admins," "Acme Managers," "Acme Members."&lt;/li&gt;
&lt;li&gt;SCIM provisioning sends these groups to your SaaS app along with each user's group memberships.&lt;/li&gt;
&lt;li&gt;Your app maps Okta groups to internal roles via a configuration: "Acme Admins" → &lt;code&gt;Admin&lt;/code&gt;, "Acme Managers" → &lt;code&gt;Manager&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;When a user's group membership changes in Okta, SCIM pushes the update and your app reassigns their role automatically.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The customer's IT admin manages access in their familiar Okta admin console. Your engineering team doesn't build a custom admin UI for role assignment. The provisioning flow is the role assignment flow.&lt;/p&gt;

&lt;p&gt;For details on SCIM provisioning, see &lt;a&gt;the SSOJet SCIM identity management guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: RBAC vs ABAC for B2B SaaS
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;RBAC&lt;/th&gt;
&lt;th&gt;ABAC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Complexity&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for customer base&lt;/td&gt;
&lt;td&gt;Under 5,000 employees per customer&lt;/td&gt;
&lt;td&gt;Regulated industries, multi-region, large enterprises&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Permission model size&lt;/td&gt;
&lt;td&gt;5-20 roles&lt;/td&gt;
&lt;td&gt;Hundreds of attribute combinations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sync from IdP via SCIM&lt;/td&gt;
&lt;td&gt;Direct group → role mapping&lt;/td&gt;
&lt;td&gt;Attributes synced individually&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Audit log shape&lt;/td&gt;
&lt;td&gt;"User X has role Y"&lt;/td&gt;
&lt;td&gt;"User X allowed because policy Z evaluated true"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Implementation time&lt;/td&gt;
&lt;td&gt;1-2 weeks&lt;/td&gt;
&lt;td&gt;2-3 months for proper policy engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Common in&lt;/td&gt;
&lt;td&gt;SaaS, internal apps, e-commerce&lt;/td&gt;
&lt;td&gt;Healthcare, finance, defense, regulated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most B2B SaaS at any stage, RBAC is the right answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision Framework
&lt;/h2&gt;

&lt;p&gt;Three questions to choose between RBAC and ABAC:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question 1: Do your customers' authorization needs map to under 20 distinct roles?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Yes: RBAC.&lt;/li&gt;
&lt;li&gt;No: continue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Question 2: Do your access decisions need to evaluate attributes that change more often than role assignments?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No: RBAC.&lt;/li&gt;
&lt;li&gt;Yes: continue.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Question 3: Are you in healthcare, finance, defense, or another heavily regulated vertical with documented attribute-based access requirements?&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No: RBAC. The complexity isn't worth it.&lt;/li&gt;
&lt;li&gt;Yes: ABAC.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you answered "yes" to all three questions, ABAC is right. If you said "no" to any, default to RBAC.&lt;/p&gt;

&lt;p&gt;It depends on your specific product, of course. Some general-purpose B2B SaaS targeting both regulated and unregulated verticals build a hybrid: RBAC by default, ABAC for the policies that specific regulated customers require.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Authorization Patterns I've Seen
&lt;/h2&gt;

&lt;p&gt;Three patterns from B2B SaaS that designed authorization at scale.&lt;/p&gt;

&lt;p&gt;The first pattern: a workflow SaaS that built ABAC at the seed stage to be "future-proof." 18 months later, every customer used only 4 effective roles ("Admin," "Editor," "Commenter," "Viewer") and the ABAC policy engine was a maintenance burden the team rewrote into RBAC. Net cost: 6 weeks of refactoring plus opportunity cost.&lt;/p&gt;

&lt;p&gt;The second pattern: a healthcare analytics SaaS that built RBAC at the seed stage and hit ABAC requirements when their first hospital system customer required PHI access policies based on patient assignment. Net cost: 12 weeks of retrofit plus the customer waited 3 months.&lt;/p&gt;

&lt;p&gt;The third pattern: a customer success SaaS that designed RBAC with SCIM group sync from the start. The customer's IT admin manages roles in Okta; the SaaS team never built a role assignment UI. GrackerAI followed this pattern and closed 3 enterprise deals in their first month largely because the IT admin onboarding was self-serve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance Considerations
&lt;/h2&gt;

&lt;p&gt;Authorization model choice affects compliance evidence. RBAC is easier to audit because every access decision maps to a role assignment that's easy to query. ABAC requires a policy decision log that's more complex to query but can satisfy detailed audit questions.&lt;/p&gt;

&lt;p&gt;Per the AICPA's 2024 SOC 2 audit guide, both RBAC and ABAC can satisfy CC6.1 (logical access) controls when implemented with proper logging. Per Gartner's 2025 Magic Quadrant for IGA, the trend is for B2B SaaS to default RBAC with ABAC overlays for specific high-risk operations.&lt;/p&gt;

&lt;p&gt;According to NIST SP 800-162, the implementation cost of ABAC is significantly higher than RBAC for the same security outcome unless the use case genuinely requires attribute-based decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Can I start with RBAC and migrate to ABAC later if needed?
&lt;/h3&gt;

&lt;p&gt;Yes, but plan for it to be a 2 to 3-month project. Migrating means rewriting your authorization layer, mapping existing roles to ABAC policies, building a policy engine, and reconciling audit logs. Most B2B SaaS that start with RBAC never need to migrate; the 90% rule is real.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the relationship between SCIM groups and RBAC roles?
&lt;/h3&gt;

&lt;p&gt;In a clean implementation: SCIM groups (from the customer's IdP) are the input, your application roles are the output. SCIM provisioning syncs group memberships, your app maps groups to roles via a configuration. The customer's IT admin manages access in their familiar Okta/Entra/Google console; your team doesn't build admin UI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does SSOJet support both RBAC and ABAC?
&lt;/h3&gt;

&lt;p&gt;SSOJet's SAML and SCIM provisioning are agnostic to your application's authorization model. Both protocols pass user attributes (groups, department, location) that your app can use for either RBAC or ABAC decisions. The choice between RBAC and ABAC happens in your application code, not in the SSO/SCIM layer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I have role hierarchies in RBAC?
&lt;/h3&gt;

&lt;p&gt;Yes. NIST SP 800-162 defines hierarchical RBAC where senior roles inherit junior role permissions (e.g., Admin includes all Manager permissions). Most application frameworks support this either natively or through libraries.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I avoid the "permission explosion" problem in RBAC?
&lt;/h3&gt;

&lt;p&gt;Keep your permission set small. Group fine-grained permissions into coarse-grained ones (e.g., one "billing:manage" permission instead of "billing:view," "billing:edit," "billing:export"). If you have more than 30-40 distinct permission types, reconsider whether your authorization model needs to be that granular.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's a "ReBAC" model and how does it relate?
&lt;/h3&gt;

&lt;p&gt;Relationship-Based Access Control (ReBAC) is a third model popularized by Google's Zanzibar paper. ReBAC checks "is user X related to resource Y in way Z" (e.g., "is alice the owner of document 123"). It's structurally between RBAC and ABAC and fits document-sharing or social-graph products well. For most B2B SaaS, RBAC remains the simpler choice; ReBAC is worth considering for content-collaboration products like Notion or Google Docs.&lt;/p&gt;

&lt;p&gt;If you're ready to wire enterprise SCIM group sync into your existing RBAC model, &lt;a href="https://ssojet.com" rel="noopener noreferrer"&gt;start a 30-day free trial of SSOJet&lt;/a&gt; and ship enterprise-ready in days.&lt;/p&gt;

</description>
      <category>rbacvsabacenterprise</category>
      <category>rolebasedaccesscontr</category>
      <category>attributebasedaccess</category>
      <category>b2bsaaspermissions</category>
    </item>
    <item>
      <title>Enterprise SSO in FastAPI: How to Add SAML and OIDC Auth to Python APIs in 2026</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Thu, 07 May 2026 09:47:45 +0000</pubDate>
      <link>https://forem.com/ssojet/enterprise-sso-in-fastapi-how-to-add-saml-and-oidc-auth-to-python-apis-in-2026-3kki</link>
      <guid>https://forem.com/ssojet/enterprise-sso-in-fastapi-how-to-add-saml-and-oidc-auth-to-python-apis-in-2026-3kki</guid>
      <description>&lt;p&gt;According to the Verizon 2025 Data Breach Investigations Report, 81% of hacking-related breaches involved compromised or weak credentials. For Python API teams building B2B SaaS on FastAPI, that number translates directly to a sales blocker: enterprise procurement teams require SSO as a hard gate before contracts get signed, and "we'll add it after the deal" is not a strategy that closes regulated enterprise accounts.&lt;/p&gt;

&lt;p&gt;The practical challenge for FastAPI developers is that Python's enterprise SSO ecosystem is thinner than Java or .NET. Rolling your own SAML parser in Python means owning &lt;code&gt;python3-saml&lt;/code&gt;, managing per-tenant XML signature validation, and debugging encoding edge cases across Okta, Azure AD, and Ping Identity tenants. The better path is SSOJet's OIDC hosted page flow: your FastAPI app redirects to SSOJet's hosted authorization page, the enterprise user authenticates against their IdP, and SSOJet returns a standard OIDC authorization code to your callback endpoint. You write zero SAML code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise SSO in FastAPI:&lt;/strong&gt; The practice of wiring a FastAPI application to SSOJet's hosted OIDC authorization page so that enterprise customers authenticate through their own managed identity provider (Okta, Azure AD, Google Workspace, OneLogin) rather than a username-and-password form, with SSOJet handling all SAML 2.0 XML parsing, assertion signature validation, and claim normalization before returning a standard OIDC authorization code to your FastAPI callback route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI has no built-in SSO or SAML support. SSOJet's OIDC hosted page flow exposes all enterprise SAML connections through a standard OpenID Connect interface that &lt;code&gt;httpx&lt;/code&gt; and &lt;code&gt;python-jose&lt;/code&gt; handle natively in FastAPI.&lt;/li&gt;
&lt;li&gt;SSOJet's hosted page handles enterprise IdP routing, SAML XML parsing, assertion validation, and claim normalization. Your FastAPI app never imports &lt;code&gt;python3-saml&lt;/code&gt; or touches SAML XML.&lt;/li&gt;
&lt;li&gt;PKCE (Proof Key for Code Exchange, RFC 7636) is enforced by SSOJet on all authorization code flows. Your FastAPI app generates the &lt;code&gt;code_verifier&lt;/code&gt; and &lt;code&gt;code_challenge&lt;/code&gt; pair and stores the verifier in the server-side session for callback verification.&lt;/li&gt;
&lt;li&gt;JIT (just-in-time) user provisioning in the callback endpoint creates or updates SQLAlchemy user records from the normalized OIDC claims, scoped by &lt;code&gt;(external_id, connection_id)&lt;/code&gt; to prevent multi-tenant account collisions.&lt;/li&gt;
&lt;li&gt;FastAPI's &lt;code&gt;Depends()&lt;/code&gt; system is the right place for session-based auth guards. A reusable &lt;code&gt;get_current_user&lt;/code&gt; dependency keeps auth logic in one place rather than scattered across route handlers.&lt;/li&gt;
&lt;li&gt;SSOJet's flat-rate $49/month pricing with no per-user charges means your tenth enterprise customer costs exactly the same as your first, unlike Auth0 and WorkOS which charge per connection or per MAU.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why FastAPI Teams Use a Broker for Enterprise SSO
&lt;/h2&gt;

&lt;p&gt;FastAPI is an excellent choice for B2B SaaS APIs. Its async-first design, Pydantic validation, and automatic OpenAPI documentation make it genuinely competitive for the kinds of data-intensive platforms that enterprise customers buy. But the enterprise auth story in Python requires some candor.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python3-saml&lt;/code&gt; works and is actively maintained, but production multi-tenant deployments require you to manage per-customer IdP configurations, XML signature validation certificates, and ACS URL routing logic that has nothing to do with your product. At one customer that's a sprint. At ten customers it's an ongoing operational burden with meaningful security surface area.&lt;/p&gt;

&lt;p&gt;The broker pattern changes the equation. SSOJet handles every SAML interaction on your behalf and exposes a clean OIDC endpoint. Your FastAPI app uses standard OAuth2 patterns that Python developers already know. When a new enterprise customer onboards, you create a SSOJet connection in the dashboard. Your FastAPI code doesn't change.&lt;/p&gt;

&lt;p&gt;According to the JetBrains 2024 Python Developer Survey, FastAPI is now used by 28% of Python developers for web development, up from 18% in 2022. That's a massive install base of developers who deserve a clean enterprise auth integration path. The full &lt;a href="https://ssojet.com/blog/b2b-authentication-provider-comparison-features-pricing-sso-support" rel="noopener noreferrer"&gt;B2B authentication provider comparison&lt;/a&gt; covers how SSOJet stacks up against Auth0, WorkOS, and Keycloak for multi-tenant B2B SaaS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing Your Approach: OIDC Hosted Page vs Direct SAML
&lt;/h2&gt;

&lt;p&gt;Before writing any code, commit to one path. These approaches diverge enough that switching after your first enterprise customer is a meaningful refactor.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;SAML Code in App&lt;/th&gt;
&lt;th&gt;Multi-Tenant&lt;/th&gt;
&lt;th&gt;Setup Time&lt;/th&gt;
&lt;th&gt;Python Library Needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SSOJet OIDC hosted page (this guide)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;Hours&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;httpx&lt;/code&gt;, &lt;code&gt;python-jose&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;python3-saml&lt;/code&gt; direct&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;td&gt;1 to 2 weeks&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;python3-saml&lt;/code&gt;, &lt;code&gt;lxml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pysaml2&lt;/code&gt; direct&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;DIY&lt;/td&gt;
&lt;td&gt;1 to 2 weeks&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pysaml2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth0 Python SDK&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;1 to 2 days&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authlib&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keycloak as SP broker&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;3 to 5 days&lt;/td&gt;
&lt;td&gt;Keycloak ops overhead&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a FastAPI B2B SaaS team, the SSOJet OIDC hosted page is the right default. No SAML library ownership, no per-tenant XML configuration, and the Python code stays clean. The &lt;a href="https://ssojet.com/blog/best-sso-scim-providers-for-b2b-saas-selling-to-enterprise-2026-ranked-guide" rel="noopener noreferrer"&gt;best SSO and SCIM providers for B2B SaaS in 2026&lt;/a&gt; covers all the major options in a ranked format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Setup and Dependencies
&lt;/h2&gt;

&lt;p&gt;You need Python 3.11+, FastAPI 0.110+, and no SAML libraries at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;fastapi uvicorn[standard] httpx python-jose[cryptography] &lt;span class="se"&gt;\&lt;/span&gt;
    sqlalchemy alembic pydantic-settings itsdangerous python-multipart

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Project structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;myapp/
├── main.py
├── config.py
├── auth/
│ ├── __init__.py
│ ├── oidc.py # SSOJet OIDC flow logic
│ ├── session.py # Session JWT signing and verification
│ └── dependencies.py # FastAPI auth dependencies
├── models/
│ ├── __init__.py
│ └── user.py # SQLAlchemy User model
├── routers/
│ ├── auth.py # /auth/login, /auth/callback, /auth/logout
│ └── dashboard.py # Protected routes
└── database.py

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;config.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseSettings&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseSettings&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;ssojet_client_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;ssojet_client_secret&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;ssojet_redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;ssojet_issuer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.ssojet.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;ssojet_api_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;session_secret_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;database_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sqlite:///./app.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;env_file&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.env&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Settings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;SSOJET_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_client_id&lt;/span&gt;
&lt;span class="py"&gt;SSOJET_CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;your_client_secret&lt;/span&gt;
&lt;span class="py"&gt;SSOJET_REDIRECT_URI&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://yourdomain.com/auth/callback&lt;/span&gt;
&lt;span class="py"&gt;SSOJET_API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;sk_live_xxx&lt;/span&gt;
&lt;span class="py"&gt;SESSION_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;a-minimum-32-character-random-string&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never commit &lt;code&gt;.env&lt;/code&gt; to source control. In production on Railway, Render, or AWS, use the platform's secret management. The &lt;code&gt;SSOJET_REDIRECT_URI&lt;/code&gt; must exactly match the callback URL registered in your SSOJet dashboard. Per NIST SP 800-63B, session secret keys should have at least 112 bits of entropy, so use a minimum 32-character random string generated with &lt;code&gt;secrets.token_urlsafe(32)&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SSOJet's OIDC Hosted Page Flow Works in FastAPI
&lt;/h2&gt;

&lt;p&gt;Understanding the five-step runtime sequence before writing route handlers prevents the class of bugs that stem from misconfigured redirect URIs and missing scopes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1:&lt;/strong&gt; An unauthenticated user requests a protected FastAPI endpoint. The &lt;code&gt;get_current_user&lt;/code&gt; dependency checks for a valid session cookie, finds none, and raises an HTTP 401 or redirects to &lt;code&gt;/auth/login&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2:&lt;/strong&gt; Your &lt;code&gt;/auth/login&lt;/code&gt; route generates a PKCE &lt;code&gt;code_verifier&lt;/code&gt; and &lt;code&gt;code_challenge&lt;/code&gt; pair, signs a &lt;code&gt;state&lt;/code&gt; JWT containing a nonce and &lt;code&gt;return_to&lt;/code&gt; path, stores the &lt;code&gt;code_verifier&lt;/code&gt; and &lt;code&gt;state&lt;/code&gt; in the server-side session, and redirects the browser to &lt;code&gt;https://auth.ssojet.com/authorize&lt;/code&gt; with the &lt;code&gt;client_id&lt;/code&gt;, requested scopes, &lt;code&gt;state&lt;/code&gt;, and &lt;code&gt;code_challenge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3:&lt;/strong&gt; On SSOJet's hosted page, the user enters their work email. SSOJet resolves the email domain to the correct enterprise connection and initiates the SAML or OIDC exchange with that customer's IdP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4:&lt;/strong&gt; The enterprise user authenticates against their corporate IdP. SSOJet validates the assertion, normalizes the user profile into standard OIDC claims (&lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;given_name&lt;/code&gt;, &lt;code&gt;family_name&lt;/code&gt;, &lt;code&gt;groups&lt;/code&gt;, &lt;code&gt;connection_id&lt;/code&gt;), and redirects to your FastAPI callback URL with an authorization code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5:&lt;/strong&gt; Your &lt;code&gt;/auth/callback&lt;/code&gt; route verifies the &lt;code&gt;state&lt;/code&gt; signature and nonce, exchanges the authorization code at SSOJet's token endpoint using the stored &lt;code&gt;code_verifier&lt;/code&gt;, runs JIT user provisioning, creates a signed session cookie, and redirects the user to their intended destination.&lt;/p&gt;

&lt;p&gt;Your FastAPI code is only involved in steps 2 and 5. Everything else is SSOJet's responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OIDC Module: PKCE, Authorization URL, and Code Exchange
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# auth/oidc.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate_pkce_pair&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate a PKCE code_verifier and code_challenge (S256 method).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;verifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_urlsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;digest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlsafe_b64encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;verifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;challenge&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_authorization_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Build the SSOJet hosted page authorization URL.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;response_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssojet_client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect_uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssojet_redirect_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scope&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openid profile email groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code_challenge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code_challenge_method&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.ssojet.com/authorize?&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;exchange_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Exchange the authorization code for an access token,
    then fetch the normalized user profile from SSOJet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s UserInfo endpoint.
    SSOJet has already validated the SAML assertion or OIDC token before
    issuing this code. Your app never touches raw SAML XML.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;token_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.ssojet.com/oauth/token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorization_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect_uri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssojet_redirect_uri&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssojet_client_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_secret&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ssojet_client_secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code_verifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;token_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;access_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;userinfo_resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://auth.ssojet.com/userinfo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;userinfo_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;userinfo_resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PKCE with &lt;code&gt;S256&lt;/code&gt; is the only method SSOJet accepts, per OAuth 2.0 Security Best Current Practice (RFC 9700). The &lt;code&gt;code_verifier&lt;/code&gt; is a cryptographically random string. The &lt;code&gt;code_challenge&lt;/code&gt; is its SHA-256 hash, base64url-encoded without padding. SSOJet stores the challenge on the authorization request and verifies the verifier matches when your server presents it at the token endpoint. This prevents authorization code interception attacks, which OWASP classifies as a high-impact risk for OAuth2 flows.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;exchange_code&lt;/code&gt; function is async because FastAPI is async-first and &lt;code&gt;httpx.AsyncClient&lt;/code&gt; integrates cleanly with the event loop. The server-to-server token exchange happens entirely in your backend. The authorization code never touches the browser's JavaScript context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Session Module: Signing and Verifying State JWTs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# auth/session.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

&lt;span class="n"&gt;ALGORITHM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;STATE_EXPIRY_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="c1"&gt;# 10 minutes: enough for hardware MFA flows
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sign_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Create a signed state JWT for CSRF protection on the OIDC callback.
    RFC 6749 Section 10.12 requires verifying state to prevent CSRF attacks.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nonce&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;token_urlsafe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;STATE_EXPIRY_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_secret_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Verify the state JWT. Raises JWTError on tampered or expired tokens.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_secret_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verify_exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_session_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Create a signed session JWT stored in an httpOnly cookie.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hours&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_secret_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;decode_session_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Decode and verify a session JWT. Raises JWTError on failure.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_secret_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;algorithms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;ALGORITHM&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 10-minute state token expiry handles enterprise SSO flows that involve hardware MFA tokens, manager approval workflows, or Duo push notifications. A 60-second expiry looks more secure but fails legitimate users in these multi-step flows. The 8-hour session token matches typical enterprise workday length and avoids annoying re-authentication during active work sessions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Auth Router: Login, Callback, and Logout
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# routers/auth.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;APIRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;RedirectResponse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;itsdangerous&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;URLSafeTimedSerializer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BadSignature&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth.oidc&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;generate_pkce_pair&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;build_authorization_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exchange_code&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth.session&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sign_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verify_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;create_session_token&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models.user&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;upsert_sso_user&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;APIRouter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# Use itsdangerous for secure server-side session (stores PKCE verifier + state)
&lt;/span&gt;&lt;span class="n"&gt;_signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;URLSafeTimedSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_secret_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;SESSION_COOKIE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssojet_session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;AUTH_STATE_COOKIE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssojet_auth_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@router.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Initiate the SSOJet OIDC hosted page flow.
    Generates PKCE pair, signs state JWT, redirects to SSOJet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s hosted page.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code_challenge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate_pkce_pair&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sign_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Store code_verifier in a short-lived, signed cookie.
&lt;/span&gt;    &lt;span class="c1"&gt;# It must survive the round trip to SSOJet and back.
&lt;/span&gt;    &lt;span class="n"&gt;auth_state_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code_verifier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;authorization_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_authorization_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;code_challenge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;code_challenge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;authorization_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AUTH_STATE_COOKIE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auth_state_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;httponly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;secure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;samesite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Must be lax for cross-site OIDC redirect to work
&lt;/span&gt;        &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;

&lt;span class="nd"&gt;@router.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/callback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;error_description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Handle the OIDC authorization code callback from SSOJet.
    Verifies state, exchanges code, provisions user, creates session.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# Surface IdP-side errors gracefully
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SSO authentication failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error_description&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing code or state parameter.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Retrieve the PKCE verifier and expected state from the auth state cookie
&lt;/span&gt;    &lt;span class="n"&gt;auth_state_cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AUTH_STATE_COOKIE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;auth_state_cookie&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Auth state cookie missing. SSO session may have expired.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;auth_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth_state_cookie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;BadSignature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Invalid auth state cookie.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Verify the state JWT to prevent CSRF (RFC 6749 Section 10.12)
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;auth_state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;State mismatch. Possible CSRF attempt.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;state_claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;State token expired or invalid.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Exchange the authorization code for a normalized user profile.
&lt;/span&gt;    &lt;span class="c1"&gt;# SSOJet has already validated the SAML assertion at this point.
&lt;/span&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;profile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;exchange_code&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;code_verifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;auth_state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SSOJet code exchange failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# JIT provisioning: create or update the user from OIDC token claims
&lt;/span&gt;    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;upsert_sso_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;return_to&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state_claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;return_to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;session_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_session_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Clear the short-lived auth state cookie
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AUTH_STATE_COOKIE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Set the long-lived session cookie
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;session_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;httponly&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Not accessible to JavaScript
&lt;/span&gt;        &lt;span class="n"&gt;secure&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# HTTPS only
&lt;/span&gt;        &lt;span class="n"&gt;samesite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lax&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Correct for post-login top-level navigation
&lt;/span&gt;        &lt;span class="n"&gt;max_age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;

&lt;span class="nd"&gt;@router.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/logout&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;logout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Clear the session cookie and redirect to home.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;RedirectResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;302&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete_cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;samesite="lax"&lt;/code&gt; setting on both cookies is not negotiable. When SSOJet's hosted page redirects back to your &lt;code&gt;/auth/callback&lt;/code&gt;, the browser performs a top-level cross-site navigation. &lt;code&gt;lax&lt;/code&gt; allows cookies to be sent on these top-level navigations from external sites. &lt;code&gt;strict&lt;/code&gt; blocks them, causing the auth state cookie to be absent on the callback and breaking state verification every time. This is the single most common misconfiguration in Python OIDC integrations.&lt;/p&gt;

&lt;p&gt;The two-cookie pattern (short-lived &lt;code&gt;ssojet_auth_state&lt;/code&gt; + long-lived &lt;code&gt;ssojet_session&lt;/code&gt;) is cleaner than using a server-side session store. The auth state cookie carries the PKCE verifier and signed state across the OIDC redirect. The session cookie carries the authenticated user identity. Both are signed with &lt;code&gt;itsdangerous&lt;/code&gt; and &lt;code&gt;python-jose&lt;/code&gt; respectively, so tampering is detectable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Auth Dependency: Protecting FastAPI Routes
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# auth/dependencies.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;jose&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth.session&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;decode_session_token&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models.user&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_user_by_id&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_db&lt;/span&gt;

&lt;span class="n"&gt;SESSION_COOKIE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssojet_session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ssojet_session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;SESSION_COOKIE&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_db&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    FastAPI dependency for session-based authentication.
    Inject into any route handler that requires an authenticated user.
    Usage: user = Depends(get_current_user)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ssojet_session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_401_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Not authenticated. Visit /auth/login to sign in.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WWW-Authenticate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;decode_session_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ssojet_session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;JWTError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_401_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Session expired. Please sign in again.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;get_user_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_401_UNAUTHORIZED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User account not found or deactivated.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;require_admin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Dependency for admin-only routes.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTP_403_FORBIDDEN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;detail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Admin access required.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastAPI's &lt;code&gt;Depends()&lt;/code&gt; system makes auth injection clean and composable. Any route that needs authentication adds &lt;code&gt;user: User = Depends(get_current_user)&lt;/code&gt;. Admin-only routes add &lt;code&gt;user: User = Depends(require_admin)&lt;/code&gt;. You can stack dependencies, cache their results within a request, and test them in isolation without touching the real session infrastructure. This is one of the genuinely good design choices in FastAPI's architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The User Model: JIT Provisioning With SQLAlchemy
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models/user.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.ext.asyncio&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.future&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;database&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Base&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;external_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;connection_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# SSOJet tenant identifier
&lt;/span&gt;    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nullable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;member&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;String&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;provisioned_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;DateTime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="c1"&gt;# Composite unique constraint: prevents multi-tenant account collisions
&lt;/span&gt;    &lt;span class="n"&gt;__table_args__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="c1"&gt;# UniqueConstraint imported separately in migration
&lt;/span&gt;        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;upsert_sso_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Create or update a user from SSOJet&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s normalized OIDC profile.
    Scoped to (external_id, connection_id) to prevent cross-tenant collisions.

    SSOJet returns consistent claim names regardless of whether the customer&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s
    IdP uses SAML or OIDC: sub, email, given_name, family_name, groups, connection_id.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;external_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;connection_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;default&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;connection_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;given_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;family_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_derive_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;provisioned_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_derive_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;lowered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admins&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;lowered&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;billing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;member&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user_by_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalar_one_or_none&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The composite lookup on &lt;code&gt;(external_id, connection_id)&lt;/code&gt; is the correct key for multi-tenant user records. Scoping to email alone causes silent account merging when the same email appears in two different enterprise customer tenants. The &lt;code&gt;connection_id&lt;/code&gt; SSOJet provides is globally unique per customer connection, so it's a reliable tenant boundary key that survives email domain changes and IdP migrations. Per NIST SP 800-63B guidelines on federated identity, user identifiers from an IdP must be scoped to that IdP's namespace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Main Application: Wiring Everything Together
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# main.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Depends&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.middleware.cors&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;CORSMiddleware&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.responses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTMLResponse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;routers.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;auth_router&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;routers.dashboard&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;dashboard_router&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth.dependencies&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_current_user&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;My B2B SaaS API&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;docs_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/docs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;redoc_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;CORSMiddleware&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;allow_origins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://yourdomain.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;allow_credentials&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Required for cookie-based auth
&lt;/span&gt;    &lt;span class="n"&gt;allow_methods&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;allow_headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth_router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;include_router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dashboard_router&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HTMLResponse&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;home&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    &amp;lt;html&amp;gt;&amp;lt;body&amp;gt;
      &amp;lt;h1&amp;gt;Welcome&amp;lt;/h1&amp;gt;
      &amp;lt;a href=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth/login&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;Sign in with Enterprise SSO&amp;lt;/a&amp;gt;
    &amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="nd"&gt;@app.get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/me&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;me&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;Depends&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;allow_credentials=True&lt;/code&gt; in the CORS middleware is required for cookie-based authentication to work with cross-origin requests. Without it, browsers suppress the &lt;code&gt;Set-Cookie&lt;/code&gt; header on cross-origin responses, and the session cookie is never stored. This bites teams building separate frontend SPAs (React, Vue) that call the FastAPI backend on a different subdomain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Tenant SSO: Adding a Second Enterprise Customer
&lt;/h2&gt;

&lt;p&gt;Adding Customer B requires zero code changes in your FastAPI app. Create a new connection in the SSOJet dashboard for Customer B's IdP. SSOJet generates the ACS URL and SP entity ID that their IT admin needs to configure Okta or Azure AD. Your customer's IT admin completes configuration using SSOJet's self-serve portal.&lt;/p&gt;

&lt;p&gt;SSOJet associates Customer B's email domain with the new connection. When a &lt;code&gt;@customerb.com&lt;/code&gt; user enters their email on SSOJet's hosted page, SSOJet routes to Customer B's IdP. Your FastAPI callback receives the same &lt;code&gt;profile&lt;/code&gt; dict with a different &lt;code&gt;connection_id&lt;/code&gt; value. Your &lt;code&gt;upsert_sso_user&lt;/code&gt; function handles it without branching.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;connection_id&lt;/code&gt; in the user record is your stable multi-tenant data scoping key. When Customer B's user hits a protected endpoint, filter their data queries by &lt;code&gt;connection_id&lt;/code&gt; to enforce tenant isolation at the database level. Understanding &lt;a href="https://ssojet.com/blog/scim-vs-sso-understanding-identity-provisioning-vs-authentication" rel="noopener noreferrer"&gt;when SCIM adds value beyond SSO alone&lt;/a&gt; matters before your first enterprise security questionnaire. For deprovisioning support, the &lt;a href="https://ssojet.com/blog/scim-identity-management-guide/" rel="noopener noreferrer"&gt;SCIM identity management guide&lt;/a&gt; covers exactly how OIDC SSO and SCIM work together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three Production Failure Modes for FastAPI OIDC SSO
&lt;/h2&gt;

&lt;p&gt;These appear in staging or production, not on localhost. All three are one-line fixes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 1: Auth state cookie missing on callback.&lt;/strong&gt; If the &lt;code&gt;ssojet_auth_state&lt;/code&gt; cookie isn't present when SSOJet redirects back to &lt;code&gt;/auth/callback&lt;/code&gt;, state verification fails. The cause is almost always &lt;code&gt;samesite="strict"&lt;/code&gt; on the auth state cookie, which blocks cross-site cookie delivery on the redirect from SSOJet's hosted page. The fix: use &lt;code&gt;samesite="lax"&lt;/code&gt; on both the auth state and session cookies. Document this in your security baseline so it doesn't get "tightened" later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 2: CORS blocking the session cookie on SPA frontends.&lt;/strong&gt; If you have a React or Vue frontend on &lt;code&gt;app.yourdomain.com&lt;/code&gt; calling FastAPI on &lt;code&gt;api.yourdomain.com&lt;/code&gt;, the session cookie must be a &lt;code&gt;__Host-&lt;/code&gt; prefixed cookie or explicitly scoped to the shared parent domain. The &lt;code&gt;allow_credentials=True&lt;/code&gt; CORS setting is also required, and the frontend must pass &lt;code&gt;credentials: "include"&lt;/code&gt; in &lt;code&gt;fetch&lt;/code&gt; calls. Missing any one of these three settings causes the cookie to be silently dropped with no browser error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Failure 3: Redirect URI mismatch on the token exchange.&lt;/strong&gt; The &lt;code&gt;redirect_uri&lt;/code&gt; your FastAPI app sends in the token exchange request must exactly match the URI registered in your SSOJet dashboard, including protocol, path, and trailing slash. A mismatch causes a &lt;code&gt;redirect_uri_mismatch&lt;/code&gt; error from SSOJet's token endpoint. Confirm the exact URI in your dashboard matches &lt;code&gt;SSOJET_REDIRECT_URI&lt;/code&gt; in your environment variables. The &lt;a href="https://ssojet.com/docs/" rel="noopener noreferrer"&gt;SSOJet documentation&lt;/a&gt; covers redirect URI registration in the dashboard setup guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance and Security for FastAPI SSO Apps
&lt;/h2&gt;

&lt;p&gt;Enterprise security questionnaires for FastAPI applications focus on three areas: how sessions are stored and expired, how enterprise users are deprovisioned when an IT admin removes them from their IdP, and what compliance certifications back the SSO infrastructure.&lt;/p&gt;

&lt;p&gt;On deprovisioning: OIDC alone doesn't handle it. An active FastAPI session persists until the JWT expiry even after the user is removed from Okta. The complete answer pairs SSOJet OIDC with SCIM. When SSOJet receives a SCIM deprovisioning event, it sends a webhook to a FastAPI endpoint you implement. Your &lt;code&gt;upsert_sso_user&lt;/code&gt; equivalent marks the user inactive. The &lt;code&gt;get_current_user&lt;/code&gt; dependency checks &lt;code&gt;is_active&lt;/code&gt; on every request and returns a 401 for deprovisioned users. The &lt;a href="https://ssojet.com/blog/scim-vs-saml-understanding-the-difference-between-provisioning-and-authentication" rel="noopener noreferrer"&gt;SAML vs SCIM guide&lt;/a&gt; explains the protocol-level difference between authentication and provisioning.&lt;/p&gt;

&lt;p&gt;SSOJet maintains SOC 2 Type II, GDPR, ISO 27001, and OpenID Certified status. HIPAA coverage is available for healthcare SaaS customers. Every OIDC authentication event is recorded in SSOJet's audit logs with timestamp, connection ID, user subject, and originating IP. According to the Okta Business at Work 2024 Report, organizations with federated SSO experience 50% fewer credential-based security incidents compared to password-only authentication. Routing enterprise authentication through SSOJet means your FastAPI application stops storing enterprise user passwords entirely. Check &lt;a href="https://ssojet.com/pricing/" rel="noopener noreferrer"&gt;SSOJet's pricing page&lt;/a&gt; for current plan details including the flat-rate model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing the OIDC Flow in FastAPI
&lt;/h2&gt;

&lt;p&gt;You don't need a real Okta or Azure AD tenant for local development. In the SSOJet dashboard, create a test connection and use SSOJet's built-in OIDC Tester to simulate an authorization code flow against your local FastAPI app at &lt;code&gt;http://localhost:8000&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For automated tests using pytest and FastAPI's &lt;code&gt;TestClient&lt;/code&gt;, override the &lt;code&gt;get_current_user&lt;/code&gt; dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# tests/conftest.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pytest&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.testclient&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TestClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth.dependencies&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_current_user&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;models.user&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;make_test_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dev@enterprise.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;member&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;connection_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conn_test_123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;auth_client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;TestClient with a mock authenticated user.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;test_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_test_user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;test_user&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nd"&gt;@pytest.fixture&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;admin_client&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;TestClient with a mock admin user.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth.dependencies&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;require_admin&lt;/span&gt;
    &lt;span class="n"&gt;test_admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_test_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;admin&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;get_current_user&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;test_admin&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;require_admin&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;test_admin&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dependency_overrides&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clear&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="c1"&gt;# tests/test_auth.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;unittest.mock&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AsyncMock&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;patch&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastapi.testclient&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TestClient&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;main&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_protected_route_requires_auth&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_protected_route_accessible_with_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth_client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_admin_route_forbidden_for_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth_client&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth_client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/admin/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_callback_creates_user_and_sets_session&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;routers.auth.exchange_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AsyncMock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mock_exchange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \
         &lt;span class="nf"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;routers.auth.upsert_sso_user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_callable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AsyncMock&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;mock_upsert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

        &lt;span class="n"&gt;mock_exchange&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sub&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;okta-sub-001&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dev@enterprise.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;given_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dev&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;family_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;conn_test_123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;engineering&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;mock_user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_test_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;mock_upsert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mock_user&lt;/span&gt;

        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth.session&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sign_state&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;itsdangerous&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;URLSafeTimedSerializer&lt;/span&gt;
        &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sign_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;signer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;URLSafeTimedSerializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_secret_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;auth_cookie&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;signer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test_verifier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;TestClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;follow_redirects&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/auth/callback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="n"&gt;cookies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssojet_auth_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;auth_cookie&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;302&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;location&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssojet_session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cookies&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These tests run in milliseconds without any external service dependency. The &lt;code&gt;dependency_overrides&lt;/code&gt; pattern is the idiomatic FastAPI approach to test-time auth injection. The callback test exercises the full OIDC code exchange path, including state verification, &lt;code&gt;exchange_code&lt;/code&gt; mocking, JIT provisioning, and session cookie creation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does FastAPI have built-in SAML or OIDC support I should use instead of SSOJet?
&lt;/h3&gt;

&lt;p&gt;FastAPI has no built-in SSO support. The &lt;code&gt;python-oauth2&lt;/code&gt; and &lt;code&gt;authlib&lt;/code&gt; libraries provide OIDC client functionality, but they require you to manage per-tenant IdP configurations and token validation manually for multi-tenant scenarios. SSOJet handles all of that on the broker side and returns a normalized user profile via a single OIDC endpoint. Your FastAPI code only needs &lt;code&gt;httpx&lt;/code&gt; and &lt;code&gt;python-jose&lt;/code&gt;, which most Python web apps already include.&lt;/p&gt;

&lt;h3&gt;
  
  
  How does SSOJet route different enterprise customers to their own IdP in FastAPI?
&lt;/h3&gt;

&lt;p&gt;Each enterprise customer maps to one SSOJet connection in your dashboard. SSOJet's hosted page resolves the customer user's email domain to the correct connection and initiates the SAML or OIDC exchange with that customer's IdP. Your FastAPI callback receives the same normalized &lt;code&gt;profile&lt;/code&gt; dict regardless of which IdP handled authentication. Adding a new enterprise customer requires creating a new SSOJet connection with no changes to your FastAPI application code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my FastAPI OIDC callback fail with a missing auth state cookie?
&lt;/h3&gt;

&lt;p&gt;The most common cause is &lt;code&gt;samesite="strict"&lt;/code&gt; on the auth state cookie, which blocks cross-site cookie delivery when SSOJet redirects back to your callback URL. Set &lt;code&gt;samesite="lax"&lt;/code&gt; on both your auth state cookie and your session cookie. The second common cause is HTTPS-only cookies (&lt;code&gt;secure=True&lt;/code&gt;) that don't work on &lt;code&gt;http://localhost&lt;/code&gt; during local development. Disable &lt;code&gt;secure=True&lt;/code&gt; only in local development environments and re-enable it for staging and production.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I deprovision an enterprise user when their IT admin removes them from Okta?
&lt;/h3&gt;

&lt;p&gt;OIDC SSO alone doesn't handle deprovisioning. An active FastAPI session JWT persists until its expiry even after removal from Okta. The complete solution pairs SSOJet OIDC with SCIM: when SSOJet receives a SCIM deprovisioning event from the enterprise IdP, it sends a webhook to a FastAPI endpoint you implement. Your database sets &lt;code&gt;user.is_active = False&lt;/code&gt;. The &lt;code&gt;get_current_user&lt;/code&gt; dependency checks &lt;code&gt;is_active&lt;/code&gt; on every authenticated request and returns a 401 for deprovisioned users, ending their session on the next request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use SSOJet OIDC SSO with FastAPI and a separate React or Next.js frontend?
&lt;/h3&gt;

&lt;p&gt;Yes, but you need three things configured correctly. First, set &lt;code&gt;allow_credentials=True&lt;/code&gt; in FastAPI's CORS middleware. Second, configure the session cookie with the correct domain scope so it's accessible from both your API subdomain and your frontend subdomain. Third, pass &lt;code&gt;credentials: "include"&lt;/code&gt; in every &lt;code&gt;fetch&lt;/code&gt; or &lt;code&gt;axios&lt;/code&gt; call from your frontend to the FastAPI API. Missing any one of these causes the session cookie to be silently dropped and users to appear unauthenticated on every request.&lt;/p&gt;

&lt;h3&gt;
  
  
  What OIDC claims does SSOJet return after enterprise IdP authentication in FastAPI?
&lt;/h3&gt;

&lt;p&gt;SSOJet normalizes enterprise IdP attributes into a consistent set of OIDC claims regardless of whether the customer's IdP uses SAML 2.0 or OIDC. The UserInfo endpoint always returns &lt;code&gt;sub&lt;/code&gt; (stable unique user ID), &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;given_name&lt;/code&gt;, &lt;code&gt;family_name&lt;/code&gt;, &lt;code&gt;groups&lt;/code&gt; (array of IdP group names), and &lt;code&gt;connection_id&lt;/code&gt; (SSOJet's identifier for the enterprise connection). Custom IdP attributes like department, employee ID, and cost center are available in &lt;code&gt;rawAttributes&lt;/code&gt; for applications that need them for role mapping or compliance reporting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Enterprise SAML and OIDC SSO in FastAPI doesn't require a SAML library. SSOJet's OIDC hosted page flow gives you a clean Python implementation using &lt;code&gt;httpx&lt;/code&gt; and &lt;code&gt;python-jose&lt;/code&gt;, a hosted UI your enterprise customers interact with directly, and multi-tenant IdP routing that scales to any number of customers without code changes. The three production failure modes in this guide are each a one-line fix once you know the cause.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ssojet.com" rel="noopener noreferrer"&gt;Start a 30-day free trial of SSOJet&lt;/a&gt; to ship your first FastAPI enterprise SSO login today. The &lt;a href="https://ssojet.com/docs/" rel="noopener noreferrer"&gt;SSOJet documentation&lt;/a&gt; has step-by-step connection setup guides for Okta, Azure AD, Google Workspace, and all other supported identity providers.&lt;/p&gt;

</description>
      <category>samlssofastapipython</category>
      <category>fastapienterprisesso</category>
      <category>fastapioidc</category>
      <category>ssojetfastapi</category>
    </item>
    <item>
      <title>Adding Enterprise SAML SSO to Python Django Apps: The Complete Guide for 2026</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Wed, 06 May 2026 12:55:10 +0000</pubDate>
      <link>https://forem.com/ssojet/adding-enterprise-saml-sso-to-python-django-apps-the-complete-guide-for-2026-2o52</link>
      <guid>https://forem.com/ssojet/adding-enterprise-saml-sso-to-python-django-apps-the-complete-guide-for-2026-2o52</guid>
      <description>&lt;p&gt;According to the Verizon 2025 Data Breach Investigations Report, over 80% of hacking-related breaches involved compromised credentials, making strong federated authentication the single most impactful security control you can add to a web application. If you're building a Django app that needs to sell into enterprise accounts, SAML SSO isn't optional anymore. Your procurement contact at that Fortune 500 prospect will ask for it on the first security questionnaire, and "we're working on it" is not a winning answer.&lt;/p&gt;

&lt;p&gt;Adding SAML SSO to Django the right way means wiring up a custom authentication backend, validating XML signatures correctly, building an ACS (Assertion Consumer Service) endpoint, generating SP metadata, and handling multi-tenant IdP routing. You can do all of this with the &lt;code&gt;python3-saml&lt;/code&gt; library, or you can offload most of the heavy lifting to &lt;a href="https://ssojet.com" rel="noopener noreferrer"&gt;SSOJet's managed SSO infrastructure&lt;/a&gt;. This guide covers both approaches with real, production-grade Django code.&lt;/p&gt;

&lt;p&gt;I've worked with more than 100 SaaS engineering teams through their first enterprise SSO implementations, and the same failure modes come up every time: skipped signature validation, hardcoded IdP configs that don't scale to multi-tenant, and session handling that leaks identity attributes. This guide is built specifically to help you avoid all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SAML SSO Django enterprise 2026:&lt;/strong&gt; A SAML (Security Assertion Markup Language) integration in Django lets an enterprise identity provider (IdP) like Okta or Azure AD authenticate your users and pass a signed XML assertion to your app's ACS URL. Django then validates that assertion, creates or updates a local user record via JIT provisioning, and starts a session, all without your app ever handling a password.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;SAML SSO requires four moving parts in Django: an SP metadata endpoint, an SSO initiation view, an ACS view for assertion validation, and a custom authentication backend.&lt;/li&gt;
&lt;li&gt;Skipping XML signature validation on the SAML assertion is the most dangerous mistake. According to OWASP's Authentication Cheat Sheet (2024), unsigned or improperly validated assertions are a leading cause of SAML-based authentication bypass vulnerabilities.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;python3-saml&lt;/code&gt; library gives you full control but requires manual handling of XML canonicalization, certificate rotation, and multi-tenant IdP routing. SSOJet abstracts all three.&lt;/li&gt;
&lt;li&gt;Multi-tenant apps must look up the correct IdP configuration by organization or domain before initiating the SSO flow. Hardcoding a single IdP config will break the moment you add a second enterprise customer.&lt;/li&gt;
&lt;li&gt;JIT (just-in-time) provisioning inside your Django authentication backend lets you create or update user records on first login without pre-populating your database from an HR system.&lt;/li&gt;
&lt;li&gt;You can pair SAML SSO with automated user lifecycle management through &lt;a href="https://ssojet.com/blog/scim-identity-management-guide/" rel="noopener noreferrer"&gt;SCIM provisioning&lt;/a&gt;, which handles account creation and deactivation outside of the login flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Is SAML SSO and Why Does Django Need a Custom Integration?
&lt;/h2&gt;

&lt;p&gt;SAML SSO is not a Django-native feature. Django's built-in authentication system handles username/password login against a local database. Enterprise SSO requires an entirely different flow: the user's browser redirects to a corporate IdP, the IdP authenticates the user against Active Directory or a similar directory, and the IdP posts a signed XML document (the SAML assertion) back to your app's ACS URL.&lt;/p&gt;

&lt;p&gt;Django doesn't have a built-in way to receive and validate that XML document. You need to build or install:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A view that generates the SAML AuthnRequest and redirects the user to the correct IdP.&lt;/li&gt;
&lt;li&gt;A view at your ACS URL that receives the HTTP POST from the IdP and validates the assertion.&lt;/li&gt;
&lt;li&gt;A custom authentication backend that interprets the validated assertion, matches or creates a local Django User object, and returns it to Django's &lt;code&gt;authenticate()&lt;/code&gt; call.&lt;/li&gt;
&lt;li&gt;An SP metadata endpoint that returns your app's SAML metadata as XML, so enterprise IT admins can register your app in Okta, Azure AD, or Google Workspace.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;According to the Django documentation (Django 5.x, 2025), authentication backends must implement &lt;code&gt;authenticate()&lt;/code&gt; and &lt;code&gt;get_user()&lt;/code&gt; methods. The &lt;code&gt;authenticate()&lt;/code&gt; method receives &lt;code&gt;**kwargs&lt;/code&gt; and returns a User object or None. In a SAML integration, the "credentials" passed to &lt;code&gt;authenticate()&lt;/code&gt; are the parsed and validated assertion attributes extracted from the XML, not a username and password.&lt;/p&gt;

&lt;p&gt;This is a fundamentally different mental model from the default Django auth backend, and it's the first place where developers get confused. You're not checking a password. You're trusting a signed certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does the SAML Flow Work in a Django App?
&lt;/h2&gt;

&lt;p&gt;The complete SAML SSO flow in Django involves six steps. Understanding each step makes debugging much easier when something goes wrong, and something will go wrong the first time you configure a new IdP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: SP Metadata.&lt;/strong&gt; Your Django app serves an XML document at a known URL (for example, &lt;code&gt;/sso/metadata/&lt;/code&gt;) that tells the IdP your entity ID, your ACS URL, and your public key. Enterprise IT admins paste this URL into their IdP configuration or download and upload the XML file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: SSO Initiation.&lt;/strong&gt; When a user clicks "Sign in with SSO," your Django view constructs a SAML AuthnRequest, encodes it, and redirects the user's browser to the IdP's SSO endpoint. You store the relay state (a random, unguessable string tied to the user's session) to prevent CSRF attacks on the callback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3: IdP Authentication.&lt;/strong&gt; The IdP authenticates the user (password, MFA, whatever the enterprise has configured), then redirects the browser back to your ACS URL with a POST body containing the SAML response.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4: ACS Validation.&lt;/strong&gt; Your ACS view receives the POST, decodes the base64-encoded SAMLResponse parameter, and validates the XML. This includes checking the XML signature, the assertion's validity window (NotBefore and NotOnOrAfter), the audience restriction (is this assertion meant for your app?), and the destination URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5: Authentication Backend.&lt;/strong&gt; You pass the validated assertion attributes (typically NameID, email, first name, last name, and group memberships) to Django's &lt;code&gt;authenticate()&lt;/code&gt; function, which your custom backend handles to look up or create a Django User.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6: Session Creation.&lt;/strong&gt; You call &lt;code&gt;django.contrib.auth.login(request, user)&lt;/code&gt; to start the Django session, then redirect the user to their intended destination or a default landing page.&lt;/p&gt;

&lt;p&gt;According to the SAML 2.0 specification (OASIS, 2005, still the authoritative standard as of 2026), the assertion recipient must validate the XML digital signature using the IdP's X.509 certificate before trusting any attribute in the assertion. This is non-negotiable. Skipping it turns your SSO endpoint into an unauthenticated account takeover vector.&lt;/p&gt;

&lt;h2&gt;
  
  
  python3-saml vs SSOJet SDK: Which Approach Should You Use?
&lt;/h2&gt;

&lt;p&gt;This is the practical decision most Django teams face. Both approaches work. The right choice depends on your team's capacity for ongoing identity infrastructure maintenance.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;python3-saml&lt;/th&gt;
&lt;th&gt;SSOJet SDK&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Installation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pip install python3-saml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pip install ssojet&lt;/code&gt; + API key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IdP configuration storage&lt;/td&gt;
&lt;td&gt;You manage (DB, settings, files)&lt;/td&gt;
&lt;td&gt;Managed in SSOJet dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XML signature validation&lt;/td&gt;
&lt;td&gt;Handled (requires xmlsec1 system lib)&lt;/td&gt;
&lt;td&gt;Handled server-side by SSOJet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;XML canonicalization&lt;/td&gt;
&lt;td&gt;Handled by library&lt;/td&gt;
&lt;td&gt;Handled server-side by SSOJet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate rotation&lt;/td&gt;
&lt;td&gt;Manual, you build the logic&lt;/td&gt;
&lt;td&gt;Automatic via SSOJet dashboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-tenant IdP routing&lt;/td&gt;
&lt;td&gt;You build the lookup logic&lt;/td&gt;
&lt;td&gt;Built-in (org ID or domain)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SP metadata generation&lt;/td&gt;
&lt;td&gt;Handled&lt;/td&gt;
&lt;td&gt;Handled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OIDC support&lt;/td&gt;
&lt;td&gt;No (separate library needed)&lt;/td&gt;
&lt;td&gt;Yes, same API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JIT provisioning helpers&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SCIM provisioning&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Available as add-on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debugging tools&lt;/td&gt;
&lt;td&gt;Stack traces, you parse XML&lt;/td&gt;
&lt;td&gt;SSOJet dashboard event logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintenance burden&lt;/td&gt;
&lt;td&gt;Medium to high&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Full control, single-tenant&lt;/td&gt;
&lt;td&gt;Multi-tenant, fast time to market&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest tradeoff: &lt;code&gt;python3-saml&lt;/code&gt; requires &lt;code&gt;libxml2&lt;/code&gt; and &lt;code&gt;libxmlsec1&lt;/code&gt; as system-level dependencies, which can cause headaches in containerized environments and CI pipelines. If you're deploying on AWS Lambda or a stripped-down Docker image, you'll spend real time getting those native libraries installed correctly. SSOJet eliminates that dependency but adds a network call to the SSOJet API in your authentication path.&lt;/p&gt;

&lt;p&gt;For most B2B SaaS teams, SSOJet is the faster path. For teams building on-prem or air-gapped deployments, or teams with a specific requirement to handle all identity processing internally, &lt;code&gt;python3-saml&lt;/code&gt; gives you what you need. You can read a broader comparison of &lt;a href="https://ssojet.com/blog/b2b-authentication-provider-comparison-features-pricing-sso-support" rel="noopener noreferrer"&gt;B2B authentication providers including their tradeoffs&lt;/a&gt; if you're still evaluating.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Set Up the Django Project for SAML SSO?
&lt;/h2&gt;

&lt;p&gt;Start with your Django settings. You need to define your SP entity ID, ACS URL, metadata URL, and the path where you store IdP configurations. Here's a real &lt;code&gt;settings.py&lt;/code&gt; block for an app that supports multiple enterprise tenants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# settings.py
&lt;/span&gt;
&lt;span class="n"&gt;SAML_CONFIG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Always True in production
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;debug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entityId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.yourcompany.com/sso/metadata/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assertionConsumerService&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.yourcompany.com/sso/acs/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;binding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;singleLogoutService&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.yourcompany.com/sso/sls/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;binding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NameIDFormat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x509cert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Your SP certificate (optional for encryption)
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;privateKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Your SP private key (optional for encryption)
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;SSOJET_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;env&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SSOJET_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# Store in environment, not in settings.py
&lt;/span&gt;&lt;span class="n"&gt;SSOJET_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ssojet.com/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install your dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# For python3-saml approach&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;python3-saml

&lt;span class="c"&gt;# For SSOJet approach&lt;/span&gt;
pip &lt;span class="nb"&gt;install &lt;/span&gt;ssojet requests

&lt;span class="c"&gt;# Add to INSTALLED_APPS and configure URLs&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add to your &lt;code&gt;urls.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# urls.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.urls&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sso_views&lt;/span&gt;

&lt;span class="n"&gt;urlpatterns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso/metadata/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sso_views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_metadata&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso/initiate/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sso_views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;initiate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_initiate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso/acs/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sso_views&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;acs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_acs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to the Python Software Foundation's 2025 Python Developer Survey, Django remains the most widely used Python web framework for production web applications, used by 42% of Python developers building web projects. It's a safe foundation for enterprise SSO work.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Build the SP Metadata Endpoint?
&lt;/h2&gt;

&lt;p&gt;The SP metadata view is the simplest piece. It generates XML that describes your application to the IdP. Enterprise IT admins need this to register your app. Here's how it looks with &lt;code&gt;python3-saml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# sso_views.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HttpResponse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;onelogin.saml2.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OneLogin_Saml2_Auth&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;onelogin.saml2.settings&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OneLogin_Saml2_Settings&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;require_GET&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_saml_settings_for_org&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Look up org-specific IdP config from your database.
    Returns a python3-saml settings dict.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;myapp.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SSOConfiguration&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SSOConfiguration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;SSOConfiguration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DoesNotExist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="n"&gt;settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;strict&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;debug&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entityId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.yourcompany.com/sso/metadata/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assertionConsumerService&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.yourcompany.com/sso/acs/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;binding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NameIDFormat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x509cert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;privateKey&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entityId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idp_entity_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;singleSignOnService&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idp_sso_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;binding&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x509cert&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;idp_certificate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;

&lt;span class="nd"&gt;@require_GET&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;settings_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_saml_settings_for_org&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;settings_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Organization not found.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;saml_settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneLogin_Saml2_Settings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings_data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sp_validation_only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;metadata_xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;saml_settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_sp_metadata&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Metadata error: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metadata_xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/xml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;org_id&lt;/code&gt; in the URL path is your multi-tenant routing key. Each enterprise customer gets their own metadata URL and ACS URL, which map to their specific IdP configuration in your database. This is the pattern you need from day one, even if you only have one enterprise customer right now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the real world:&lt;/strong&gt; One SaaS team I worked with launched with a hardcoded single IdP config in &lt;code&gt;settings.py&lt;/code&gt;. When they signed their second enterprise customer, it took them two weeks to refactor. Build the multi-tenant lookup from the start. The SSOConfiguration model above takes about 30 minutes to set up and saves you days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Build the SSO Initiation View?
&lt;/h2&gt;

&lt;p&gt;The initiation view generates the SAML AuthnRequest and redirects the user to the IdP. You also need to identify which IdP to use. For multi-tenant apps, you'll want a domain-to-org mapping so users who enter &lt;code&gt;alice@bigcorp.com&lt;/code&gt; get routed to BigCorp's Okta instance without knowing (or caring) about your internal org ID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# sso_views.py (continued)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.shortcuts&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redirect&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.csrf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csrf_exempt&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_org_id_for_email_domain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Look up org ID by email domain for auto-routing.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;myapp.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OrganizationDomain&lt;/span&gt;
    &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;@&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OrganizationDomain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select_related&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;OrganizationDomain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DoesNotExist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;prepare_django_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Convert Django request to the format python3-saml expects.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;on&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_secure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;off&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http_host&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;META&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HTTP_HOST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;script_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;META&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PATH_INFO&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;get_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initiate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;settings_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_saml_settings_for_org&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;settings_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Organization not found.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prepare_django_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneLogin_Saml2_Auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_settings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# relay_state is an opaque value you can use to restore state post-login
&lt;/span&gt;    &lt;span class="n"&gt;relay_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Store relay state in session to validate on callback
&lt;/span&gt;    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;saml_relay_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;relay_state&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;saml_org_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;

    &lt;span class="n"&gt;sso_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;return_to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;relay_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sso_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relay state is critical for security. According to OWASP's SAML Security Cheat Sheet (2024), failing to validate relay state on the ACS callback enables open redirect and session fixation attacks. Store the relay state in the server-side session before redirecting, and validate it when the assertion comes back.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Build the ACS View with Proper Assertion Validation?
&lt;/h2&gt;

&lt;p&gt;The ACS view is where most implementations break. Here's what proper validation looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# sso_views.py (continued)
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.csrf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csrf_exempt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HttpResponseBadRequest&lt;/span&gt;

&lt;span class="nd"&gt;@csrf_exempt&lt;/span&gt; &lt;span class="c1"&gt;# SAML POST comes from IdP, not your own forms
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;acs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponseBadRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ACS endpoint only accepts POST requests.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;settings_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;build_saml_settings_for_org&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;settings_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Organization not found.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;prepare_django_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneLogin_Saml2_Auth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_settings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;settings_data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# This call validates:
&lt;/span&gt;    &lt;span class="c1"&gt;# 1. XML digital signature using IdP's x509cert
&lt;/span&gt;    &lt;span class="c1"&gt;# 2. NotBefore / NotOnOrAfter validity window
&lt;/span&gt;    &lt;span class="c1"&gt;# 3. Audience restriction (your SP entityId)
&lt;/span&gt;    &lt;span class="c1"&gt;# 4. Destination URL matches your ACS URL
&lt;/span&gt;    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;process_response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;errors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_errors&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;error_reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_last_error_reason&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="c1"&gt;# Log this. Do not show raw error to the user.
&lt;/span&gt;        &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SAML ACS error for org &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; - &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error_reason&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authentication failed. Please contact your IT administrator.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_authenticated&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authentication failed.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Extract assertion attributes
&lt;/span&gt;    &lt;span class="n"&gt;attributes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_attributes&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;name_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_nameid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="c1"&gt;# Pass to your custom auth backend
&lt;/span&gt;    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;saml_name_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;name_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;saml_attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User not authorized.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Validate relay state matches what we stored before redirect
&lt;/span&gt;    &lt;span class="n"&gt;relay_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RelayState&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;stored_relay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;saml_relay_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Only redirect to same-origin paths
&lt;/span&gt;    &lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlparse&lt;/span&gt;
    &lt;span class="n"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlparse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relay_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;netloc&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;netloc&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_host&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;relay_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relay_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What breaks without signature validation:&lt;/strong&gt; If you call &lt;code&gt;auth.process_response()&lt;/code&gt; but do not check &lt;code&gt;auth.get_errors()&lt;/code&gt; or trust the response even when &lt;code&gt;is_authenticated()&lt;/code&gt; is False, an attacker can craft a forged SAML response with arbitrary attributes and POST it directly to your ACS URL. No IdP required. This is CVE-2017-11427 class of vulnerability, which affected multiple SAML libraries and resulted in authentication bypass at scale. Do not skip the errors check.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Build the Custom Django Authentication Backend?
&lt;/h2&gt;

&lt;p&gt;The authentication backend is where you translate the SAML assertion into a Django User object. This is also where JIT provisioning lives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# backends.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth.backends&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseBackend&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;get_user_model&lt;/span&gt;

&lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_user_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SAMLAuthBackend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseBackend&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Django authentication backend for SAML SSO.
    Receives validated assertion attributes from the ACS view.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;saml_name_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;saml_attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;saml_name_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;saml_attributes&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="c1"&gt;# Not our backend's responsibility
&lt;/span&gt;
        &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;saml_name_id&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Fall back to email attribute if NameID isn't an email
&lt;/span&gt;            &lt;span class="n"&gt;email_attr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;saml_attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;saml_attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;email_attr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;email_attr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_attr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;email_attr&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

        &lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_get_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saml_attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;firstName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;given_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_get_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saml_attributes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lastName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;family_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="c1"&gt;# JIT provisioning: create or update user on first login
&lt;/span&gt;        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_or_create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;defaults&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;username&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# Update attributes from IdP on every login (keeps user data fresh)
&lt;/span&gt;            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_name&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_name&lt;/span&gt;
            &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;update_fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="c1"&gt;# Attach org_id to user session for multi-tenant access control
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;organization_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;
                &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DoesNotExist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="nd"&gt;@staticmethod&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_get_attr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register the backend in &lt;code&gt;settings.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;AUTHENTICATION_BACKENDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;myapp.backends.SAMLAuthBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;django.contrib.auth.backends.ModelBackend&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Keep for admin login
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ModelBackend&lt;/code&gt; fallback means your Django admin still works with username/password. The &lt;code&gt;SAMLAuthBackend&lt;/code&gt; only activates when &lt;code&gt;saml_name_id&lt;/code&gt; and &lt;code&gt;saml_attributes&lt;/code&gt; are passed to &lt;code&gt;authenticate()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the real world:&lt;/strong&gt; Azure AD sends attribute names as full URIs like &lt;code&gt;http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress&lt;/code&gt;. Okta usually sends short names like &lt;code&gt;email&lt;/code&gt;. Google Workspace uses its own attribute names. The &lt;code&gt;_get_attr&lt;/code&gt; helper above tries multiple key names, which is the practical reality of supporting multiple enterprise IdPs. Plan for this from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Integrate SSOJet Instead of Managing python3-saml Yourself?
&lt;/h2&gt;

&lt;p&gt;If you're using SSOJet, the approach is architecturally similar but simpler operationally. SSOJet handles the XML processing, signature validation, certificate management, and multi-tenant IdP routing on their side. Your Django app makes API calls and handles the session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# sso_views_ssojet.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.conf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.shortcuts&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;redirect&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.csrf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csrf_exempt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.contrib.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;login&lt;/span&gt;

&lt;span class="n"&gt;SSOJET_API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SSOJET_API_KEY&lt;/span&gt;
&lt;span class="n"&gt;SSOJET_BASE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.ssojet.com/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initiate_ssojet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Initiate SSO via SSOJet. Works for both SAML and OIDC IdPs.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;org_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;org_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;next_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;next&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Organization ID required.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SSOJET_BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sso/initiate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SSOJET_API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirectUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.yourcompany.com/sso/callback/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;next_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SSO initiation failed.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ssojet_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;authorizationUrl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="nd"&gt;@csrf_exempt&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;callback_ssojet&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Handle the callback from SSOJet after IdP authentication.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Missing authorization code.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Exchange code for validated identity attributes
&lt;/span&gt;    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SSOJET_BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sso/token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SSOJET_API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirectUri&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.yourcompany.com/sso/callback/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Token exchange failed.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="c1"&gt;# identity contains: email, firstName, lastName, organizationId,
&lt;/span&gt;    &lt;span class="c1"&gt;# groups, and any custom SAML attributes mapped in SSOJet dashboard
&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;saml_name_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;saml_attributes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;org_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;organizationId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User not authorized.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;login&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/dashboard/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSOJet supports both SAML 2.0 and OIDC through the same API, so you can handle Okta (typically SAML), Azure AD (either), and Google Workspace (OIDC) without writing protocol-specific code. You can also configure &lt;a href="https://ssojet.com/blog/scim-vs-saml-understanding-the-difference-between-provisioning-and-authentication" rel="noopener noreferrer"&gt;SCIM provisioning&lt;/a&gt; alongside SSO so that users are deprovisioned automatically when they leave an enterprise customer's organization.&lt;/p&gt;

&lt;p&gt;For teams evaluating whether to build or buy this infrastructure, the &lt;a href="https://ssojet.com/blog/best-sso-scim-providers-for-b2b-saas-selling-to-enterprise-2026-ranked-guide" rel="noopener noreferrer"&gt;best SSO and SCIM providers comparison for 2026&lt;/a&gt; covers the full landscape. SSOJet's &lt;a href="https://ssojet.com/pricing/" rel="noopener noreferrer"&gt;pricing page&lt;/a&gt; starts with a flat-rate model that doesn't charge per MAU, which matters when your enterprise customer has thousands of employees logging in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Are the Most Common Security Mistakes in Django SAML Implementations?
&lt;/h2&gt;

&lt;p&gt;OWASP's 2024 Authentication Cheat Sheet identifies several SAML-specific vulnerabilities that appear regularly in real implementations. Here are the ones I've seen most often when reviewing Django SSO code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skipping Signature Validation
&lt;/h3&gt;

&lt;p&gt;This is the single most dangerous mistake. If you parse the XML manually (with &lt;code&gt;lxml&lt;/code&gt;, &lt;code&gt;xml.etree&lt;/code&gt;, or even &lt;code&gt;xmltodict&lt;/code&gt;) and extract attributes without running the signature check, you're trusting user-controlled input. Always go through a library that validates the XML digital signature using the IdP's public certificate before you extract any attribute value.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trusting the NameID Without Normalizing Case
&lt;/h3&gt;

&lt;p&gt;Email addresses in SAML assertions are not always consistently cased. &lt;code&gt;alice@BigCorp.com&lt;/code&gt; and &lt;code&gt;alice@bigcorp.com&lt;/code&gt; should map to the same user. If your &lt;code&gt;get_or_create&lt;/code&gt; call doesn't normalize to lowercase, you'll create duplicate users every time case changes between logins. The backend code above handles this with &lt;code&gt;email.lower()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Not Validating the Audience Restriction
&lt;/h3&gt;

&lt;p&gt;The SAML assertion contains an &lt;code&gt;AudienceRestriction&lt;/code&gt; element that names the intended SP. If you don't validate this, a SAML assertion issued for App A can be replayed against App B. &lt;code&gt;python3-saml&lt;/code&gt; with &lt;code&gt;strict: True&lt;/code&gt; checks this automatically. Disabling &lt;code&gt;strict&lt;/code&gt; mode in production is a serious mistake.&lt;/p&gt;

&lt;h3&gt;
  
  
  Exposing Raw SAML Error Messages to Users
&lt;/h3&gt;

&lt;p&gt;When assertion validation fails, the raw error message from &lt;code&gt;python3-saml&lt;/code&gt; or your SAML library often includes details about your SP entity ID, certificate configuration, or assertion attribute names. Log these server-side and show a generic message to the user. The ACS view code above follows this pattern.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storing the IdP Certificate as a Hardcoded String
&lt;/h3&gt;

&lt;p&gt;Enterprise IdPs rotate their signing certificates periodically. If you hardcode the certificate in &lt;code&gt;settings.py&lt;/code&gt;, you'll get a production outage when the IdP rotates its cert. Store certificates in your database alongside the rest of the &lt;code&gt;SSOConfiguration&lt;/code&gt; model so you can update them without a deployment.&lt;/p&gt;

&lt;p&gt;According to NIST Special Publication 800-63B (2024 revision), federation assertions must be validated for signature, audience, and time window. All three checks are required by the standard, not optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Does Multi-Tenant IdP Lookup Work at Scale?
&lt;/h2&gt;

&lt;p&gt;Multi-tenancy in SSO is about routing. When user &lt;code&gt;alice@bigcorp.com&lt;/code&gt; clicks "Sign in with SSO," your app needs to know which IdP configuration to use before constructing the AuthnRequest. There are two common routing strategies:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Domain-based routing:&lt;/strong&gt; Extract the email domain from the login form, look up the matching organization, retrieve their IdP config, and initiate the SSO flow. This works well for most B2B SaaS apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Org-slug routing:&lt;/strong&gt; The user navigates to &lt;code&gt;app.yourcompany.com/org/bigcorp/login/&lt;/code&gt;, and the org slug in the URL identifies the IdP. This is simpler but requires the user to know their organization's slug.&lt;/p&gt;

&lt;p&gt;Here's the domain-based lookup model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# models.py
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.db&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UUIDField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;editable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SlugField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto_now_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SSOConfiguration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;organization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OneToOneField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_config&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;idp_entity_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;idp_sso_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;URLField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;idp_certificate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TextField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;# PEM-encoded X.509 certificate, no headers
&lt;/span&gt;    &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BooleanField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto_now_add&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auto_now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OrganizationDomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Model&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;organization&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Organization&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;related_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domains&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;domain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CharField&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unique&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;db_index&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fields&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])]&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;db_index=True&lt;/code&gt; on the &lt;code&gt;domain&lt;/code&gt; field matters at scale. If you have 500 enterprise customers each with multiple allowed domains, that lookup happens on every SSO initiation. Without an index, it becomes a table scan.&lt;/p&gt;

&lt;p&gt;For deeper reading on &lt;a href="https://ssojet.com/blog/scim-vs-sso-understanding-identity-provisioning-vs-authentication" rel="noopener noreferrer"&gt;how SSO differs from SCIM provisioning&lt;/a&gt; and when you need both, that's a common question for teams building their first enterprise identity stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Is an ACS URL in Django SAML SSO?
&lt;/h3&gt;

&lt;p&gt;The ACS (Assertion Consumer Service) URL is the endpoint in your Django app where the IdP posts the SAML response after authenticating a user. It's a standard HTTP POST endpoint that receives a base64-encoded XML document containing the signed SAML assertion. You configure this URL in your SP metadata and register it in your IdP application settings. In a multi-tenant Django app, each organization typically has its own ACS URL containing the org ID for routing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does SAML SSO Require a Custom Authentication Backend in Django?
&lt;/h3&gt;

&lt;p&gt;Yes. Django's built-in authentication system handles username and password credentials against a local database. SAML SSO passes an XML assertion, not a password. You need a custom authentication backend that implements &lt;code&gt;authenticate()&lt;/code&gt; to receive the validated assertion attributes and return a Django User object. Without a custom backend, Django has no way to convert a SAML assertion into an authenticated session.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Breaks in Django SAML If You Skip Signature Validation?
&lt;/h3&gt;

&lt;p&gt;Skipping XML signature validation on the SAML assertion means your ACS endpoint trusts any XML document posted to it, regardless of source. An attacker can craft a SAML response with arbitrary attributes (including an admin user's email) and POST it directly to your ACS URL, bypassing the IdP entirely. This class of vulnerability has appeared in multiple real-world CVEs. Always validate the signature using the IdP's registered X.509 certificate before reading any assertion attribute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can You Use SSOJet With an Existing Django User Model?
&lt;/h3&gt;

&lt;p&gt;Yes. SSOJet returns validated identity attributes (email, name, groups, and custom attributes) through its callback API. Your Django custom authentication backend receives those attributes and calls &lt;code&gt;get_or_create()&lt;/code&gt; against your existing User model. SSOJet doesn't replace or modify your user model. It acts as the identity verification layer, and your backend handles the Django-side user lifecycle including JIT provisioning.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Do You Handle Multi-Tenant SAML SSO in Django?
&lt;/h3&gt;

&lt;p&gt;Build a &lt;code&gt;SSOConfiguration&lt;/code&gt; model in your database with one row per enterprise customer. Each row stores the IdP entity ID, SSO URL, and X.509 certificate. Add an &lt;code&gt;OrganizationDomain&lt;/code&gt; model to map email domains to organizations. When a user initiates SSO, look up the organization by email domain, retrieve the matching IdP config, and use it to construct the SAML AuthnRequest. Each organization gets its own ACS URL containing the org ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Adding SAML SSO Affect Your Django Admin Login?
&lt;/h3&gt;

&lt;p&gt;Only if you configure it to. The custom &lt;code&gt;SAMLAuthBackend&lt;/code&gt; only activates when &lt;code&gt;saml_name_id&lt;/code&gt; and &lt;code&gt;saml_attributes&lt;/code&gt; are passed to &lt;code&gt;authenticate()&lt;/code&gt;. For all other authentication calls (including the Django admin login form), Django falls through to &lt;code&gt;ModelBackend&lt;/code&gt;, which handles username and password normally. Keep both backends in &lt;code&gt;AUTHENTICATION_BACKENDS&lt;/code&gt; and your admin access continues to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Adding SAML SSO to Django is a well-defined engineering task once you understand the flow. Build the SP metadata endpoint and multi-tenant IdP lookup from day one, validate every assertion rigorously, and use a tested library rather than parsing XML manually. Whether you go with &lt;code&gt;python3-saml&lt;/code&gt; for full control or &lt;a href="https://ssojet.com" rel="noopener noreferrer"&gt;SSOJet&lt;/a&gt; for faster multi-IdP coverage, the authentication backend pattern is the same. The difference is how much identity infrastructure you want to maintain yourself.&lt;/p&gt;

</description>
      <category>samlssodjango</category>
      <category>djangoenterprisesso</category>
      <category>python3samldjango</category>
      <category>ssojetdjangointegrat</category>
    </item>
    <item>
      <title>How to Add SCIM Provisioning to Your SaaS Without Building It From Scratch</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Wed, 06 May 2026 12:50:36 +0000</pubDate>
      <link>https://forem.com/ssojet/how-to-add-scim-provisioning-to-your-saas-without-building-it-from-scratch-28kk</link>
      <guid>https://forem.com/ssojet/how-to-add-scim-provisioning-to-your-saas-without-building-it-from-scratch-28kk</guid>
      <description>&lt;p&gt;According to the IBM Cost of a Data Breach Report 2024, the average cost of a breach traceable to compromised internal accounts is now $4.99 million, with orphaned post-offboarding accounts among the top contributing factors. SCIM 2.0 (System for Cross-domain Identity Management) solves the orphaned-account problem by automating user lifecycle from the customer's IdP into your SaaS app. Yet most B2B SaaS teams underestimate the build cost: the SCIM 2.0 specification (RFC 7643/7644) defines 16+ endpoints, three PATCH operation types, filter expression parsing, pagination metadata, and per-IdP quirk handling.&lt;/p&gt;

&lt;p&gt;After helping 100+ B2B SaaS engineering teams ship enterprise auth, the same realization hits at the second customer: building SCIM from scratch is a 2 to 5-day project before per-IdP testing, then ongoing maintenance forever. This guide shows the broker-pattern path that collapses SCIM to a single webhook receiver in your app, with working code for receiving SSOJet provisioning events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add SCIM provisioning SaaS:&lt;/strong&gt; A B2B SaaS that integrates SCIM 2.0 user provisioning by delegating the SCIM protocol surface to a managed broker (SSOJet) and consuming the resulting normalized webhook events in the SaaS application code. The pattern collapses 16+ SCIM endpoints into a single webhook receiver, reducing implementation time from 2-5 days to 4-6 hours and eliminating per-IdP quirk maintenance forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  What You'll Build by the End of This Guide
&lt;/h2&gt;

&lt;p&gt;A SaaS app that:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Receives SCIM provisioning events from any major IdP (Okta, Microsoft Entra, Google Workspace) without implementing SCIM directly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Creates user records when employees join their company.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Updates user profiles when their roles change.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Deactivates users within minutes when they leave.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Logs every provisioning event with timestamp, IdP source, and target user for SOC 2 evidence.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total engineering time: 4 to 6 hours. The same project built from scratch in any framework is 2 to 5 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Build vs Broker Matters Specifically for SCIM
&lt;/h2&gt;

&lt;p&gt;Building SCIM yourself means implementing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;16+ HTTP endpoints under &lt;code&gt;/scim/v2/&lt;/code&gt; (Users, Groups, Schemas, ResourceTypes, ServiceProviderConfig, Bulk).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Bearer-token authentication with constant-time comparison.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Filter expression parsing (&lt;code&gt;userName eq "alice@acme.com"&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;PATCH operation parser supporting add/replace/remove with path expressions.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Pagination metadata (&lt;code&gt;startIndex&lt;/code&gt;, &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;totalResults&lt;/code&gt;).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Per-IdP quirks (Okta and Azure AD send different PATCH payloads despite the same RFC).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broker pattern collapses this to one webhook handler. Your app receives normalized events, the broker handles SCIM protocol details. Detail on the protocol differences is in &lt;a&gt;the SSOJet SCIM identity management guide&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;You need an SSOJet account with at least one SAML or OIDC connection already configured (SCIM rides alongside SSO). Time budget: 4 to 6 hours for first implementation, 30 minutes for each additional language/framework if you have multiple services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Understand the SCIM 2.0 Schema (Brief Version)
&lt;/h2&gt;

&lt;p&gt;You don't need to learn the whole RFC 7643. The fields that matter for 95% of SaaS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schemas"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"urn:ietf:params:scim:schemas:core:2.0:User"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"abcd1234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"userName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice@acme.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"externalId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"00u123"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"givenName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"familyName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Anderson"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"formatted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Alice Anderson"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"emails"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alice@acme.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"work"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"groups"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"grp001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Engineering"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"resourceType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"User"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"created"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-06T12:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lastModified"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-06T12:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"location"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/scim/v2/Users/abcd1234"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the User resource. The Group resource is structurally similar with member references instead of email/name. Everything else (Schemas, ResourceTypes, ServiceProviderConfig) is metadata that the IdP queries during initial setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Configure the SSOJet SCIM Endpoint
&lt;/h2&gt;

&lt;p&gt;In your SSOJet dashboard:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Open your existing &lt;strong&gt;Tenant&lt;/strong&gt; (or create one for the customer).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Navigate to &lt;strong&gt;Configure Directory → SCIM Configuration&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Enable SCIM 2.0.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Copy the generated SCIM endpoint URL (looks like &lt;code&gt;https://api.ssojet.com/v1/scim/v2/conn_xxx&lt;/code&gt;) and the auto-generated bearer token.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Paste the URL and token into your customer's IdP SCIM configuration (Okta, Entra, etc.).&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The customer's IT admin then assigns users or groups to your app for provisioning scope. Within seconds, SSOJet starts receiving SCIM events from the IdP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Configure the SSOJet Webhook to Your App
&lt;/h2&gt;

&lt;p&gt;Now SSOJet needs to forward provisioning events to your application:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;In SSOJet dashboard, navigate to &lt;strong&gt;Webhooks → New Webhook&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set the &lt;strong&gt;Endpoint URL&lt;/strong&gt; to your app's webhook receiver: &lt;code&gt;https://api.acme.com/webhooks/ssojet&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Select event types: &lt;code&gt;user.created&lt;/code&gt;, &lt;code&gt;user.updated&lt;/code&gt;, &lt;code&gt;user.deactivated&lt;/code&gt;, &lt;code&gt;group.created&lt;/code&gt;, &lt;code&gt;group.updated&lt;/code&gt;, &lt;code&gt;group.deleted&lt;/code&gt;, &lt;code&gt;group.member_added&lt;/code&gt;, &lt;code&gt;group.member_removed&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Copy the &lt;strong&gt;Webhook Secret&lt;/strong&gt; (used for HMAC signature verification on each delivery).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Save.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;SSOJet now sends a signed POST to your endpoint every time a SCIM event arrives from the customer's IdP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Implement the Webhook Receiver
&lt;/h2&gt;

&lt;p&gt;Working code in five common backend languages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Node.js / Express
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./db.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/ssojet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-ssojet-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SSOJET_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user.created&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;externalId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;externalId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;provisionedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user.deactivated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;externalId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;externalId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;isActive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;deprovisionedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user.updated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;externalId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;externalId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;firstName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;group.member_added&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;externalUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;externalId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;groupName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;group.member_removed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userGroups&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;externalUserId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;externalId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;groupName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Python / Django
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.http&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HttpResponseUnauthorized&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.views.decorators.csrf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;csrf_exempt&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;django.conf&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UserGroup&lt;/span&gt;

&lt;span class="nd"&gt;@csrf_exempt&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ssojet_webhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-SSOJet-Signature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SSOJET_WEBHOOK_SECRET&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compare_digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;HttpResponseUnauthorized&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user.created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;externalId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;first_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;firstName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;last_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lastName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user.deactivated&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;externalId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;deprovisioned_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;JsonResponse&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Go
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;SsoJetWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SSOJET_WEBHOOK_SECRET"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;io&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Header&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"X-SSOJet-Signature"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;mac&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sha256&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;hex&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EncodeToString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;hmac&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Equal&lt;/span&gt;&lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;byte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"invalid signature"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`json:"event"`&lt;/span&gt;
            &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="k"&gt;interface&lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt; &lt;span class="s"&gt;`json:"user"`&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Event&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"user.created"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;extID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"externalId"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`INSERT INTO users (email, external_id, is_active) VALUES ($1, $2, true)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s"&gt;"user.deactivated"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;extID&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;ev&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"externalId"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;`UPDATE users SET is_active=false WHERE external_id=$1`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WriteHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Ruby on Rails
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SsoWebhooksController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActionController&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;API&lt;/span&gt;
  &lt;span class="n"&gt;skip_before_action&lt;/span&gt; &lt;span class="ss"&gt;:verify_authenticity_token&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;receive&lt;/span&gt;
    &lt;span class="n"&gt;secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SSOJET_WEBHOOK_SECRET"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;given&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"X-SSOJet-Signature"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenSSL&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;HMAC&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"SHA256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw_post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:unauthorized&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SecurityUtils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;secure_compare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw_post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"user.created"&lt;/span&gt;
      &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="ss"&gt;email: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;external_id: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"externalId"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;first_name: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"firstName"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;last_name: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"lastName"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="ss"&gt;is_active: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s2"&gt;"user.deactivated"&lt;/span&gt;
      &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;external_id: &lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"externalId"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
          &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;is_active: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;deprovisioned_at: &lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Laravel
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;receive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;Request&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$secret&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"services.ssojet.webhook_secret"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$given&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nb"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"X-SSOJet-Signature"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;hash_hmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sha256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;getContent&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nv"&gt;$secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;hash_equals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$given&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$expected&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nv"&gt;$event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$request&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"event"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"user.created"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
                &lt;span class="s2"&gt;"email"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user.email"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s2"&gt;"external_id"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user.externalId"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s2"&gt;"first_name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user.firstName"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s2"&gt;"last_name"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user.lastName"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="s2"&gt;"is_active"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;]);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"user.deactivated"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"external_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;$event&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"user.externalId"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"is_active"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"deprovisioned_at"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()]);&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;response&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"ok"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In all five examples, the receiver is roughly 30 lines and handles the four most common SCIM lifecycle events. Adding more event types (group changes, attribute updates) follows the same pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Test With Okta's Free Developer Provisioning
&lt;/h2&gt;

&lt;p&gt;The cleanest test path uses Okta's free Developer Edition (no payment required):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Open Okta admin console at &lt;code&gt;your-tenant.okta.com/admin&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Applications → Browse App Catalog → Search "SCIM 2.0 Test App"&lt;/strong&gt; and add it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Open the app → &lt;strong&gt;Provisioning → Configure API Integration&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Set &lt;strong&gt;Base URL&lt;/strong&gt; to your SSOJet SCIM endpoint URL.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bearer token&lt;/strong&gt; : paste your SSOJet-generated bearer token.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Click &lt;strong&gt;Test API Credentials&lt;/strong&gt;. Should return success.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Assign a test user to the app.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Within 30 seconds, your webhook receives a &lt;code&gt;user.created&lt;/code&gt; event.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A successful Okta SCIM test log looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Provisioning Event - User Create
Time: 2026-05-06T12:34:56Z
User: alice@acme.com
Status: Successful
Application: Acme SaaS (SCIM 2.0)
Operation: POST /scim/v2/Users
Response: 201 Created

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you see this in Okta's audit log and the corresponding event in your webhook receiver's logs within 60 seconds, the integration is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: Build vs Broker Time and Cost
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Approach&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Implementation Time&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Per-IdP Quirks&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Maintenance Burden&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Broker pattern (this guide)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;4-6 hours&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Handled by broker&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;One webhook receiver&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Build from scratch (Django/Rails)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;2-5 days&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;You handle&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;16+ endpoints maintained&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Build from scratch (Spring/Go)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;3-7 days&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;You handle&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;16+ endpoints maintained&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Use a SCIM library (django-scim2, scimitar)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;1-3 days&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Library + you&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Library overrides + your code&lt;/p&gt;

&lt;p&gt;|&lt;/p&gt;

&lt;p&gt;The broker pattern is the lowest-LOC and lowest-maintenance path for B2B SaaS that don't need full SCIM protocol custody. Setting up SCIM with SSOJet took COX about 45 minutes for the broker config plus an afternoon for the webhook receiver.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real-World Webhook Patterns
&lt;/h2&gt;

&lt;p&gt;Three things to plan for in the webhook receiver.&lt;/p&gt;

&lt;p&gt;First, idempotency. SSOJet retries failed deliveries with exponential backoff. Your handler may receive the same event twice if your first response was slow. Use the event's &lt;code&gt;id&lt;/code&gt; field as an idempotency key in your database to deduplicate.&lt;/p&gt;

&lt;p&gt;Second, queueing for high-volume customers. A 5,000-user initial sync sends 5,000 events back-to-back. If your handler does 100ms of database work per event, that's 8+ minutes of synchronous processing. Queue the events into Sidekiq, Celery, or a similar background queue and respond 200 immediately.&lt;/p&gt;

&lt;p&gt;Third, structured logging. Every event should be logged with timestamp, event type, IdP source, target user external_id, and outcome. This is the SOC 2 CC6.2 evidence the auditor will sample. SSOJet's audit log captures this on the broker side too, providing redundant evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance Mapping
&lt;/h2&gt;

&lt;p&gt;The webhook receiver pattern satisfies SOC 2 CC6.2 (access provisioning) and CC6.3 (access removal upon termination) when paired with structured logging and sub-1-hour processing SLA. Per Gartner's 2025 Magic Quadrant for Access Management, automated provisioning and deprovisioning is now a top-three buyer criterion for IGA tooling.&lt;/p&gt;

&lt;p&gt;According to Microsoft's 2024 Digital Defense Report, 50% of identity-related security incidents involve accounts that should have been deprovisioned but weren't. The webhook receiver pattern, with sub-15-minute typical SLA from IdP-side disable to your-side deactivation, materially reduces this risk.&lt;/p&gt;

&lt;p&gt;It depends on your customer base. A SaaS with 10 enterprise customers may queue events synchronously without issue. A SaaS with 200 enterprise customers needs proper queueing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How does this differ from implementing SCIM endpoints directly?
&lt;/h3&gt;

&lt;p&gt;You don't implement SCIM endpoints at all. SSOJet exposes the SCIM endpoints to the IdP, validates the protocol, and forwards normalized events to your webhook. Your code handles only the resulting business logic (create/update/deactivate user). You skip the 16+ endpoint implementation, the PATCH parser, the filter expression parser, and per-IdP quirk handling.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if my webhook receiver is down?
&lt;/h3&gt;

&lt;p&gt;SSOJet retries with exponential backoff for up to 24 hours. If your endpoint stays down longer, the events are logged in SSOJet's dashboard for manual replay. The customer's IdP sees a successful SCIM operation regardless because SSOJet acknowledges the SCIM call upstream.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I customize which events I receive?
&lt;/h3&gt;

&lt;p&gt;Yes. The webhook subscription supports per-event-type filtering. If you only care about user.created and user.deactivated and not group changes, configure those in the webhook settings.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I handle the initial 5,000-user sync from a new enterprise customer?
&lt;/h3&gt;

&lt;p&gt;The events arrive as a stream. SSOJet rate-limits to ensure your webhook isn't overloaded (configurable, typically 10 events per second by default). For very large syncs, use SSOJet's batch sync endpoint instead, which sends a single webhook with the full user array.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the webhook signature verification mandatory?
&lt;/h3&gt;

&lt;p&gt;Yes. Without HMAC signature verification, an attacker who knows your webhook URL could send fake provisioning events to create or deactivate users. The HMAC verification is one line of code in every framework and prevents this entire class of attack.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I migrate from a custom SCIM implementation to the broker pattern?
&lt;/h3&gt;

&lt;p&gt;Run them in parallel for one billing cycle. Configure your customer's IdP to send SCIM to SSOJet (the broker), then SSOJet forwards events to your existing custom SCIM endpoints (or to the new webhook receiver). Validate that events match. Once confirmed, deprecate the custom SCIM endpoints. Migration typically takes 2 to 4 weeks for a 10-customer base.&lt;/p&gt;

&lt;p&gt;If you're ready to add SCIM provisioning in an afternoon instead of building it for a week, &lt;a href="https://portal.ssojet.com" rel="noopener noreferrer"&gt;start a 30-day free trial of SSOJet.&lt;/a&gt;&lt;/p&gt;

</description>
      <category>scimprovisioningsaas</category>
      <category>scim20implementation</category>
      <category>ssojetscimendpoint</category>
      <category>oktascimprovisioning</category>
    </item>
    <item>
      <title>5 Best WorkOS Alternatives for B2B SaaS Teams That Need Enterprise SSO in 2026</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Wed, 06 May 2026 12:44:01 +0000</pubDate>
      <link>https://forem.com/ssojet/5-best-workos-alternatives-for-b2b-saas-teams-that-need-enterprise-sso-in-2026-e52</link>
      <guid>https://forem.com/ssojet/5-best-workos-alternatives-for-b2b-saas-teams-that-need-enterprise-sso-in-2026-e52</guid>
      <description>&lt;p&gt;According to G2's 2025 B2B SaaS Buyer Report, 78% of enterprise procurement teams require SSO before signing a contract. WorkOS solved this problem well for thousands of B2B SaaS teams in the early 2020s, but its per-connection pricing model creates a predictable pain point at the second renewal: a customer base that grew from 5 to 30 enterprise connections is now paying $3,875+/month for the same SSO infrastructure that started at $750/month.&lt;/p&gt;

&lt;p&gt;After helping 100+ B2B SaaS teams evaluate enterprise SSO platforms, the same five alternatives come up most often when teams hit "WorkOS pricing shock." This guide ranks them honestly, includes a comparison table up front, names one real limitation for each, and shows where SSOJet's flat-rate model fits the scaling picture. Disclosure: I work at SSOJet, so I have an obvious bias and I've tried to be candid about the others' strengths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WorkOS alternatives enterprise SSO:&lt;/strong&gt; A ranked guide to five SaaS platforms (SSOJet, Auth0, Frontegg, Keycloak, FusionAuth) that compete with WorkOS for the B2B SaaS enterprise SSO use case in 2026, with pricing, SAML/SCIM support, setup time, and one honest limitation per platform. Each alternative addresses different pain points; SSOJet is ranked first specifically for teams escaping WorkOS's per-connection pricing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison Table at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Platform&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Starting Price&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;SAML 2.0&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;SCIM 2.0&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Setup Time&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Honest Limitation&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SSOJet&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;$49/month flat&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;4-6 hours&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Newer brand, less name-recognized&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Auth0 (Okta)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;$35/month + per MAU&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes (higher tier)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;1-2 days&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Pricing escalates fast at scale&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Frontegg&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Custom (~$300+/month)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;1-2 days&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;UX-heavy abstraction may not fit dev-led teams&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Keycloak&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Free (self-hosted)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;2-4 weeks&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Operations cost is real (you run the cluster)&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;FusionAuth&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Free (self-hosted), $125+/month managed&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;1-3 days&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Self-hosted requires database management&lt;/p&gt;

&lt;p&gt;|&lt;/p&gt;

&lt;h2&gt;
  
  
  1. SSOJet
&lt;/h2&gt;

&lt;p&gt;The flat-rate enterprise SSO platform. Unlimited MAU, unlimited connections, predictable pricing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; B2B SaaS teams scaling past 5 enterprise customers who need pricing predictability.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Starting price:&lt;/strong&gt; $49/month flat.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Key differentiator:&lt;/strong&gt; Flat rate regardless of connection count or MAU.&lt;/p&gt;

&lt;p&gt;SSOJet supports SAML 2.0, OIDC, SCIM 2.0, MFA, audit logs, and a customer-facing self-serve admin portal. It's SOC 2 Type II, GDPR, ISO 27001, OpenID Certified, and HIPAA-aligned. Customer references include IBM, Dell, Accenture, Cox, HCL, and GrackerAI. Setting up SAML with SSOJet took COX about 45 minutes once their Okta admin had granted access; GrackerAI closed 3 enterprise deals in their first month after switching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limitation:&lt;/strong&gt; SSOJet is younger than Auth0 and less name-recognized in some buyer segments. If your sales team relies on brand recognition during procurement, this is a small but real tradeoff in seed-stage deals. Customer adoption has accelerated significantly through 2025 and 2026, narrowing the gap.&lt;/p&gt;

&lt;p&gt;For a deeper view, see &lt;a&gt;the SSOJet why page&lt;/a&gt; and &lt;a&gt;SSOJet vs WorkOS comparison&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Auth0 (Okta)
&lt;/h2&gt;

&lt;p&gt;The feature-rich CIAM platform that many B2B SaaS teams already know.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; B2B SaaS that also needs full CIAM (consumer identity, social login, B2C flows) alongside enterprise SSO.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Starting price:&lt;/strong&gt; $35/month with per-MAU pricing on top.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Key differentiator:&lt;/strong&gt; Broadest feature surface (B2B + B2C, social login, MFA depth, rules engine, hooks).&lt;/p&gt;

&lt;p&gt;Auth0 supports every IdP your enterprise customer might use, plus comprehensive consumer auth flows. It's the right pick for products that serve both consumer end-users and enterprise IT admins from the same auth layer. Compliance is comprehensive: SOC 2, GDPR, HIPAA, FedRAMP at higher tiers. Documentation is excellent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limitation:&lt;/strong&gt; Pricing escalates fast at scale. Per-MAU charges plus add-on fees for SCIM, MFA SMS, custom domains, and tenant isolation can make Auth0 the most expensive option once you cross 25,000 MAU. Many teams that picked Auth0 at the seed stage migrate off at Series B for cost reasons.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Frontegg
&lt;/h2&gt;

&lt;p&gt;The multi-tenant-first B2B identity platform with prebuilt admin UIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; B2B SaaS teams that want substantial pre-built UI for tenant management, role management, and customer admin self-serve.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Starting price:&lt;/strong&gt; Custom (typically $300+/month).&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Key differentiator:&lt;/strong&gt; UX-first abstraction with pre-built admin portals and tenant management widgets.&lt;/p&gt;

&lt;p&gt;Frontegg's value is the time saved building tenant administration UI. If your team would otherwise spend 4 to 8 weeks building a tenant admin dashboard, Frontegg can collapse that to a day. SAML, SCIM, and MFA are standard. The platform is SOC 2 Type II compliant and growing in enterprise references.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limitation:&lt;/strong&gt; Frontegg's UX-heavy abstraction may not fit developer-led B2B SaaS teams who prefer to own the admin UI. The opinionated admin portal looks great out of the box but is harder to deeply customize than building it yourself with SSOJet primitives. For developer-tools or DevOps SaaS, the UX abstraction can feel like overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keycloak
&lt;/h2&gt;

&lt;p&gt;The open-source, self-hosted identity broker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams with strong DevOps capacity who need full data sovereignty and zero per-MAU costs.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Starting price:&lt;/strong&gt; Free (self-hosted; you pay for the infrastructure and operations).&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Key differentiator:&lt;/strong&gt; Open source, no vendor lock-in, full data sovereignty.&lt;/p&gt;

&lt;p&gt;Keycloak is the de facto open-source identity broker, supporting SAML 2.0, OIDC, OAuth 2.0, and SCIM out of the box. Major enterprises run Keycloak in production. The community is active and the codebase is mature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limitation:&lt;/strong&gt; Operations cost is real. You run the Keycloak cluster (typically 3+ nodes for HA), manage Postgres or MariaDB for state, handle upgrades on a 6-month cycle, monitor JVM heap usage, and respond to Keycloak's CVEs. For a 5-person engineering team, Keycloak's operational overhead can exceed what a paid SaaS broker costs. Use Keycloak only if you have dedicated DevOps capacity or compliance requirements that mandate self-hosting.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. FusionAuth
&lt;/h2&gt;

&lt;p&gt;The developer-focused identity platform with a self-hosted option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Teams that want a paid SaaS or self-hosted option in the same product, with strong developer DX.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Starting price:&lt;/strong&gt; Free for self-hosted with limits, $125/month for managed cloud.&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Key differentiator:&lt;/strong&gt; Same product runs self-hosted (free) or managed cloud, decision can be revisited later.&lt;/p&gt;

&lt;p&gt;FusionAuth supports SAML 2.0, OIDC, SCIM, MFA, and a comprehensive admin UI. The documentation and developer experience are strong. Compliance includes SOC 2 Type II for the managed cloud tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest limitation:&lt;/strong&gt; Self-hosted FusionAuth requires database management (PostgreSQL or MySQL) and at least one application server. It's lighter than Keycloak operationally but heavier than a fully managed SaaS. The managed cloud tier removes this overhead but at the same price point as several alternatives, including SSOJet for higher-MAU scenarios.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Each Alternative Beats WorkOS
&lt;/h2&gt;

&lt;p&gt;The honest decision framework:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick SSOJet&lt;/strong&gt; if you're scaling past 5 enterprise customers and per-connection pricing is hurting. The flat-rate model is the structural answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Auth0&lt;/strong&gt; if you serve consumer users alongside enterprise users from the same auth layer, and you need the deep CIAM feature set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Frontegg&lt;/strong&gt; if you want pre-built tenant administration UI more than you want pricing predictability, and your team values UX abstraction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Keycloak&lt;/strong&gt; if you have DevOps capacity, need full data sovereignty, and your CISO requires self-hosted identity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick FusionAuth&lt;/strong&gt; if you want the optionality to start self-hosted (free) and migrate to managed later, and your team is comfortable running PostgreSQL.&lt;/p&gt;

&lt;p&gt;It depends on your specific business profile. A 50-seat customer SaaS may not care about per-connection pricing because they'll never have many connections. A 500-customer SaaS cares enormously and that's where SSOJet's flat rate compounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where WorkOS Still Makes Sense
&lt;/h2&gt;

&lt;p&gt;To be honest about the alternative the article is comparing against: WorkOS is still a reasonable choice if your team values its specific documentation aesthetic above all else, you're at the seed stage with no urgency to optimize cost, and you don't anticipate growing past 10 enterprise customers in the next 18 months. For everyone else, one of the five alternatives above will fit better at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compliance Posture Across the Five Alternatives
&lt;/h2&gt;

&lt;p&gt;Enterprise security questionnaires don't care which broker you picked; they care about the certifications. Here's how the five compare:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Platform&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;SOC 2 Type II&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;GDPR&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;HIPAA&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;ISO 27001&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;OpenID Certified&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SSOJet&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes (included)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Auth0&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes (higher tier)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Frontegg&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes (higher tier)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;In progress&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;No&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;Keycloak&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;N/A (you self-host)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;N/A&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;N/A&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;N/A&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes (protocol-level)&lt;/p&gt;

&lt;p&gt;|&lt;br&gt;
| &lt;/p&gt;

&lt;p&gt;FusionAuth&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes (managed)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;Yes (managed)&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;In progress&lt;/p&gt;

&lt;p&gt;| &lt;/p&gt;

&lt;p&gt;No&lt;/p&gt;

&lt;p&gt;|&lt;/p&gt;

&lt;p&gt;For self-hosted Keycloak, compliance is your responsibility and you bear the audit cost. For all the managed options, the broker carries the cert and your customer's procurement team accepts that as an inherited control.&lt;/p&gt;

&lt;p&gt;According to the Verizon 2025 Data Breach Investigations Report, 81% of hacking-related breaches involve compromised credentials. All five alternatives materially reduce that risk by federating identity to enterprise IdPs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why are people moving off WorkOS?
&lt;/h3&gt;

&lt;p&gt;Two main reasons: per-connection pricing escalates as your enterprise customer base grows (a 30-customer SaaS pays ~$3,875/month for SSO that costs $99/month on SSOJet's flat tier), and the "Audit Logs" SKU is separate from the base price at higher tiers. Teams hitting their second renewal cycle are typically the ones who start evaluating alternatives.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I migrate off WorkOS without breaking customer SSO?
&lt;/h3&gt;

&lt;p&gt;Yes, with planning. Provision the matching connections on your new platform in parallel for each customer, update your application to call the new platform's APIs, then ask each customer's IT admin to update their IdP-side ACS URL and Entity ID. Most migrations complete in 2 to 4 weeks for a 10-customer SSO base.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is the SSOJet flat rate really the same at any MAU?
&lt;/h3&gt;

&lt;p&gt;For unlimited MAU on the listed tiers, yes. There are tier breakpoints around 5,000 MAU and 100,000 MAU, but the structure is flat (not per-user). For very high-volume deployments (1M+ MAU), an enterprise tier with custom pricing applies. For 95%+ of B2B SaaS in their first 5 years, the flat rate covers them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Keycloak really save money over SaaS alternatives?
&lt;/h3&gt;

&lt;p&gt;Only if your team has the DevOps capacity to run it well. Most teams underestimate Keycloak's operational cost: 3 nodes for HA, Postgres for state, upgrades, monitoring, CVE response, and the engineer-hours those consume. For a 5-person team, Keycloak's true cost often exceeds what SSOJet or Frontegg would cost. For a 50-person engineering team with dedicated DevOps, Keycloak can be the right pick.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which alternative is best for healthcare B2B SaaS?
&lt;/h3&gt;

&lt;p&gt;SSOJet, because HIPAA support is included in all tiers rather than gated behind a higher contract. Auth0 supports HIPAA at higher tiers. Frontegg and FusionAuth support HIPAA on managed cloud higher tiers. Keycloak self-hosted is fine for HIPAA if your DevOps team handles the infrastructure compliance.&lt;/p&gt;

&lt;h3&gt;
  
  
  What about Microsoft Entra External ID as a WorkOS alternative?
&lt;/h3&gt;

&lt;p&gt;Microsoft Entra External ID (formerly Azure AD B2C and B2B) is a valid alternative for shops already deep in the Microsoft stack. It's powerful but heavily Microsoft-flavored and has a learning curve outside the Azure ecosystem. For non-Microsoft-shop B2B SaaS, the five alternatives in this guide fit better.&lt;/p&gt;

&lt;p&gt;If you're ready to escape per-connection pricing without sacrificing enterprise SSO capability, &lt;a href="https://portal.ssojet.com" rel="noopener noreferrer"&gt;start a 30-day free trial of SSOJet&lt;/a&gt; and see how the flat rate compares at your customer count.&lt;/p&gt;

</description>
      <category>workosalternativesen</category>
      <category>workoscompetitors202</category>
      <category>b2bsaasssoalternativ</category>
      <category>ssojetauth0fronteggc</category>
    </item>
    <item>
      <title>9 Enterprise Identity Trends That Will Define 2026 and Beyond</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Mon, 04 May 2026 12:00:58 +0000</pubDate>
      <link>https://forem.com/ssojet/9-enterprise-identity-trends-that-will-define-2026-and-beyond-1lf9</link>
      <guid>https://forem.com/ssojet/9-enterprise-identity-trends-that-will-define-2026-and-beyond-1lf9</guid>
      <description>&lt;p&gt;We're at an inflection point for enterprise identity. The protocols, the threat models, and the definition of "user" are all changing at once. Here is what the next two years actually look like._&lt;/p&gt;

&lt;p&gt;Enterprise identity has seen genuine disruption before: the shift from Kerberos to SAML, from SAML to OIDC, from on-premise ADFS to cloud IdPs. Each transition took five to ten years and left a long tail of legacy infrastructure. The current inflection is moving faster because it's not driven by one protocol change but by five converging forces: AI agents that need identity governance, passkeys displacing passwords at the platform level, compliance frameworks catching up to what security teams have been worried about for two years, a non-human identity population growing faster than any team can govern manually, and vendor consolidation starting to reshape buying decisions.&lt;/p&gt;

&lt;p&gt;The nine trends below represent where enterprise identity is heading. Some are already happening. Some will cross a threshold in 2026 that makes them unavoidable. All of them have direct implications for B2B SaaS companies deciding what to build and what to buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trend 1: Agentic Identity Becomes a First-Class Citizen in IAM
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; AI agents are operating in production environments today. They're reading emails, writing code, querying databases, posting to Slack, filing tickets, and making API calls across dozens of enterprise systems. Their identity governance is, in most organizations, roughly: shared service accounts, long-lived API keys, and informal ownership. Nobody's IT department can name all the agents running in their environment. Most CISOs can't either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; Enterprise procurement teams are starting to ask vendors directly: "What is your AI agent authentication story?" The question is new in 2025 RFPs. By 2026, it will be a table-stakes requirement, not a differentiator. NIST SP 800-63 is being updated to explicitly address non-human authenticators. ISO 27001:2022's emphasis on "assets" already technically covers AI agents. Explicit AI governance requirements are coming in the next revision cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Any product that lets customers' AI agents take actions on their behalf needs formal agent identity: registered OAuth clients, scoped short-lived tokens, per-task authorization, and audit logs that distinguish agent actions from human actions. Products that lack this will start losing enterprise deals to products that have it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"By Q4 2026, we expect the majority of enterprise security questionnaires to include at least three questions specific to AI agent authentication and non-human identity governance. Teams that built this into their identity layer in 2025 will close those deals faster."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 2: Identity Providers Add Native MCP Support
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; The Model Context Protocol went from an Anthropic project to a Linux Foundation standard with backing from OpenAI, Google, Microsoft, and AWS in under twelve months. Over 97 million monthly downloads as of early 2026. It is becoming the universal interface between AI agents and enterprise tools. But MCP's authentication story is incomplete. Most MCP deployments today use personal access tokens or developer-issued API keys that bypass corporate IdP governance entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; The MCP November 2025 specification revision added Cross App Access (XAA) semantics and explicit OAuth 2.1 authorization server requirements. Anthropic's reference implementation requires PKCE and dynamic client registration. Microsoft has added MCP server support to Copilot Studio. Okta and Microsoft Entra ID are both actively working on native MCP connector support. When Okta ships native MCP support in its dashboard, every enterprise IT admin gets a first-class way to govern agent connections through their existing IdP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Products that expose MCP endpoints need to be ready for the day a customer's IT admin tries to configure the MCP connection directly through their Okta or Entra portal, the same way they configure an SSO connection today. That requires your MCP authentication to flow through a corporate IdP rather than through personal tokens. For a detailed breakdown of the current state of &lt;a href="https://ssojet.com/blog/what-tech-leaders-need-to-know-about-mcp-authentication-in-2025" rel="noopener noreferrer"&gt;MCP authentication requirements&lt;/a&gt;, the implementation gap is narrower than most teams expect.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"The first major IdP to ship a native MCP connector marketplace will trigger a wave of enterprise procurement mandates: 'We only approve MCP servers that authenticate through our IdP.' That deadline is probably 2026."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 3: Cross App Access Standardizes Agent Delegation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; OAuth 2.0 handles one-hop delegation well: App A gets a token to call App B on behalf of User U. The agentic use case breaks this model almost immediately. An AI orchestrator calls three downstream services in a chain, each of which needs to verify that User U actually authorized this entire sequence and that no hop exceeded the user's original grant.&lt;/p&gt;

&lt;p&gt;RFC 8693 (Token Exchange) is the nearest existing standard. The IETF OAuth working group's draft-oauth-identity-chaining extends it for multi-hop agentic delegation. As of early 2026, this draft has passed working group last call and is on the path to becoming an RFC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; When Cross App Access ships in major IdPs, it changes the liability question for AI agent actions. Today, if an AI agent takes an unauthorized action, the chain of delegation is nearly impossible to audit. With CAA implemented, every hop in the agent chain carries a cryptographically verifiable record of what was delegated by whom under what scope constraints. Compliance teams will require this for regulated industries within 12-18 months of IdP support shipping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Products that use AI agents or expose APIs that AI agents call should be watching draft-oauth-identity-chaining closely. The architecture decisions being made in 2026 will determine whether your product is natively compliant with CAA or requires an expensive retrofit. Early adoption means you can tell enterprise customers "our agent delegation is auditable end-to-end," which is a competitive advantage in security reviews.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"Cross App Access will be the most important protocol development in enterprise identity since OAuth 2.0 itself. The first SaaS vendors to build native CAA support will have a three-to-five year head start on the compliance curve."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 4: Passkey Adoption Crosses the Enterprise Chasm
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; Passkeys have crossed consumer adoption. The FIDO Alliance's 2024 survey found 53% of users have enabled passkeys on at least one account. Apple, Google, and Microsoft built passkey support into their platforms in 2022-2023. But enterprise adoption has lagged consumer adoption by roughly 18-24 months, blocked by enrollment logistics, account recovery workflows, and IT admin tooling that wasn't ready.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; Microsoft's announcement that Entra ID will auto-enable passkey profiles and shift registration campaigns toward passkey enrollment is the enterprise chasm moment. Beginning March 2026, Microsoft is automatically pushing synced passkeys as the default authentication method across Entra tenants. When the world's largest enterprise IdP makes passkeys the default rather than an opt-in, enterprise adoption stops being a matter of "if" and becomes a matter of "when."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Products that offer SSO through Entra ID connections will see enterprise customers' employees arriving with passkey-based authentication more frequently from 2026 onwards. Products that also let non-SSO users choose passkeys as their primary factor will benefit from the consumer familiarity effect: users who already use passkeys on their personal devices will want the same experience in your B2B product.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"Passkeys will be table stakes for any new B2B authentication UI by late 2026. Products that still default to password + SMS MFA in their signup flow will face procurement friction from enterprise buyers whose employees are now accustomed to passkey UX from Microsoft and Google."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 5: Decentralized Identity Moves From Hype to Niche Reality
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; Decentralized identity, the W3C DID standard, Verifiable Credentials, and self-sovereign identity concepts have been in the "emerging" category for a decade. Gartner predicted in 2024 that 50% of large enterprises would implement decentralized identity by 2026. That prediction is almost certainly too aggressive, but the underlying movement is real. The EU Digital Identity Wallet mandate requires EU member states to offer government-issued digital identity wallets to citizens by 2026. A handful of verifiable credential deployments in healthcare and education are in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; The EU eIDAS 2.0 regulation has a hard 2026 deadline for the EUDIW (EU Digital Identity Wallet). When a regulated bloc of 450 million people has a government-issued digital identity wallet, enterprise use cases that accept verifiable credentials become commercially viable. Healthcare verifying professional credentials, financial services confirming KYC data, higher education issuing verifiable diplomas: these are the niches where decentralized identity solves a real problem before it solves the general problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Most B2B SaaS products don't need to implement DID support in 2026. But products selling into EU-regulated industries (healthcare, financial services, government contracting) should be aware of EUDIW as an incoming authentication vector. The practical question is not whether to support DID generally, but whether to accept EU Digital Identity Wallet credentials specifically for identity verification in onboarding flows.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"Decentralized identity won't replace SSO in enterprise environments in this decade. But it will become a real identity input vector for high-assurance use cases, like KYC-gated product features and credential-verified professional access, by 2027-2028."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 6: Non-Human Identities Outnumber Human Identities 10:1
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; CyberArk's 2024 Identity Security Threat Landscape report estimated that non-human identities already outnumber human identities by a ratio of roughly 45:1 in enterprise environments when you count all service accounts, API keys, machine certificates, bot accounts, and automation tokens. The actual governance coverage is the inverse: most organizations have detailed processes for human identity governance and almost nothing for non-human identity governance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; A TerraZone study in 2024 found that credential-based attacks, many targeting non-human credentials, account for over 80% of breaches. The attack pattern is consistent: a leaked API key or service account credential with excessive permissions, found in a public repository or leaked via a compromised development environment, provides lateral movement capabilities that a compromised human credential wouldn't. The scale problem is getting worse as AI agents multiply, not better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Products that issue API keys, service account credentials, or any long-lived machine credential to customers need a formal non-human identity governance story. That means: inventory of all issued credentials, expiry enforcement, scope constraints, automated rotation, and revocation propagation. Products that don't have this will face procurement questions from enterprise customers who are now explicitly reviewing non-human identity coverage in security assessments.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"By 2027, we expect 'non-human identity governance' to appear explicitly in SOC 2 Type 2 audit procedures. Right now it's implied by access control requirements. It won't stay implied much longer."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 7: Identity-First Security Replaces Network-Centric Models
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; Network-perimeter security assumed that everything inside the corporate network was trusted. Zero trust shattered that assumption in theory. But most organizations still have implicit network trust baked into their architecture: resources accessible from VPN without additional authentication, internal services that trust traffic from within the data center, access control enforced at the network layer rather than the identity layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; The 2022 Uber breach, the 2023 Okta compromise, and Verizon's 2025 DBIR finding that 46% of devices with corporate logins were unmanaged all point to the same structural failure: network trust doesn't catch identity-layer attacks. Organizations using zero trust with device posture checks saw 50% fewer breaches per the 2025 DBIR data. The numbers are moving enterprise security teams toward identity-first architecture, not network-first architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Products that claim zero-trust compatibility need to demonstrate it, not just claim it. That means: accepting device posture signals from customer IdPs, supporting continuous authentication re-verification for sensitive operations, exposing telemetry that customers' SIEMs can consume for anomaly detection, and having no implicit trust based on IP address or network location. The &lt;a href="https://ssojet.com/blog/best-iam-device-aware-sso" rel="noopener noreferrer"&gt;device-aware SSO guide&lt;/a&gt; covers what enterprise customers are actually checking in vendor security reviews.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"Within 18 months, 'does your product support identity-based access controls independent of network location?' will be in most enterprise security questionnaires. Right now it's asked by the most security-mature buyers. It's moving mainstream."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 8: Compliance Frameworks Explicitly Cover AI Agents
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; Current compliance frameworks handle AI agents awkwardly, at best. SOC 2's trust service criteria cover logical access controls and monitoring, but they were written when "user" meant a human with a managed device. HIPAA's access control requirements don't distinguish between a human accessing ePHI and an AI agent doing the same. ISO 27001:2022's control A.8.5 (secure authentication) and A.5.15 (access control) apply to AI agents technically, but no current audit procedure tests for it specifically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; The EU AI Act's high-risk AI system requirements (for AI systems in employment, education, critical infrastructure, and healthcare contexts) include obligations for logging, human oversight, and robustness. NIST's AI Risk Management Framework is being integrated with existing NIST security frameworks. The UK ICO's guidance on AI and data protection explicitly addresses automated decision-making with personal data. The FTC has issued guidance on AI-enabled products in consumer contexts. These are early signals, not finalized requirements, but compliance frameworks tend to move from "guidance" to "audit finding" on a 2-3 year cycle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Products in regulated sectors that use AI agents internally or expose AI capabilities to customers should be documenting their AI agent governance posture now, before it becomes an audit requirement. The documentation that's cheap to produce in 2025 becomes expensive to reconstruct retroactively after the first audit cycle that asks for it. Building audit-ready AI agent identity governance infrastructure now is exactly the kind of investment that compounds.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"The first SOC 2 audit that includes an explicit 'AI agent access control' finding will publish in 2026 or early 2027. When it does, the entire B2B SaaS market will have 6-12 months to get their AI agent governance in order before auditors standardize the procedure."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Trend 9: Consolidation in the IAM Vendor Market Accelerates
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Current state.&lt;/strong&gt; The IAM vendor market is fragmented across at least five categories: workforce IAM (Okta, Microsoft Entra ID), customer IAM (Auth0, Cognito, Stytch), B2B SaaS SSO enablement (SSOJet, WorkOS), privileged access management (CyberArk, BeyondTrust), and identity security (Silverfort, Oort). These categories have historically been distinct. They're converging. Microsoft's Entra suite now covers workforce, external identity, and privileged access. Okta acquired Auth0 and launched Okta Customer Identity. CyberArk acquired Idaptive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The inflection point.&lt;/strong&gt; The consolidation driver is the enterprise IT team's desire to reduce vendor count. Managing six separate identity vendors creates governance complexity, audit burden, integration debt, and cost overlap. When a single vendor can handle workforce SSO, customer identity, PAM, and AI agent governance in one platform, the procurement argument against maintaining separate specialized tools becomes hard to win. Per Gartner's Market Guide for Identity and Access Management, consolidation is the dominant buying pattern in 2025-2026 for enterprises over 5,000 employees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Implication for B2B SaaS.&lt;/strong&gt; Products that specialize in one part of the identity stack need to be clear about which part they own best, rather than trying to expand into adjacent categories to avoid consolidation risk. The vendors who survive consolidation waves are the ones who either become the consolidator or become deeply embedded in a specific category that the consolidators don't absorb. For B2B SaaS SSO enablement specifically, the strategic moat is universal IdP compatibility and developer experience. Those are harder to replicate than raw feature count.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;SSOJet team prediction:&lt;/strong&gt;"Consolidation will drive enterprise buyers toward platforms. But it will drive B2B SaaS companies toward specialized layers that do one thing extremely well without the overhead of an enterprise platform contract. We're building for the second customer."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What These Nine Trends Have in Common
&lt;/h2&gt;

&lt;p&gt;They're all pointing at the same underlying shift: identity is no longer a login problem. It's an infrastructure problem.&lt;/p&gt;

&lt;p&gt;In 2015, "enterprise identity" meant "set up SSO so employees don't need separate passwords." In 2026, it means governing a population of entities that includes humans, AI agents, service accounts, IoT devices, and automated pipelines, across organizational boundaries, under compliance frameworks that are updating faster than most security teams can track, using protocols that are actively being revised as this article is published.&lt;/p&gt;

&lt;p&gt;The B2B SaaS products that will win enterprise deals in this environment are the ones that treat identity as first-class infrastructure: transparent to their customers' IT teams, exportable to their customers' SIEMs, compatible with their customers' IdPs, and auditable at the level compliance frameworks increasingly require.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ssojet.com/" rel="noopener noreferrer"&gt;SSOJet&lt;/a&gt; connects to 100+ identity providers through SAML 2.0, OIDC, SCIM 2.0, and OAuth 2.1, handles SCIM provisioning and deprovisioning automatically, and surfaces structured audit logs that customers can pipe directly into their security infrastructure. That's the foundation. The &lt;a href="https://ssojet.com/white-papers/non-human-identity-handbook-ciam-nhi-ai-security/" rel="noopener noreferrer"&gt;non-human identity handbook&lt;/a&gt; covers where the AI agent governance layer is headed for teams who want to be ahead of the 2026-2027 compliance cycle rather than behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Are the Biggest Enterprise Identity Trends in 2026?
&lt;/h3&gt;

&lt;p&gt;The nine most significant trends are: agentic identity becoming a first-class IAM concern, native MCP support arriving in major IdPs, Cross App Access standardizing agent delegation, passkeys crossing the enterprise adoption threshold (driven by Microsoft Entra's March 2026 auto-enrollment), decentralized identity finding real traction in EU-regulated industries, non-human identities demanding formal governance, identity-first security displacing network-centric models, compliance frameworks explicitly covering AI agents, and IAM vendor consolidation accelerating.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Is Agentic Identity and Why Does It Matter for Enterprise Security?
&lt;/h3&gt;

&lt;p&gt;Agentic identity is the practice of giving AI agents formal, governable identities: registered OAuth clients, scoped credentials, short-lived tokens, and audit trails that distinguish agent actions from human actions. It matters because AI agents are already operating in enterprise environments with real permissions against real data. Without formal identity governance, they represent an ungoverned access vector that sits entirely outside existing IAM infrastructure. Enterprise procurement teams are beginning to ask vendors for their AI agent identity story in security questionnaires.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Will Passkeys Become Standard in Enterprise Environments?
&lt;/h3&gt;

&lt;p&gt;The inflection point is 2026. Microsoft Entra ID began auto-enabling passkey profiles and shifting registration campaigns toward passkey enrollment in March 2026. When the world's largest enterprise identity provider makes passkeys the default, enterprise adoption follows. Most enterprises using Entra ID will have meaningful passkey adoption by the end of 2026. Okta and Google Workspace are following similar trajectories on slightly longer timelines.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Is Cross App Access and How Does It Relate to AI Agents?
&lt;/h3&gt;

&lt;p&gt;Cross App Access (CAA), also called OAuth Identity Chaining (draft-oauth-identity-chaining at the IETF), extends OAuth 2.0's token exchange framework for multi-hop agent delegation chains. Where OAuth 2.0 handles "App A calls App B on behalf of User U," CAA handles "App A calls App B calls App C, and App C needs to verify that User U authorized this entire chain." It provides cryptographic auditability of delegation chains, which compliance teams will require for AI agent workflows in regulated industries.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Should B2B SaaS Companies Prepare for IAM Compliance Changes?
&lt;/h3&gt;

&lt;p&gt;Document AI agent governance infrastructure now, before compliance frameworks finalize audit procedures for it. Implement formal non-human identity lifecycle management: every API key, service account, and agent credential should have a registered owner, an expiry, and a revocation procedure. Expose audit telemetry to customer SIEMs. Support customer IdPs' device posture and conditional access policies through your SSO integration. The investment in building identity infrastructure that satisfies a 2027 SOC 2 audit is the same infrastructure that closes enterprise deals today.&lt;/p&gt;

</description>
      <category>enterpriseidentitytr</category>
      <category>futureofenterpriseid</category>
      <category>agenticidentitymanag</category>
      <category>mcpauthenticationent</category>
    </item>
    <item>
      <title>10 Common SSO Implementation Mistakes (and How to Avoid Each)</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Mon, 04 May 2026 11:50:51 +0000</pubDate>
      <link>https://forem.com/ssojet/10-common-sso-implementation-mistakes-and-how-to-avoid-each-21hh</link>
      <guid>https://forem.com/ssojet/10-common-sso-implementation-mistakes-and-how-to-avoid-each-21hh</guid>
      <description>&lt;p&gt;&lt;em&gt;These aren't hypothetical edge cases. They're the patterns that show up in support tickets, post-mortems, and "why is nobody able to log in at 9am" incidents. Here's what causes each one and how to fix it.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most SSO implementations work fine in the demo environment. Then the first enterprise customer tries to log in using an IdP-initiated flow, and everything breaks. Or the metadata certificate expires on a Saturday. Or a new employee can't access the product for three days because nobody connected SCIM. These aren't bugs in the SAML spec. They're predictable implementation gaps that show up reliably across engineering teams who built SSO for the first time.&lt;/p&gt;

&lt;p&gt;The 10 mistakes below are drawn from the most common patterns in SSO support tickets, with the bug each produces, the fix, and how a properly abstracted SSO layer handles it automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging SAML Assertions: A Mini-Guide
&lt;/h2&gt;

&lt;p&gt;Before the mistakes list, a reference you'll use when debugging. Most SSO problems are visible in the SAML assertion itself, but the base64 encoding makes them invisible in raw form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1: Capture the assertion.&lt;/strong&gt; Install the SAML Tracer browser extension (Firefox or Chrome). Reproduce the failing login. The extension shows every HTTP request. Find the POST to your Assertion Consumer Service (ACS) URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2: Decode it.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zlib&lt;/span&gt;

&lt;span class="c1"&gt;# SAMLResponse is the form field value from the POST
&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PHNhbWxwOlJlc3BvbnNlIHhtbG5zOnNhbWxwPSJ1...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;decoded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;base64&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;b64decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Some responses are also zlib-compressed (DEFLATE)
&lt;/span&gt;&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;decoded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3: Check the fields that fail most often:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NotBefore: 2025-09-14T08:30:00Z → Must be in the past (within clock skew)
NotOnOrAfter: 2025-09-14T08:35:00Z → Must be in the future
Issuer: https://idp.customer.com/... → Must match what you've stored
Audience: https://app.yourproduct.com/ → Must match your Entity ID exactly
InResponseTo: _abc123... → Must match your AuthnRequest ID (SP-initiated only)
NameID: jane.doe@customer.com → This is the user identifier

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If any of those mismatches are present, you've found your bug before writing a single line of code. The &lt;a href="https://ssojet.com/saml-tester" rel="noopener noreferrer"&gt;SSOJet SAML tester&lt;/a&gt; lets you paste a raw SAMLResponse and get all field validations surfaced immediately, which speeds this process up considerably for teams doing repeated debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 1: Treating SSO as Auth-Only and Skipping Provisioning
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; A new employee at your enterprise customer gets assigned to your product in their company's Okta. They try to log in for the first time. The SSO flow completes successfully. Your application has no user record for them. They land on a 404 or "account not found" error. The customer IT admin files a support ticket. You spend an hour recreating the user manually.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; SSO handles authentication: verifying that the person is who they say they are. It doesn't create the user account in your system, assign roles, set up their workspace, or pre-populate their permissions. That's provisioning. Teams implement SAML or OIDC, test it with an existing account, see a successful login, and ship it. The first-time login case for a never-seen-before user gets overlooked.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Implement one of two approaches, ideally both: Just-in-Time (JIT) provisioning creates a user account on first successful SSO login using attributes from the SAML assertion (typically email, name, and department). SCIM provisioning pre-creates accounts before first login by receiving a push from the customer's IdP whenever a user is assigned to your application. JIT covers the "user shows up and they're not in our system" case. SCIM covers the "user should have existed before they even tried to log in" case, and also handles deprovisioning when someone leaves.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle_sso_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saml_assertion&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;saml_assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by_email&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# JIT provisioning: create account from assertion attributes
&lt;/span&gt;        &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;saml_assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;map_role&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saml_assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;saml_assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provisioned_via&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;jit_saml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nf"&gt;send_welcome_notification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How SSOJet handles it.&lt;/strong&gt; &lt;a href="https://ssojet.com/blog/scim-vs-saml-understanding-the-difference-between-provisioning-and-authentication" rel="noopener noreferrer"&gt;SSOJet's SCIM provisioning&lt;/a&gt; runs alongside SSO authentication and handles both JIT and SCIM-push provisioning out of the box, including role mapping from assertion attributes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2: Storing IdP Metadata as Application Config Instead of Per-Tenant Database Records
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; Your SSO configuration lives in a config file or environment variable. You have 12 enterprise customers. When Okta announces a certificate rotation in 30 days, you have to deploy a new version of your application for each customer's connection update, or maintain 12 separate deployment configurations. When one customer migrates from ADFS to Entra ID, the change requires a code deploy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; The first SSO implementation often gets the IdP metadata (Entity ID, SSO URL, certificate) hardcoded into config because that's the simplest working version. It's fine for one customer. It doesn't scale past three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Store IdP configuration in a database table, keyed by tenant or organization. Each tenant has their own row with their own SAML metadata, certificate, and attribute mapping. Load the configuration dynamically at authentication time based on which tenant the user belongs to (derived from email domain, subdomain, or explicit organization selector).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_idp_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sso_configs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_one&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TenantNotConfiguredError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entity_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idp_entity_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idp_sso_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;certificate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idp_certificate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attr_mapping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attribute_mapping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;How SSOJet handles it.&lt;/strong&gt; SSOJet stores all per-tenant IdP configuration in isolated tenant records. Certificate updates, metadata changes, and attribute mapping adjustments happen through a customer-facing admin portal with zero deployment required on your side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 3: Ignoring IdP-Initiated Flows
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; A customer's IT team configures your product as a SAML app in their Okta dashboard. Their employees now see your product tile in the Okta app switcher. They click it. Your application receives a SAMLResponse with no corresponding AuthnRequest ID. Your validation logic checks &lt;code&gt;InResponseTo&lt;/code&gt; against a request it never issued, fails, and returns a 400 error. The customer calls this "SSO not working" and they're right.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Most SSO tutorials cover SP-initiated flow: the user visits your app, gets redirected to the IdP, authenticates, gets sent back. IdP-initiated flow starts in reverse: the user logs into Okta or Entra ID first, clicks your app tile, and gets a SAMLResponse pushed to your ACS URL unsolicited. There's no &lt;code&gt;InResponseTo&lt;/code&gt; to validate. Many implementations reject this outright without realizing it's a legitimate flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Handle both flows. For SP-initiated: validate &lt;code&gt;InResponseTo&lt;/code&gt; against a stored request ID. For IdP-initiated: allow &lt;code&gt;InResponseTo&lt;/code&gt; to be absent, but tighten the other validations: check &lt;code&gt;Recipient&lt;/code&gt;, verify &lt;code&gt;NotOnOrAfter&lt;/code&gt;, validate &lt;code&gt;Audience&lt;/code&gt;, and reject assertions older than 2 minutes. The risk in IdP-initiated flows is request forgery: an attacker could forge a SAMLResponse and POST it to your ACS URL. Timestamp and audience validation are your primary defenses when &lt;code&gt;InResponseTo&lt;/code&gt; isn't available.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_saml_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stored_request_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# InResponseTo is optional (IdP-initiated)
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stored_request_id&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;in_response_to&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;stored_request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;InvalidResponseError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;InResponseTo mismatch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# These checks are required regardless of flow type
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audience&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENTITY_ID&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AudienceMismatchError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;not_on_or_after&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionExpiredError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;not_on_or_after&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;issue_instant&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;minutes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionTooLongError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mistake 4: Hardcoding Email as the User Identifier
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; A user at a customer company gets married and changes their last name. Their IT admin updates their email in Okta. Next login, your application receives a SAML assertion with &lt;code&gt;jane.smith@company.com&lt;/code&gt; instead of &lt;code&gt;jane.doe@company.com&lt;/code&gt;. Your system looks up the user by email, finds nothing, and creates a duplicate account. The user now has two accounts, their data is split across both, and your customer's IT team has to file a support ticket to merge them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Email is the most human-readable attribute in a SAML assertion and the easiest thing to use as a primary key. It's also mutable. IdPs issue a persistent, immutable user identifier (typically &lt;code&gt;nameID&lt;/code&gt; with format &lt;code&gt;urn:oasis:names:tc:SAML:2.0:nameid-format:persistent&lt;/code&gt;, or an &lt;code&gt;externalId&lt;/code&gt; in OIDC) specifically to avoid this problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Store the persistent IdP user identifier as the primary lookup key for SSO logins. Email can be stored and updated as a secondary attribute. When an assertion arrives, look up the user by their persistent &lt;code&gt;nameID&lt;/code&gt; or &lt;code&gt;sub&lt;/code&gt; (OIDC), not by email. If you also want to allow email-based login, store email as a separate indexed field that can be updated without affecting the primary identity link.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_or_create_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Primary lookup: persistent IdP user ID (never changes)
&lt;/span&gt;    &lt;span class="n"&gt;idp_user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name_id&lt;/span&gt; &lt;span class="c1"&gt;# or assertion.get("sub") for OIDC
&lt;/span&gt;    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by_idp_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idp_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Update mutable attributes on every login
&lt;/span&gt;        &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;

    &lt;span class="c1"&gt;# First login: create with persistent ID
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idp_user_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;idp_user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mistake 5: No Fallback for Admin Lockout
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; A customer's IT admin misconfigures their SAML metadata (wrong certificate, wrong Entity ID). All SSO logins for that customer fail. There are no non-SSO logins for admin accounts because the implementation forced all admin authentication through SSO. The customer's admin can't log in to fix the SSO configuration. Your engineering team has to manually update the database to fix it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Teams sometimes enforce SSO for all logins as a "security improvement" without thinking about what happens when the SSO connection itself is broken. A broken SSO connection is then an accidental lockout of the entire organization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Keep at least one break-glass admin authentication method that doesn't depend on SSO. A hardware FIDO2 key registered directly against your application works well for super-admin accounts. For regular admin accounts, a one-time emergency access code workflow (email-based, with strict rate limiting and audit logging) covers most recovery scenarios. The emergency path should be harder to use than SSO, not easier, but it must exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 6: Assuming All IdPs Behave Like Okta
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; You test your SSO implementation against Okta and everything works. Your first non-Okta customer uses Microsoft Entra ID. The attribute mapping is different. Entra sends &lt;code&gt;http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress&lt;/code&gt; where you expected &lt;code&gt;email&lt;/code&gt;. Your user lookup fails. Your second customer uses PingFederate. Ping sends the email in the &lt;code&gt;NameID&lt;/code&gt; rather than as a separate attribute. Your user lookup fails again. You spend a week writing IdP-specific attribute mapping logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; SAML 2.0 standardizes the XML structure but not attribute names. Every IdP uses its own claim naming conventions. Okta, Entra ID, Google Workspace, Ping, OneLogin, and ADFS all use different default attribute names for email, name, groups, and custom attributes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Build a per-tenant attribute mapping layer that translates IdP-specific attribute names into your application's internal attribute schema. Store the mapping in the per-tenant configuration (see Mistake 2). Ship default mappings for the IdPs you've tested against, and expose the mapping as configurable for customers who need to override it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;DEFAULT_ATTRIBUTE_MAPPINGS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;okta&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;displayName&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;entra_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://schemas.microsoft.com/identity/claims/displayname&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://schemas.microsoft.com/ws/2008/06/identity/claims/groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;google&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;groups&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_attribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attr_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_config&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;mapping&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tenant_config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attr_mapping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; \
              &lt;span class="n"&gt;DEFAULT_ATTRIBUTE_MAPPINGS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_config&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idp_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
    &lt;span class="n"&gt;idp_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attr_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attr_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;assertion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;idp_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mistake 7: Ignoring Clock Skew
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; Random users fail to log in with "assertion expired" or "assertion not yet valid" errors. The problem isn't reproducible in your environment. It's happening on the customer's side. The root cause: a server in your stack has drifted 3-4 minutes from NTP. SAML assertions have tight time windows (&lt;code&gt;NotBefore&lt;/code&gt; and &lt;code&gt;NotOnOrAfter&lt;/code&gt; are typically 5 minutes apart). A 3-minute clock drift puts the assertion outside that window intermittently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Clock synchronization is infrastructure, not code, and often falls outside the scope of a SAML implementation ticket. It's easy to implement correct assertion time validation but forget to verify that the system clock is actually synchronized.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Two layers: first, configure NTP on all servers in your authentication path and alert if NTP sync fails. Second, implement a configurable clock skew tolerance in your assertion validator. 60 to 120 seconds is standard. Don't exceed 5 minutes (half the typical assertion window) or you defeat the purpose of the time constraint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;CLOCK_SKEW_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;120&lt;/span&gt; &lt;span class="c1"&gt;# 2 minute tolerance
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_assertion_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;not_before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;not_on_or_after&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;not_before&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CLOCK_SKEW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionNotYetValidError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;now=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, not_before=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;not_before&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;not_on_or_after&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;CLOCK_SKEW_SECONDS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionExpiredError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;now=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, expired=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;not_on_or_after&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also: add structured logging for every assertion time validation that fails, including the actual values. "Assertion expired" as a user-facing error is useless. "NotOnOrAfter=2025-09-14T08:35:00Z, server_time=2025-09-14T08:38:42Z, delta=+162s" gives you the root cause in the log without manual debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 8: No Logout Propagation (SLO)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; A user clicks "Sign Out" in your application. Your application destroys their session. They go back to the customer's Okta portal and click your app tile. They're logged back in immediately because their IdP session was never terminated. From the user's perspective (and their IT security team's perspective), "sign out" didn't work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Single Log-Out (SLO) is the part of the SAML specification that most implementations skip. It requires your SP to send a &lt;code&gt;LogoutRequest&lt;/code&gt; to the IdP when a user logs out, so the IdP can terminate the upstream session. It also requires your ACS endpoint to accept inbound &lt;code&gt;LogoutRequest&lt;/code&gt; messages from the IdP (when a user signs out of Okta, all connected SPs should be notified).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Implement both directions. On user logout from your app: send a SAML &lt;code&gt;LogoutRequest&lt;/code&gt; to the IdP's SLO endpoint. On inbound &lt;code&gt;LogoutRequest&lt;/code&gt; from IdP: destroy the local session and return a &lt;code&gt;LogoutResponse&lt;/code&gt;. This is particularly important for regulated industries where "logout means logout" is a compliance requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 9: Leaking Tenant Information in Error Messages
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; A user from &lt;code&gt;competitor.com&lt;/code&gt; tries to access your application and guesses that &lt;code&gt;acme.com&lt;/code&gt; is one of your customers by attempting &lt;code&gt;yourapp.com/sso?tenant=acme&lt;/code&gt;. Your error message says "SSO not configured for this tenant" versus "SSO not configured" versus "Authentication failed." The difference is an information disclosure: an attacker can enumerate which companies are your customers by testing different tenant identifiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; Helpful error messages for developers become information disclosure in production. "Tenant 'acme' not found in SSO configuration" is great for debugging. It's a customer list leak in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Return identical error messages and HTTP status codes for all authentication failure modes: tenant not found, SSO not configured, assertion invalid, and user not found should all look the same to the client. Log the specific error internally with the tenant context. Surface detailed diagnostics only to authenticated admin users in your own admin panel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Don't do this
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tenant &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; not found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Do this instead
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sso_config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sso_failure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant_not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;HTTPException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authentication failed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mistake 10: Shipping SSO Without SCIM
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The bug it produces.&lt;/strong&gt; An employee at a customer company leaves. Their IT admin disables their Okta account. Your application gets no notification of this. The former employee's account remains active. Depending on whether they kept their corporate device or had a browser session open, they may still be able to access your application. The customer's security team discovers this during an access review and files a compliance incident against your product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it happens.&lt;/strong&gt; SSO handles authentication at login time. It doesn't tell your application when a user is deprovisioned between login events. A user whose Okta account was disabled will fail to authenticate on their next login attempt, but any existing sessions remain valid until they expire. For products with long session lifetimes (7-day refresh tokens, for example), that window is significant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix.&lt;/strong&gt; Implement SCIM provisioning alongside SSO. When the customer's IdP sends a SCIM DELETE or a PATCH with &lt;code&gt;active: false&lt;/code&gt;, immediately deactivate the user account in your system and revoke all active sessions. The combination of SSO for authentication and SCIM for lifecycle management is what enterprise IT teams mean when they say "enterprise-grade identity integration."&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://ssojet.com/blog/scim-vs-saml-understanding-the-difference-between-provisioning-and-authentication" rel="noopener noreferrer"&gt;SCIM vs SAML guide on SSOJet&lt;/a&gt; explains why shipping one without the other creates the exact security and compliance gaps that enterprise security reviews find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reduce SSO Maintenance With a Platform That Handles These Patterns
&lt;/h2&gt;

&lt;p&gt;The 10 mistakes above have something in common: most of them are things you'll discover after your first enterprise customer is live in production, not before. The Okta attribute naming bug shows up when your second customer uses Entra ID. The clock skew error shows up when one of your servers misses an NTP sync at 2am. The SLO gap shows up when a customer's security team does an access review.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ssojet.com/" rel="noopener noreferrer"&gt;SSOJet&lt;/a&gt; handles SAML assertion parsing, per-tenant IdP configuration, attribute mapping normalization, JIT provisioning, SCIM lifecycle management, and SLO out of the box, across 100+ identity providers. When Entra ID's attribute naming changes, that's a platform update, not a ticket to your engineering team. When a customer uses an unusual IdP, the existing connection infrastructure handles it. The maintenance burden shifts from your team to a platform that exists specifically to absorb these implementation details at scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Causes Most SSO Implementation Failures in B2B SaaS?
&lt;/h3&gt;

&lt;p&gt;The most common causes are: email used as the primary identifier (breaks on name changes), missing JIT provisioning (new users can't log in at all), no IdP-initiated flow support (Okta/Entra app tiles don't work), and per-tenant IdP configuration stored as application config rather than database records (breaks when you have more than a few enterprise customers). Clock skew and missing SCIM are close runners-up for post-launch issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Is the Difference Between SP-Initiated and IdP-Initiated SSO Flows?
&lt;/h3&gt;

&lt;p&gt;SP-initiated SSO starts at your application: the user visits your app, gets redirected to their IdP to authenticate, and is sent back. IdP-initiated SSO starts at the IdP: the user logs into Okta or Entra ID, clicks a tile for your application, and gets a SAMLResponse pushed to your ACS endpoint without a prior request. Both are valid SAML 2.0 flows. IdP-initiated requires different validation logic (no &lt;code&gt;InResponseTo&lt;/code&gt; to verify) and is often the one developers forget to implement.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Do You Debug a SAML Assertion Failure?
&lt;/h3&gt;

&lt;p&gt;Install SAML Tracer in your browser, reproduce the failing login, and find the POST to your ACS URL. Copy the &lt;code&gt;SAMLResponse&lt;/code&gt; field value and base64-decode it. The resulting XML shows you the exact assertion fields: &lt;code&gt;NotBefore&lt;/code&gt;, &lt;code&gt;NotOnOrAfter&lt;/code&gt;, &lt;code&gt;Issuer&lt;/code&gt;, &lt;code&gt;Audience&lt;/code&gt;, &lt;code&gt;NameID&lt;/code&gt;, and &lt;code&gt;InResponseTo&lt;/code&gt;. A mismatch in any of these is almost always the cause of assertion failures. Clock skew shows as a timestamp that should be valid but isn't. Certificate issues show as signature validation failures on correctly formed assertions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Do Users Get "Account Not Found" After Successful SSO Login?
&lt;/h3&gt;

&lt;p&gt;This is the JIT provisioning gap. SSO authentication succeeded: the IdP verified the user's identity. But your application has no user record for that person. They've never logged in before (or they were provisioned under a different email). The fix is Just-in-Time provisioning: on successful SSO callback, if no user record exists for the returned &lt;code&gt;nameID&lt;/code&gt;, create one from the assertion attributes before creating the session.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do You Need SCIM If You Already Have SSO?
&lt;/h3&gt;

&lt;p&gt;Yes. SSO handles authentication at login time. SCIM handles the full user lifecycle: creating accounts before first login, updating attributes when roles change, and deprovisioning access immediately when someone leaves the organization. Without SCIM, former employees' accounts remain active until they next attempt to log in and fail. This is a compliance gap in security reviews and a real access control risk for products with long session lifetimes.&lt;/p&gt;

</description>
      <category>ssoimplementationmis</category>
      <category>ssointegrationproble</category>
      <category>samldebuggingguide</category>
      <category>ssoimplementationb2b</category>
    </item>
    <item>
      <title>12 Questions to Ask Before Choosing an SSO Integration Provider</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Mon, 04 May 2026 11:44:35 +0000</pubDate>
      <link>https://forem.com/ssojet/12-questions-to-ask-before-choosing-an-sso-integration-provider-31g3</link>
      <guid>https://forem.com/ssojet/12-questions-to-ask-before-choosing-an-sso-integration-provider-31g3</guid>
      <description>&lt;p&gt;&lt;em&gt;Most SSO vendor comparisons focus on features. The questions that actually separate good vendors from bad ones are about pricing math, operational reality, and what happens when things go wrong.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The average B2B SaaS team spends three weeks evaluating SSO vendors and discovers the pricing problem six months after signing the contract. The per-connection vs per-user distinction alone can mean the difference between $800 per month and $12,000 per month at 100 enterprise customers. That's before considering what happens when your sixth enterprise customer triggers an automatic tier upgrade, or when an identity provider your customer uses isn't on the vendor's supported list.&lt;/p&gt;

&lt;p&gt;The 12 questions below are a buyer's guide and an RFP template. Use them in vendor calls. Use them to pressure-test demos. The "a good answer looks like" guidance after each question tells you what separates vendors who will grow with you from vendors who will tax your growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RFP Template: Copy and Send
&lt;/h2&gt;

&lt;p&gt;Before diving into the questions, here's the quick-reference version you can paste directly into a vendor RFP email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Please answer the following before our technical evaluation call:

1. Is your pricing per-connection, per-user (MAU), or per-tenant? 
   Show us a modeled cost at 50 enterprise customers.
2. How many IdPs do you support natively today, and what's your 
   process when a customer needs one you don't cover?
3. Does your platform support OAuth 2.1? PKCE enforcement? 
   Refresh token rotation?
4. What is your MCP / AI agent authentication story?
5. Where do audit logs surface, and can we expose them to 
   customer admins via API?
6. Can our customers' IT admins configure their own SSO 
   connection without our engineering team involved?
7. What is your uptime SLA? What's the remediation process for 
   an incident that breaches it?
8. Where is customer authentication data stored, and can we 
   specify a region?
9. If we migrate away, what does the data export process look 
   like, and how long does it take?
10. What does your roadmap look like for the next 12 months, 
    and how do customers influence it?
11. Can you provide references from B2B SaaS companies at our 
    scale and with our customer type?
12. What does your pricing model look like if we want to 
    include SSO in all plans, not gate it?

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Question 1: What Is Your Pricing Model and What Does It Cost at Scale?
&lt;/h2&gt;

&lt;p&gt;This is the question vendors least want to answer specifically, which is exactly why it needs to be the first one you ask.&lt;/p&gt;

&lt;p&gt;SSO integration providers price in three fundamentally different ways. Per-connection models charge a flat fee for each enterprise customer IdP connection, regardless of how many users that customer has. WorkOS charges $125 per connection per month. At 50 enterprise customers, that's $6,250 per month in authentication costs alone, before any other features. Per-user (MAU) models charge based on total authenticated users. This sounds simple until you land a 5,000-seat enterprise customer and discover that 5,000 monthly active users just multiplied your authentication bill. Per-tenant models charge based on your own customer count rather than your customers' user counts, which is structurally more aligned with how B2B SaaS revenue works.&lt;/p&gt;

&lt;p&gt;The specific risk to watch for is the connection trap: vendors who offer a cheap entry price with a hard cap on connections (3 connections on Essentials, 5 on Professional) that forces you into a $10,000-per-month enterprise contract when you sign your sixth customer. This is not a hypothetical. Auth0's B2B plan enforces exactly this structure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; A pricing model that scales with your enterprise customer count in a predictable, linear way. Ask to model the cost at 10, 50, and 100 enterprise customers. Ask specifically whether there are connection limits that trigger tier upgrades. Ask what happens to your monthly invoice if your largest customer has 10,000 employees. &lt;a href="https://ssojet.com/blog/authentication-platforms-for-b2b-saas" rel="noopener noreferrer"&gt;SSOJet's connection-based pricing&lt;/a&gt; charges per tenant, not per end user, which means a 5,000-person enterprise customer costs the same as a 50-person one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 2: How Many IdPs Do You Support, and How Do You Handle Gaps?
&lt;/h2&gt;

&lt;p&gt;Enterprise customers don't all use Okta and Entra ID. Financial services companies run Ping Identity. Healthcare organizations use OneLogin or a legacy ADFS deployment. Government contractors sometimes use CyberArk or custom SAML configurations that nobody has heard of. If your SSO vendor doesn't support the IdP your customer uses, you're in a support escalation at the worst possible moment: during a deal.&lt;/p&gt;

&lt;p&gt;The realistic answer to "how many IdPs do you support" is harder to verify than it sounds. Vendors often count an IdP as "supported" if they've documented a generic SAML configuration, even if they've never tested it. The more useful question is: how many IdPs have you tested against in the last six months, and what is your process when a customer brings a configuration that doesn't work?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; Verified support for at least Okta, Microsoft Entra ID, Google Workspace, Ping Identity, OneLogin, and ADFS, with a clear process for adding new IdP configurations. Ask for a specific example of a customer who came in with an unusual IdP and how long it took to resolve. SSOJet supports 100+ identity providers through standardized SAML 2.0 and OIDC protocols, which means customers can connect any standards-compliant IdP without requiring vendor support tickets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 3: Do You Support OAuth 2.1, and Are Security Defaults Enforced?
&lt;/h2&gt;

&lt;p&gt;OAuth 2.1 is the consolidated security standard that mandates PKCE for all authorization code flows, removes implicit flow, removes ROPC, requires refresh token rotation, and enforces exact redirect URI matching. These aren't optional enhancements. They're the security defaults that prevent authorization code interception, session hijacking, and credential phishing at the protocol level.&lt;/p&gt;

&lt;p&gt;Many SSO vendors still operate on OAuth 2.0 without these controls enforced. Ask specifically: Is PKCE required for all public clients, or optional? Can integrators still use implicit flow? What is your default access token TTL? What is your refresh token rotation policy?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; PKCE enforced for all clients. Implicit flow disabled. Refresh tokens rotated on every use with reuse detection. Access token TTL under 60 minutes by default. The vendor should be able to name the specific OAuth 2.1 draft requirements they've implemented without looking them up. This isn't a "nice to have for future compliance." It's the baseline that enterprise security teams will audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 4: What Is Your AI Agent and MCP Authentication Story?
&lt;/h2&gt;

&lt;p&gt;This question will filter out roughly 80% of vendors immediately. Most existing SSO platforms were designed for human users authenticating through browsers. AI agents are a fundamentally different client type: they authenticate at machine speed, call multiple APIs per second, operate without human oversight, and need formal identity governance including per-task token issuance, scope constraints, audit logging, and lifecycle management.&lt;/p&gt;

&lt;p&gt;Model Context Protocol (MCP) is becoming the standard connector interface for AI agents accessing tools and data. If your product exposes MCP-compatible endpoints, or if your enterprise customers are asking about AI agent governance, your SSO vendor needs a machine-to-machine authentication model that treats agents as first-class OAuth clients, not as shared service accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; Support for OAuth 2.0 Client Credentials flow with scope constraints, short-lived JWTs (15 minutes or less) for agent tasks, and audit logging that distinguishes agent actions from human user actions. Ask what happens when an AI agent's token is compromised: can the vendor revoke it immediately, and does the revocation propagate to all downstream services? A vendor who doesn't have a clear answer to this question will become a liability as your enterprise customers start asking about it in security reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 5: How Do Audit Logs Surface, and Can Customers Access Theirs?
&lt;/h2&gt;

&lt;p&gt;Enterprise customers have two distinct audit log needs. Their security operations team wants to stream your authentication events into their SIEM (Splunk, Datadog, Microsoft Sentinel) in real time. Their IT admins want to be able to self-service investigate questions like "why did Jane's access break on Tuesday?" without filing a support ticket with your team.&lt;/p&gt;

&lt;p&gt;Both use cases require your SSO vendor to expose structured, per-tenant audit logs with a webhook stream and a queryable API. Many vendors offer logging but only expose it to your team, not to your customers' IT admins. This creates a support bottleneck: your engineering team becomes the first-line support for every IdP configuration question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; Per-tenant audit logs with: structured JSON format, webhook push with near-real-time delivery, paginated REST API for historical queries, at least 90-day retention (12 months is better), and the ability to expose logs to the customer's admin without requiring them to contact you. Ask specifically whether you can give your customers' IT admins read access to their own org's audit logs through your UI or API. Ask what SIEM integrations they've tested against.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 6: Can Customer IT Admins Configure SSO Themselves?
&lt;/h2&gt;

&lt;p&gt;This is the operational question that has the biggest day-to-day impact. When a customer IT admin needs to update their SSO connection (new IdP metadata, certificate rotation, attribute mapping change), do they open a ticket with your team, or do they do it themselves through a self-service admin portal?&lt;/p&gt;

&lt;p&gt;If the answer is "they open a ticket with your team," you've built a support dependency into your product for every future enterprise customer. Certificate rotations happen on 1-year cycles. Metadata updates happen when customers migrate to a new IdP version. These are routine operational tasks. They should not require your engineers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; A customer-facing admin portal (often called an "Admin Portal" or "IT admin dashboard") where the customer's IT team can configure their SSO connection independently. They should be able to paste new IdP metadata, update attribute mappings, test the connection, and deploy the change without any involvement from your team. Ask to see a screen recording of this flow. It should take under 15 minutes for a competent IT admin to complete.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 7: What Are Your SLAs and What Happens When You Miss Them?
&lt;/h2&gt;

&lt;p&gt;SSO is infrastructure, not a feature. When it goes down, nobody can log into your product. The SLA conversation matters, but the remediation conversation matters more. Many vendors offer 99.9% uptime SLAs with a credit model that gives you account credit if they miss it. Account credit for 4+ hours of downtime doesn't compensate for the enterprise customer who couldn't access your product during a critical business window.&lt;/p&gt;

&lt;p&gt;The questions to push on: Is the SLA measured on a monthly or quarterly basis (monthly is better for catching chronic issues)? Does the SLA cover planned maintenance windows? What is the incident response time when SSO is fully down? Is there a separate SLA for time-to-acknowledge versus time-to-resolve? Is there an uptime SLA for the customer self-service portal, or only for the authentication endpoints?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; 99.9% uptime minimum, with 99.99% available for enterprise tiers. An incident response time commitment of under 15 minutes for P0 (full outage) incidents. A status page that shows per-component status, not just a global status indicator. Ask to see the post-mortem from their last significant incident. How they respond to that question tells you more than the SLA number does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 8: Where Is Authentication Data Stored and Can We Specify a Region?
&lt;/h2&gt;

&lt;p&gt;Data residency is a procurement blocker in healthcare (HIPAA), financial services (SOX, PCI DSS), and any customer operating in the European Union (GDPR Article 46). If your enterprise customer's employees' authentication events are logged in US-East datacenters and the customer is required by law to keep user data in EU infrastructure, you have a compliance problem that no SLA can fix.&lt;/p&gt;

&lt;p&gt;The questions to push on: In which cloud regions are authentication events logged? Can customers request that their data stay in a specific region? Is there an additional cost for data residency controls? What certifications does the vendor hold: SOC 2 Type 2, ISO 27001, HIPAA BAA?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; Multi-region support with customer-selectable data residency for at least US and EU regions. SOC 2 Type 2 certification with the audit report available for review. HIPAA BAA available for healthcare customers. ISO 27001 certification for customers in regulated European markets. If the vendor can't provide a signed HIPAA BAA, they're excluded from your healthcare customer segment regardless of their other capabilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 9: What Does Migration Away From Your Platform Look Like?
&lt;/h2&gt;

&lt;p&gt;Nobody starts a vendor relationship planning to end it. But SSO vendors can become tightly coupled to your application architecture in ways that make migration painful and expensive. The migration question is really a vendor lock-in assessment.&lt;/p&gt;

&lt;p&gt;What data does the vendor hold that you'd need to export? (User account records, SSO connection configurations, IdP metadata, audit log history.) In what format can that data be exported? How long does a complete export take? Is there an API for it? Does the vendor have a migration guide for moving to another platform, or do they only help with migration in?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; Complete data portability: all connection configurations exportable as standard formats (SAML metadata files, OIDC client configurations), user records exportable as SCIM-compliant JSON, audit logs exportable via API with no retention lock-in. Migration should be a documented process, not a negotiation. Vendors who make migration easy earn more trust, not less. &lt;a href="https://ssojet.com/blog/best-enterprise-sso-platforms-for-startups-in-2026-technical-guide-comparison" rel="noopener noreferrer"&gt;SSOJet's architecture&lt;/a&gt; layers SSO on top of your existing auth stack rather than replacing it, which structurally reduces migration risk: your existing authentication keeps working if you remove the SSO layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 10: How Transparent Is Your Roadmap and How Do Customers Influence It?
&lt;/h2&gt;

&lt;p&gt;Authentication requirements change. OAuth 2.1 will become finalized. New IdPs will emerge. Enterprise customers will request IdP support for systems you've never heard of. New agent authentication patterns will become procurement requirements. The vendor you pick today needs to be the vendor who supports the requirements your customers will have in 18 months.&lt;/p&gt;

&lt;p&gt;The questions to push on: Is there a public roadmap? How often is it updated? What was the last feature shipped based on direct customer feedback? Is there a customer advisory board or formal feedback process? How do you handle requests for new IdP support?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; A public or customer-accessible roadmap with quarterly updates. A formal channel for feature requests with visibility into request status. At least one example of a feature that shipped because multiple customers asked for it. An explicit statement on their OAuth 2.1, MCP authentication, and FIDO2/passkeys roadmap, since those are the protocols procurement teams will ask about in 2026 and 2027.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 11: Can You Provide Reference Customers at Our Scale?
&lt;/h2&gt;

&lt;p&gt;Reference calls are the most underused due diligence tool in software procurement. Ask for two or three reference customers who are B2B SaaS companies at a similar stage (number of enterprise customers, similar industry vertical, similar IdP mix). Then actually call them.&lt;/p&gt;

&lt;p&gt;The questions to ask references: How long did your initial integration take? How many support tickets have you filed in the last six months? What's been the most painful part of using this vendor? Have you ever had a customer report that their IdP wasn't supported? How responsive is their support team when something breaks in production?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; Three references provided without hesitation, at similar scale and in similar industries. Vendors who take more than a week to produce references, or who can only produce references from much larger or much smaller companies, are probably managing a reference pool that doesn't reflect your use case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Question 12: What Happens to Pricing If We Want to Include SSO in All Plans?
&lt;/h2&gt;

&lt;p&gt;This is the strategic question. Many B2B SaaS companies want to offer SSO as a standard feature across all pricing tiers rather than gating it as a premium add-on. The ability to do this depends almost entirely on the vendor's pricing model.&lt;/p&gt;

&lt;p&gt;Under per-connection pricing at $125/connection, including SSO in your $99/month plan means authentication costs exceed plan revenue at a single enterprise customer. That's not a business model. Under connection-based pricing with a flat fee for up to N connections, the math changes. Under a per-tenant model with volume discounts, you might break even on authentication for self-serve customers and profit on the economics of large enterprise accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good answer looks like:&lt;/strong&gt; A pricing structure that makes it economically viable to include SSO in your base plan, or at least in your growth plan, rather than forcing you to charge premium prices for authentication. Ask directly: "If we wanted to include SSO for all customers at our $X/month plan, what would our authentication costs be per customer at 100 customers?" Then check whether the number is smaller than your plan price. If it isn't, you're going to gate SSO forever and compete at a disadvantage against vendors who don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  How SSOJet Answers These Questions
&lt;/h2&gt;

&lt;p&gt;The questions above are designed to be hard to answer well. Most vendors will hedge on pricing projections, avoid committing on roadmap items, and produce reference customers slowly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ssojet.com/" rel="noopener noreferrer"&gt;SSOJet&lt;/a&gt; is built specifically for B2B SaaS companies moving upmarket. Connection-based pricing that doesn't penalize you for signing large enterprise customers. Support for 100+ identity providers through SAML 2.0, OIDC, and SCIM 2.0. A customer-facing Admin Portal so your customers' IT teams configure their own SSO connections. Structured audit logs with webhook streaming. SOC 2 Type 2 and ISO 27001 certifications. An architecture that layers on top of your existing auth stack rather than requiring a migration. And a pricing model that makes it economically viable to include SSO in your standard plans rather than treating it as a premium feature that only large enterprise customers can access.&lt;/p&gt;

&lt;p&gt;If you're running through this evaluation right now, the &lt;a href="https://ssojet.com/blog/b2b-authentication-provider-comparison-features-pricing-sso-support" rel="noopener noreferrer"&gt;authentication platform comparison&lt;/a&gt; covers the specific pricing numbers for SSOJet versus WorkOS, Auth0, and the other major providers, so you can run the cost models yourself before the first vendor call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Is the Difference Between Per-Connection and Per-User SSO Pricing?
&lt;/h3&gt;

&lt;p&gt;Per-connection pricing charges a flat fee for each enterprise customer's IdP integration, regardless of how many users that customer has. Per-user (MAU) pricing charges based on the total number of authenticated users. For B2B SaaS companies, per-connection pricing is usually more predictable because a 5,000-person enterprise customer costs the same as a 50-person one. Per-user pricing can produce surprising cost spikes when you land a large enterprise customer.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Questions Should I Ask an SSO Vendor About Security?
&lt;/h3&gt;

&lt;p&gt;The most important security questions are: Do you enforce PKCE for all OAuth 2.0 clients? Have you removed support for implicit flow? What is your default access token lifetime? What is your refresh token rotation policy? Do you support back-channel logout? What compliance certifications do you hold? Can you provide your SOC 2 Type 2 report? Each of these questions has a verifiable answer that reveals whether the vendor's security posture is genuine or marketing.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Many IdPs Should an SSO Vendor Support?
&lt;/h3&gt;

&lt;p&gt;At minimum, any vendor you consider should have verified, tested support for Okta, Microsoft Entra ID, Google Workspace, Ping Identity, OneLogin, and ADFS. Beyond those six, the more important question is the vendor's process for adding new IdP support. Customers will bring unusual configurations. The vendor who can handle a custom SAML configuration from a government IdP you've never heard of is more useful than one with a polished list of 20 logos that doesn't include your next customer's IdP.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Is the SSO Connection Trap and How Do I Avoid It?
&lt;/h3&gt;

&lt;p&gt;The connection trap is a pricing structure where cheap entry-level plans have hard caps on the number of SSO connections (typically 3-5), and exceeding those caps forces you into an enterprise contract that costs $10,000 or more per month. Auth0's B2B Professional plan is a well-documented example, forcing companies into enterprise pricing at their sixth SSO customer. Avoid it by asking vendors to explicitly confirm whether there are connection limits on any plan tier, and by modeling the cost at your projected enterprise customer count before signing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should SSO Be Included in All Plans or Gated as a Premium Feature?
&lt;/h3&gt;

&lt;p&gt;Including SSO in all plans is a competitive advantage if your authentication costs make it economically viable. Gating SSO as a premium feature means SMB and mid-market customers who want SSO either pay a premium or look for competitors who include it. The vendor's pricing model determines which option is feasible. If authentication costs exceed plan revenue at small customer sizes, you'll be forced to gate it. Choose a vendor whose pricing model gives you the option to include it broadly.&lt;/p&gt;

</description>
      <category>ssovendorcomparison</category>
      <category>questionstoaskssoven</category>
      <category>ssointegrationprovid</category>
      <category>ssopricingmodels</category>
    </item>
    <item>
      <title>Top 9 Passwordless Authentication Methods Ranked for B2B SaaS</title>
      <dc:creator>SSOJet</dc:creator>
      <pubDate>Mon, 04 May 2026 11:37:51 +0000</pubDate>
      <link>https://forem.com/ssojet/top-9-passwordless-authentication-methods-ranked-for-b2b-saas-1ai2</link>
      <guid>https://forem.com/ssojet/top-9-passwordless-authentication-methods-ranked-for-b2b-saas-1ai2</guid>
      <description>&lt;p&gt;&lt;em&gt;Not all passwordless is equal. Some methods are phishing-proof. Some are worse than passwords. Here is the honest ranking, scored across four dimensions every B2B product team needs to weigh.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Passwordless authentication covers a wide range of technologies, from "click the link in your email" (which is exactly as secure as your email inbox) to FIDO2 hardware keys bound to a physical chip that can't be exported, copied, or phished. Calling both "passwordless" and treating them as equivalent security upgrades is one of the more persistent mistakes product teams make when rolling out modern auth.&lt;/p&gt;

&lt;p&gt;The nine methods below are scored on four dimensions: UX (how friction-free the method is for end users), Security (resistance to phishing, interception, and replay attacks), Recoverability (what happens when the user loses their device or credential), and Enterprise Compatibility (whether the method survives a real enterprise security review). Each score is out of 5.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Scoring Framework
&lt;/h2&gt;

&lt;p&gt;Before the rankings: why these four dimensions and not just security?&lt;/p&gt;

&lt;p&gt;Because B2B SaaS products serve two masters. The first is the end user, who needs to log in quickly without support tickets. The second is the enterprise IT team, who needs to satisfy compliance requirements, audit logs, and IdP governance policies. A method that scores 5/5 on security but requires a hardware token for every login will get blocked by enterprise procurement if the rollout cost is prohibitive. A method that scores 5/5 on UX but fails to satisfy HIPAA audit requirements won't pass a healthcare customer's security review.&lt;/p&gt;

&lt;p&gt;The rankings reflect real-world product decisions, not theoretical security purity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 9: OTP via SMS
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 3/5 | Security: 1/5 | Recoverability: 4/5 | Enterprise: 2/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 10/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SMS OTP is the most deployed "passwordless" method and the one with the most documented vulnerabilities. The mechanism is simple: a six-digit code sent to the user's phone. The attack surface is also simple: SIM swapping, SS7 interception, and social engineering of carrier support teams.&lt;/p&gt;

&lt;p&gt;SIM swap attacks are not theoretical. The FBI's 2022 IC3 report documented over $68 million in losses attributed to SIM-swapping incidents. Carrier support teams can be socially engineered into transferring a phone number to an attacker's SIM with surprisingly little effort. Once the attacker has the number, every SMS OTP on every account tied to that number is compromised.&lt;/p&gt;

&lt;p&gt;CVE-2021-35393 and related SS7 protocol vulnerabilities documented by the German Federal Office for Information Security (BSI) in 2017 remain unpatched in many carrier networks because SS7 is infrastructure-level, not application-level. You cannot fix SS7 vulnerabilities by shipping a better app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; You're building a consumer app and SMS is the last resort fallback, not the primary factor. Never as the sole authentication method for accounts with any sensitive data access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; Your customers are in regulated industries. A SOC 2 auditor will flag SMS OTP as a weak factor. PCI DSS 4.0 does not accept SMS OTP as satisfying MFA requirements for cardholder data access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 8: OTP via Email
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 3/5 | Security: 2/5 | Recoverability: 4/5 | Enterprise: 2/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 11/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Email OTP is a marginal improvement over SMS OTP. The attack surface shifts from carrier infrastructure to email account security. For most enterprise users, their email is protected by their corporate IdP with MFA, which creates a chain: to get into your app, an attacker must first compromise the user's email. For consumer users, the email account may be protected only by a weak password.&lt;/p&gt;

&lt;p&gt;The security ceiling of email OTP is the security of the email inbox. This isn't inherently bad for enterprise use cases where email is an Okta or Entra ID protected account. But it means email OTP is never independently strong. It's as secure as whatever protects the email.&lt;/p&gt;

&lt;p&gt;The other operational reality: email deliverability. OTP codes with 60-second or 5-minute expiries create support tickets when SMTP delivery delays. In high-throughput transactional email environments, codes frequently arrive after the window closes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; As a fallback method when other options fail, or for low-sensitivity flows where email security is already enforced by an enterprise IdP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; You're the primary authentication path. And don't build your own OTP email infrastructure from scratch; the reliability requirements are underestimated by almost every team that tries.&lt;/p&gt;




&lt;h2&gt;
  
  
  Method 7: Magic Links
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 4/5 | Security: 2/5 | Recoverability: 4/5 | Enterprise: 2/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 12/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Magic links are beloved by consumer product designers because the UX is genuinely good. No code to type, no password to remember. Click the link in the email, you're in. The security story is the same as email OTP: the link is as secure as the email inbox it lands in.&lt;/p&gt;

&lt;p&gt;The additional attack vector with magic links versus email OTP is link interception. A magic link is a URL. URLs get logged in proxy servers, leaked through Referer headers if the link contains a redirect, captured by email security scanners that pre-fetch URLs to check for phishing, and occasionally followed by corporate email gateways, which can consume the link and invalidate it before the user sees the email.&lt;/p&gt;

&lt;p&gt;Slack, Notion, and several other B2B products have shipped magic link authentication. It works fine for their use cases. But none of them would accept magic link authentication as sufficient for an enterprise customer's admin portal without additional factors.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; Initial onboarding flows, low-sensitivity account recovery, consumer apps where the user base doesn't have corporate email protection requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; Any context where the email routing path includes aggressive link-scanning proxies (common in enterprise environments). Also avoid as the sole factor for anything sensitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 6: TOTP (Time-Based One-Time Password)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 3/5 | Security: 3/5 | Recoverability: 2/5 | Enterprise: 4/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 12/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;TOTP is the six-digit code from Google Authenticator, Authy, 1Password, or any RFC 6238-compliant authenticator app. It's a significant security upgrade over SMS or email OTP because there's no network carrier involved and the shared secret lives in the app rather than being transmitted.&lt;/p&gt;

&lt;p&gt;The security limitations are real but bounded. TOTP codes are phishable: a real-time phishing attack can capture a TOTP code as the user enters it on a fake site and replay it on the real site before it expires. This is exactly the attack that bypasses TOTP in most AiTM (Adversary-in-the-Middle) phishing kits. It's also why TOTP doesn't satisfy FIDO2-level phishing resistance requirements.&lt;/p&gt;

&lt;p&gt;The bigger operational problem is recoverability. When a user loses their phone with their TOTP app, recovery requires manual intervention: an admin action, backup codes (which users frequently don't save), or account recovery flows that are often weaker than the factor they're replacing. This generates real support ticket volume at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; You need a second factor that's compatible with almost every enterprise IdP, passes most SOC 2 audits, and doesn't require hardware procurement. Most enterprise buyers accept TOTP as a compliant MFA method.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; Your threat model includes targeted phishing of specific users. For admin accounts or high-privilege access, TOTP is not phishing-proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 5: Push Authentication
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 4/5 | Security: 3/5 | Recoverability: 3/5 | Enterprise: 4/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 14/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Push authentication (Okta Verify, Microsoft Authenticator push, Duo Push) sends a notification to a registered mobile app. The user taps Approve or Deny. It's fast, requires no code typing, and the UX score reflects that actual users like it. Enterprise acceptance is good because most major IdPs support it natively.&lt;/p&gt;

&lt;p&gt;The known vulnerability is MFA fatigue (also called push bombing). An attacker with the user's credentials repeatedly triggers push notifications, hoping the user will eventually tap Approve out of annoyance or confusion. This attack vector was used in the Uber breach in September 2022: an attacker flooded a contractor's phone with Duo push notifications at 1am until the user accepted one. The contractor's VPN access, and eventually Uber's internal systems, were compromised.&lt;/p&gt;

&lt;p&gt;CVE-2022-36537 and the related Duo MFA bypass techniques documented by CISA in AA22-074A show that push auth without number matching or additional context is vulnerable to fatigue attacks. Number matching (where the user must type a specific number shown on the login screen into the authenticator app) closes the gap significantly. Both Okta and Microsoft now support number matching.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; Your users have smartphones and you need a good UX. Pair it with number matching, location context, and rate limiting on push requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; Number matching isn't available or isn't enforced. Plain push approval without additional context is the setup that gets companies breached.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 4: Hardware Security Keys (FIDO2)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 3/5 | Security: 5/5 | Recoverability: 2/5 | Enterprise: 4/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 14/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;YubiKey, Google Titan Key, and other FIDO2-certified hardware tokens are the most secure authentication method on this list. The private key is generated on the device's secure element and never exported. The credential is cryptographically bound to the origin domain. There is no phishable component. Even the most sophisticated real-time phishing kits, which bypass TOTP and push auth, cannot defeat a properly configured FIDO2 hardware key.&lt;/p&gt;

&lt;p&gt;The NIST 800-63B specification categorizes hardware FIDO2 keys as AAL3 (the highest Authentication Assurance Level). The US federal government mandates hardware key authentication for privileged access under Executive Order 14028. Google's internal deployment of security keys for all employees reportedly reduced successful phishing incidents to zero across their workforce.&lt;/p&gt;

&lt;p&gt;The limitations are entirely operational. A hardware key costs $25-$60 per employee. Keys get lost, left at home, damaged, forgotten. Recovery when a key is the only registered factor requires out-of-band identity verification. Enterprises deploying hardware keys universally need a procurement process, distribution logistics, and an IT helpdesk process for lost key incidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; Admin and privileged access in regulated industries, compliance-driven deployments where AAL3 is required, security-conscious enterprise customers who are willing to absorb the operational cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; You're targeting a broad user base where hardware procurement is impractical. The UX score of 3 reflects the real friction for non-technical users encountering a hardware key for the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 3: Passkeys / WebAuthn (Synced)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 5/5 | Security: 4/5 | Recoverability: 4/5 | Enterprise: 4/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 17/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Synced passkeys are the consumer-friendly implementation of FIDO2, backed by Apple iCloud Keychain, Google Password Manager, or Microsoft Authenticator. The private key is created on-device and synced encrypted across the user's devices. They're phishing-resistant by origin binding. They're 14x faster than a password plus MFA flow, per Microsoft's own benchmark (3 seconds vs 69 seconds). The FIDO Alliance's 2024 survey found that 53% of users have enabled passkeys on at least one account, which shows mainstream adoption is real.&lt;/p&gt;

&lt;p&gt;The security differential versus hardware keys is that synced passkeys exist in more than one place. The cloud sync means the key material is in an encrypted backup accessible from any of the user's devices. For most users, this is a feature. For high-security use cases (admin access to financial systems, privileged database accounts), the fact that the credential isn't hardware-bound is a gap.&lt;/p&gt;

&lt;p&gt;Enterprise compatibility is genuinely good now. Okta, Entra ID, and Google Workspace all support passkey authentication. Most enterprise browsers support WebAuthn. The main friction is enrollment: passkeys need to be registered per relying party, and enterprise rollouts need a clear enrollment flow and a recovery strategy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; Any new authentication flow targeting end users. This is the right default for 2025 and beyond for most B2B SaaS products that don't have specialized compliance requirements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; Your specific enterprise customer requires hardware attestation (device-bound, non-exportable credentials). For high-assurance tiers, pair passkeys with hardware key options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Device-Bound Certificates
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 3/5 | Security: 5/5 | Recoverability: 2/5 | Enterprise: 5/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 15/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mutual TLS (mTLS) with device-bound client certificates is the gold standard for service-to-service and privileged human authentication in high-security enterprise environments. The certificate is provisioned to a specific device, is hardware-bound to the device's TPM or secure enclave, and cannot be extracted. Authentication involves the device proving possession of the certificate private key through a cryptographic handshake that happens at the TLS layer, not the application layer.&lt;/p&gt;

&lt;p&gt;This is how zero-trust architectures like Google BeyondCorp handle device identity. The certificate lives on the device, the device registers with the organization's MDM, and access is granted based on both user identity and device compliance state simultaneously.&lt;/p&gt;

&lt;p&gt;The operational cost is high. Certificate lifecycle management (issuance, rotation, revocation, renewal) requires infrastructure: a private CA, an MDM, an enrollment process, and an admin team that understands certificate operations. Recoverability when a certificate is revoked or a device is lost requires re-enrollment workflows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; Your enterprise customers have device management infrastructure and require combined user plus device authentication for privileged access. Zero-trust architecture deployments.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Avoid it when:&lt;/strong&gt; Your users bring their own devices, your customer's IT team doesn't run MDM, or you need a method that works across unmanaged personal devices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 1: IdP-Delegated Passwordless (The B2B Winner)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;UX: 5/5 | Security: 5/5 | Recoverability: 5/5 | Enterprise: 5/5&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total: 20/20&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the method that most enterprise-focused B2B SaaS products should be implementing, but the one that gets the least attention in "how to add passwordless" tutorials. IdP-delegated passwordless means your application delegates the authentication decision entirely to the customer's enterprise IdP (Okta, Entra ID, Google Workspace, Ping Identity), which handles the passwordless method on its end.&lt;/p&gt;

&lt;p&gt;Here's why this wins on every dimension. Security: the customer's IdP enforces whatever passwordless method their security team has chosen, with their phishing-resistant authenticator configured, with their device compliance policies applied, with their conditional access rules governing when step-up auth is required. You don't implement the authentication logic at all; you consume a signed SAML assertion or OIDC token from an IdP that has already made the authentication decision. Recoverability: the customer's IT team owns credential recovery. Their helpdesk knows the process. Their admin tools handle account recovery. Your application's only job is to trust the IdP's assertion. Enterprise compatibility: perfect, because the enterprise IT team controls the entire flow. UX: the user authenticates using the same method they use for every other enterprise application, with no new enrollment step and no new credential to manage.&lt;/p&gt;

&lt;p&gt;The catch is that this only works for enterprise customers using corporate IdPs. It doesn't help for SMB customers who don't have an Okta or Entra ID deployment. The practical architecture: implement SSO via SAML and OIDC for your enterprise tier, and implement passkeys or TOTP as the fallback for customers without a corporate IdP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use it when:&lt;/strong&gt; You're targeting enterprise or mid-market B2B customers. Always.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recommended Method by Customer Segment
&lt;/h2&gt;

&lt;p&gt;The right answer depends on who you're selling to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enterprise customers (with corporate IdP):&lt;/strong&gt; IdP-delegated passwordless via SAML or OIDC. Let their IT team enforce passkeys, FIDO2, or push auth on their end. Your app trusts the assertion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mid-market customers (mixed IdP adoption):&lt;/strong&gt; SSO via OIDC for customers with IdP, plus synced passkeys as the primary factor for customers without IdP. TOTP as fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMB and self-serve customers:&lt;/strong&gt; Synced passkeys as primary, with magic links or email OTP as fallback. Remove SMS OTP from the primary path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Admin and privileged access (any tier):&lt;/strong&gt; Hardware FIDO2 keys or device-bound certificates. The operational cost is worth it for accounts that can access billing, user management, or SSO configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer API access:&lt;/strong&gt; Short-lived OAuth tokens with PKCE. Not a "passwordless" method per se, but the correct credential type for programmatic access, and it replaces long-lived API keys that are functionally equivalent to passwords.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Not to Ship: The "Passwordless" Methods With Real CVEs
&lt;/h2&gt;

&lt;p&gt;A few things get called passwordless but don't deserve the label without caveats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SMS OTP without rate limiting.&lt;/strong&gt; Beyond the SIM swap risk, unrated SMS OTP endpoints are a target for OTP enumeration attacks. An attacker triggers OTP requests to specific phone numbers to confirm account existence and harvest carrier metadata. CVE-2022-24721 (an OTP rate limiting bypass in a popular auth library) allowed attackers to brute-force codes without triggering lockouts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Magic links with long TTLs.&lt;/strong&gt; A magic link that doesn't expire for 24 hours is a credential with a 24-hour attack window. Magic links should expire in 10-15 minutes maximum. They should be single-use: clicking the link should immediately invalidate it on the server side. Magic link implementations that allow replay (clicking the same link twice) are implementing a sessionless bearer token with no expiry, which is a security regression from a password.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push auth without number matching.&lt;/strong&gt; As documented in CISA Advisory AA22-074A and the 2022 Uber breach post-mortem, plain push approval without contextual verification is vulnerable to MFA fatigue attacks. This is not hypothetical. It was used in a real breach of a major technology company. If you're shipping push auth, number matching is not optional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TOTP as the only second factor for admin accounts.&lt;/strong&gt; TOTP is phishable via real-time relay attacks. Evilginx2 and Modlishka are publicly available tools that demonstrate this attack. For admin accounts specifically, TOTP should be paired with a phishing-resistant primary method, not used as the sole factor.Implement Passwordless Without a Full Auth Rewrite&lt;/p&gt;

&lt;p&gt;The practical challenge for most B2B SaaS teams isn't understanding which method to use. It's implementing the winning method (IdP-delegated passwordless via SSO) without spending three months building SAML and OIDC from scratch, then another two months handling the Okta, Entra ID, and Google Workspace quirks that only show up in enterprise customers' actual deployments.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://ssojet.com/blog/how-to-implement-passwordless-authentication-without-replacing-your-existing-identity-stack" rel="noopener noreferrer"&gt;SSOJet's passwordless authentication implementation&lt;/a&gt; covers how to layer passkeys and FIDO2 over an existing identity stack without migrating off it. The approach: an orchestration layer sits between your application and your IdPs, handling the SAML/OIDC token exchange and surfacing the correct passwordless method based on the customer's IdP configuration. Your application calls one API. The customer's IdP handles the rest.&lt;/p&gt;

&lt;p&gt;For teams actively building enterprise auth and trying to understand which protocols to support first, &lt;a href="https://ssojet.com/blog/passwordless-authentication-enterprise-sso" rel="noopener noreferrer"&gt;SSOJet's enterprise passwordless overview&lt;/a&gt; covers the IdP integration patterns in detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What Is Passwordless Authentication in B2B SaaS?
&lt;/h3&gt;

&lt;p&gt;Passwordless authentication in B2B SaaS means verifying a user's identity without requiring them to enter a password. This includes methods ranging from email magic links and OTP codes to phishing-resistant FIDO2 passkeys and enterprise IdP delegation. The methods differ significantly in security strength, enterprise compatibility, and user experience. The right choice depends on your customer segment, compliance requirements, and whether your enterprise customers have corporate identity providers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are Passkeys Safe for Enterprise B2B Applications?
&lt;/h3&gt;

&lt;p&gt;Synced passkeys are phishing-resistant and significantly more secure than passwords or SMS OTP. They score 4/5 on security because the credential is synced to a cloud keychain rather than being hardware-bound. For most B2B applications, synced passkeys are appropriate. For privileged access in regulated industries (financial services, healthcare), device-bound FIDO2 credentials or hardware keys that score 5/5 on security are more appropriate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is SMS OTP Acceptable for Enterprise Authentication?
&lt;/h3&gt;

&lt;p&gt;No. SMS OTP is not acceptable as a primary authentication factor for enterprise B2B applications. It is vulnerable to SIM swapping (documented in FBI IC3 reports), SS7 interception at the carrier network level (BSI-documented, unpatched vulnerabilities), and social engineering of carrier support teams. PCI DSS 4.0 does not accept SMS OTP as satisfying MFA requirements for cardholder data access. If you need a fallback, email OTP is marginally safer because it eliminates the carrier attack surface.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Is IdP-Delegated Passwordless and Why Is It Best for B2B?
&lt;/h3&gt;

&lt;p&gt;IdP-delegated passwordless means your application delegates authentication entirely to the customer's enterprise identity provider (Okta, Entra ID, Google Workspace). The IdP handles the passwordless method including passkeys, FIDO2, or push auth with their security policies applied. Your application receives a signed OIDC or SAML token confirming successful authentication. This scores highest for B2B because it's the method enterprise IT teams can audit, manage, and enforce without any changes to your product's configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  What Should Replace SMS OTP in a B2B Auth Flow?
&lt;/h3&gt;

&lt;p&gt;For enterprise customers: IdP-delegated passwordless via SAML or OIDC (let their IdP handle authentication). For non-enterprise customers: synced passkeys as the primary factor, with TOTP or email OTP as fallback. For admin and privileged access: hardware FIDO2 keys. SMS OTP should be removed from the primary authentication path and, if kept at all, used only as a last-resort fallback with rate limiting, anomaly detection, and a clear migration roadmap away from it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Security Advisories and CVEs&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;CISA Advisory AA22-074A (MFA fatigue / push bombing): cisa.gov/news-events/cybersecurity-advisories/aa22-074a&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;FBI IC3 2022 Internet Crime Report (SIM swap): ic3.gov/Media/PDF/AnnualReport/2022_IC3Report.pdf&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;BSI SS7 vulnerability documentation (2017): bsi.bund.de&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Uber breach post-mortem (September 2022): uber.com/newsroom/security-update&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Standards and Specifications&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;NIST SP 800-63B (AAL levels): csrc.nist.gov/publications/detail/sp/800-63b/final&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;RFC 6238 (TOTP): datatracker.ietf.org/doc/html/rfc6238&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;WebAuthn Level 3 (W3C): w3.org/TR/webauthn-3/&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;FIDO2 overview: fidoalliance.org/fido2/&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Industry Data&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;FIDO Alliance Passkey Adoption Survey 2024: fidoalliance.org&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Microsoft passkey speed benchmark (3 vs 69 seconds): microsoft.com/security/blog&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>passwordlessauthenti</category>
      <category>passkeysforb2b</category>
      <category>fido2authenticatione</category>
      <category>magiclinkvspasskey</category>
    </item>
  </channel>
</rss>
