DEV Community

SSOJet for SSOJet

Posted on • Originally published at ssojet.com on

Enterprise SSO in FastAPI: How to Add SAML and OIDC Auth to Python APIs in 2026

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.

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 python3-saml, 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.

Enterprise SSO in FastAPI: 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.

Key Takeaways

  • 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 httpx and python-jose handle natively in FastAPI.
  • SSOJet's hosted page handles enterprise IdP routing, SAML XML parsing, assertion validation, and claim normalization. Your FastAPI app never imports python3-saml or touches SAML XML.
  • PKCE (Proof Key for Code Exchange, RFC 7636) is enforced by SSOJet on all authorization code flows. Your FastAPI app generates the code_verifier and code_challenge pair and stores the verifier in the server-side session for callback verification.
  • JIT (just-in-time) user provisioning in the callback endpoint creates or updates SQLAlchemy user records from the normalized OIDC claims, scoped by (external_id, connection_id) to prevent multi-tenant account collisions.
  • FastAPI's Depends() system is the right place for session-based auth guards. A reusable get_current_user dependency keeps auth logic in one place rather than scattered across route handlers.
  • 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.

Why FastAPI Teams Use a Broker for Enterprise SSO

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.

python3-saml 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.

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.

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 B2B authentication provider comparison covers how SSOJet stacks up against Auth0, WorkOS, and Keycloak for multi-tenant B2B SaaS.

Choosing Your Approach: OIDC Hosted Page vs Direct SAML

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

Approach SAML Code in App Multi-Tenant Setup Time Python Library Needed
SSOJet OIDC hosted page (this guide) None Built-in Hours httpx, python-jose
python3-saml direct Yes DIY 1 to 2 weeks python3-saml, lxml
pysaml2 direct Yes DIY 1 to 2 weeks pysaml2
Auth0 Python SDK None Built-in 1 to 2 days authlib
Keycloak as SP broker None Built-in 3 to 5 days Keycloak ops overhead

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 best SSO and SCIM providers for B2B SaaS in 2026 covers all the major options in a ranked format.

Project Setup and Dependencies

You need Python 3.11+, FastAPI 0.110+, and no SAML libraries at all.

pip install fastapi uvicorn[standard] httpx python-jose[cryptography] \
    sqlalchemy alembic pydantic-settings itsdangerous python-multipart

Enter fullscreen mode Exit fullscreen mode

Project structure:

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

Enter fullscreen mode Exit fullscreen mode

config.py:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    ssojet_client_id: str
    ssojet_client_secret: str
    ssojet_redirect_uri: str
    ssojet_issuer: str = "https://auth.ssojet.com"
    ssojet_api_key: str
    session_secret_key: str
    database_url: str = "sqlite:///./app.db"

    class Config:
        env_file = ".env"

settings = Settings()

Enter fullscreen mode Exit fullscreen mode

.env:

SSOJET_CLIENT_ID=your_client_id
SSOJET_CLIENT_SECRET=your_client_secret
SSOJET_REDIRECT_URI=https://yourdomain.com/auth/callback
SSOJET_API_KEY=sk_live_xxx
SESSION_SECRET_KEY=a-minimum-32-character-random-string

Enter fullscreen mode Exit fullscreen mode

Never commit .env to source control. In production on Railway, Render, or AWS, use the platform's secret management. The SSOJET_REDIRECT_URI 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 secrets.token_urlsafe(32).

How SSOJet's OIDC Hosted Page Flow Works in FastAPI

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

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

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

Step 3: 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.

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

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

Your FastAPI code is only involved in steps 2 and 5. Everything else is SSOJet's responsibility.

The OIDC Module: PKCE, Authorization URL, and Code Exchange

# auth/oidc.py
import hashlib
import base64
import secrets
import httpx
from config import settings

def generate_pkce_pair() -> tuple[str, str]:
    """Generate a PKCE code_verifier and code_challenge (S256 method)."""
    verifier = secrets.token_urlsafe(32)
    digest = hashlib.sha256(verifier.encode()).digest()
    challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
    return verifier, challenge

def build_authorization_url(state: str, code_challenge: str) -> str:
    """Build the SSOJet hosted page authorization URL."""
    import urllib.parse
    params = {
        "response_type": "code",
        "client_id": settings.ssojet_client_id,
        "redirect_uri": settings.ssojet_redirect_uri,
        "scope": "openid profile email groups",
        "state": state,
        "code_challenge": code_challenge,
        "code_challenge_method": "S256",
    }
    return f"https://auth.ssojet.com/authorize?{urllib.parse.urlencode(params)}"

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

        userinfo_resp = await client.get(
            "https://auth.ssojet.com/userinfo",
            headers={"Authorization": f"Bearer {access_token}"},
        )
        userinfo_resp.raise_for_status()
        return userinfo_resp.json()

Enter fullscreen mode Exit fullscreen mode

PKCE with S256 is the only method SSOJet accepts, per OAuth 2.0 Security Best Current Practice (RFC 9700). The code_verifier is a cryptographically random string. The code_challenge 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.

The exchange_code function is async because FastAPI is async-first and httpx.AsyncClient 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.

The Session Module: Signing and Verifying State JWTs

# auth/session.py
import secrets
import time
from datetime import timedelta
from jose import jwt, JWTError
from config import settings

ALGORITHM = "HS256"
STATE_EXPIRY_SECONDS = 600 # 10 minutes: enough for hardware MFA flows

def sign_state(return_to: str = "/dashboard") -> str:
    """
    Create a signed state JWT for CSRF protection on the OIDC callback.
    RFC 6749 Section 10.12 requires verifying state to prevent CSRF attacks.
    """
    payload = {
        "nonce": secrets.token_urlsafe(16),
        "return_to": return_to,
        "exp": int(time.time()) + STATE_EXPIRY_SECONDS,
    }
    return jwt.encode(payload, settings.session_secret_key, algorithm=ALGORITHM)

def verify_state(token: str) -> dict:
    """Verify the state JWT. Raises JWTError on tampered or expired tokens."""
    return jwt.decode(
        token,
        settings.session_secret_key,
        algorithms=[ALGORITHM],
        options={"verify_exp": True},
    )

def create_session_token(user_id: int, tenant_id: str, email: str) -> str:
    """Create a signed session JWT stored in an httpOnly cookie."""
    payload = {
        "sub": str(user_id),
        "tenant_id": tenant_id,
        "email": email,
        "exp": int(time.time()) + int(timedelta(hours=8).total_seconds()),
    }
    return jwt.encode(payload, settings.session_secret_key, algorithm=ALGORITHM)

def decode_session_token(token: str) -> dict:
    """Decode and verify a session JWT. Raises JWTError on failure."""
    return jwt.decode(
        token,
        settings.session_secret_key,
        algorithms=[ALGORITHM],
    )

Enter fullscreen mode Exit fullscreen mode

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.

The Auth Router: Login, Callback, and Logout

# routers/auth.py
from fastapi import APIRouter, Request, Response, HTTPException, Depends
from fastapi.responses import RedirectResponse
from itsdangerous import URLSafeTimedSerializer, BadSignature
from jose import JWTError
from auth.oidc import generate_pkce_pair, build_authorization_url, exchange_code
from auth.session import sign_state, verify_state, create_session_token
from models.user import upsert_sso_user
from config import settings

router = APIRouter(prefix="/auth", tags=["auth"])

# Use itsdangerous for secure server-side session (stores PKCE verifier + state)
_signer = URLSafeTimedSerializer(settings.session_secret_key)
SESSION_COOKIE = "ssojet_session"
AUTH_STATE_COOKIE = "ssojet_auth_state"

@router.get("/login")
async def login(request: Request, return_to: str = "/dashboard"):
    """
    Initiate the SSOJet OIDC hosted page flow.
    Generates PKCE pair, signs state JWT, redirects to SSOJet's hosted page.
    """
    code_verifier, code_challenge = generate_pkce_pair()
    state = sign_state(return_to=return_to)

    # Store code_verifier in a short-lived, signed cookie.
    # It must survive the round trip to SSOJet and back.
    auth_state_data = _signer.dumps({"verifier": code_verifier, "state": state})

    authorization_url = build_authorization_url(
        state=state,
        code_challenge=code_challenge,
    )

    response = RedirectResponse(url=authorization_url, status_code=302)
    response.set_cookie(
        key=AUTH_STATE_COOKIE,
        value=auth_state_data,
        httponly=True,
        secure=True,
        samesite="lax", # Must be lax for cross-site OIDC redirect to work
        max_age=600,
    )
    return response

@router.get("/callback")
async def callback(
    request: Request,
    code: str | None = None,
    state: str | None = None,
    error: str | None = None,
    error_description: str | None = None,
):
    """
    Handle the OIDC authorization code callback from SSOJet.
    Verifies state, exchanges code, provisions user, creates session.
    """
    # Surface IdP-side errors gracefully
    if error:
        raise HTTPException(
            status_code=400,
            detail=f"SSO authentication failed: {error_description or error}",
        )

    if not code or not state:
        raise HTTPException(status_code=400, detail="Missing code or state parameter.")

    # Retrieve the PKCE verifier and expected state from the auth state cookie
    auth_state_cookie = request.cookies.get(AUTH_STATE_COOKIE)
    if not auth_state_cookie:
        raise HTTPException(
            status_code=400,
            detail="Auth state cookie missing. SSO session may have expired.",
        )

    try:
        auth_state = _signer.loads(auth_state_cookie, max_age=600)
    except BadSignature:
        raise HTTPException(status_code=400, detail="Invalid auth state cookie.")

    # Verify the state JWT to prevent CSRF (RFC 6749 Section 10.12)
    if auth_state["state"] != state:
        raise HTTPException(status_code=400, detail="State mismatch. Possible CSRF attempt.")

    try:
        state_claims = verify_state(state)
    except JWTError:
        raise HTTPException(status_code=400, detail="State token expired or invalid.")

    # Exchange the authorization code for a normalized user profile.
    # SSOJet has already validated the SAML assertion at this point.
    try:
        profile = await exchange_code(
            code=code,
            code_verifier=auth_state["verifier"],
        )
    except Exception as exc:
        raise HTTPException(
            status_code=502,
            detail=f"SSOJet code exchange failed: {exc}",
        )

    # JIT provisioning: create or update the user from OIDC token claims
    user = await upsert_sso_user(profile)

    return_to = state_claims.get("return_to", "/dashboard")

    session_token = create_session_token(
        user_id=user.id,
        tenant_id=user.connection_id,
        email=user.email,
    )

    response = RedirectResponse(url=return_to, status_code=302)

    # Clear the short-lived auth state cookie
    response.delete_cookie(AUTH_STATE_COOKIE)

    # Set the long-lived session cookie
    response.set_cookie(
        key=SESSION_COOKIE,
        value=session_token,
        httponly=True, # Not accessible to JavaScript
        secure=True, # HTTPS only
        samesite="lax", # Correct for post-login top-level navigation
        max_age=8 * 3600,
    )
    return response

@router.post("/logout")
async def logout(response: Response):
    """Clear the session cookie and redirect to home."""
    response = RedirectResponse(url="/", status_code=302)
    response.delete_cookie(SESSION_COOKIE)
    return response

Enter fullscreen mode Exit fullscreen mode

The samesite="lax" setting on both cookies is not negotiable. When SSOJet's hosted page redirects back to your /auth/callback, the browser performs a top-level cross-site navigation. lax allows cookies to be sent on these top-level navigations from external sites. strict 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.

The two-cookie pattern (short-lived ssojet_auth_state + long-lived ssojet_session) 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 itsdangerous and python-jose respectively, so tampering is detectable.

The Auth Dependency: Protecting FastAPI Routes

# auth/dependencies.py
from fastapi import Cookie, Depends, HTTPException, status
from jose import JWTError
from auth.session import decode_session_token
from models.user import get_user_by_id
from sqlalchemy.ext.asyncio import AsyncSession
from database import get_db

SESSION_COOKIE = "ssojet_session"

async def get_current_user(
    ssojet_session: str | None = Cookie(default=None, alias=SESSION_COOKIE),
    db: AsyncSession = Depends(get_db),
):
    """
    FastAPI dependency for session-based authentication.
    Inject into any route handler that requires an authenticated user.
    Usage: user = Depends(get_current_user)
    """
    if not ssojet_session:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Not authenticated. Visit /auth/login to sign in.",
            headers={"WWW-Authenticate": "Bearer"},
        )

    try:
        claims = decode_session_token(ssojet_session)
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Session expired. Please sign in again.",
        )

    user = await get_user_by_id(db, int(claims["sub"]))
    if user is None or not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="User account not found or deactivated.",
        )

    return user

async def require_admin(user=Depends(get_current_user)):
    """Dependency for admin-only routes."""
    if user.role != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Admin access required.",
        )
    return user

Enter fullscreen mode Exit fullscreen mode

FastAPI's Depends() system makes auth injection clean and composable. Any route that needs authentication adds user: User = Depends(get_current_user). Admin-only routes add user: User = Depends(require_admin). 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.

The User Model: JIT Provisioning With SQLAlchemy

# models/user.py
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ARRAY
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from database import Base
from datetime import datetime, timezone

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    external_id = Column(String, nullable=False)
    connection_id = Column(String, nullable=False) # SSOJet tenant identifier
    email = Column(String, nullable=False, index=True)
    first_name = Column(String, default="")
    last_name = Column(String, default="")
    role = Column(String, default="member")
    groups = Column(ARRAY(String), default=[])
    is_active = Column(Boolean, default=True)
    provisioned_at = Column(DateTime(timezone=True))

    # Composite unique constraint: prevents multi-tenant account collisions
    __table_args__ = (
        # UniqueConstraint imported separately in migration
        {"schema": None},
    )

async def upsert_sso_user(db: AsyncSession, profile: dict) -> User:
    """
    Create or update a user from SSOJet'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's
    IdP uses SAML or OIDC: sub, email, given_name, family_name, groups, connection_id.
    """
    external_id = profile["sub"]
    connection_id = profile.get("connection_id", "default")
    groups = profile.get("groups") or []

    result = await db.execute(
        select(User).where(
            User.external_id == external_id,
            User.connection_id == connection_id,
        )
    )
    user = result.scalar_one_or_none()

    if user is None:
        user = User(
            external_id=external_id,
            connection_id=connection_id,
        )
        db.add(user)

    user.email = profile.get("email", "")
    user.first_name = profile.get("given_name", "")
    user.last_name = profile.get("family_name", "")
    user.groups = groups
    user.role = _derive_role(groups)
    user.provisioned_at = datetime.now(timezone.utc)
    user.is_active = True

    await db.commit()
    await db.refresh(user)
    return user

def _derive_role(groups: list[str]) -> str:
    lowered = [g.lower() for g in groups]
    if "admins" in lowered:
        return "admin"
    if "billing" in lowered:
        return "billing"
    return "member"

async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
    result = await db.execute(select(User).where(User.id == user_id))
    return result.scalar_one_or_none()

Enter fullscreen mode Exit fullscreen mode

The composite lookup on (external_id, connection_id) 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 connection_id 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.

The Main Application: Wiring Everything Together

# main.py
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from routers.auth import router as auth_router
from routers.dashboard import router as dashboard_router
from auth.dependencies import get_current_user

app = FastAPI(
    title="My B2B SaaS API",
    docs_url="/docs",
    redoc_url=None,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://yourdomain.com"],
    allow_credentials=True, # Required for cookie-based auth
    allow_methods=["*"],
    allow_headers=["*"],
)

app.include_router(auth_router)
app.include_router(dashboard_router)

@app.get("/", response_class=HTMLResponse)
async def home():
    return """
    <html><body>
      <h1>Welcome</h1>
      <a href="/auth/login">Sign in with Enterprise SSO</a>
    </body></html>
    """

@app.get("/me")
async def me(user=Depends(get_current_user)):
    return {
        "id": user.id,
        "email": user.email,
        "role": user.role,
        "connection_id": user.connection_id,
    }

Enter fullscreen mode Exit fullscreen mode

allow_credentials=True in the CORS middleware is required for cookie-based authentication to work with cross-origin requests. Without it, browsers suppress the Set-Cookie 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.

Multi-Tenant SSO: Adding a Second Enterprise Customer

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.

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

The connection_id 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 connection_id to enforce tenant isolation at the database level. Understanding when SCIM adds value beyond SSO alone matters before your first enterprise security questionnaire. For deprovisioning support, the SCIM identity management guide covers exactly how OIDC SSO and SCIM work together.

Three Production Failure Modes for FastAPI OIDC SSO

These appear in staging or production, not on localhost. All three are one-line fixes.

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

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

Failure 3: Redirect URI mismatch on the token exchange. The redirect_uri 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 redirect_uri_mismatch error from SSOJet's token endpoint. Confirm the exact URI in your dashboard matches SSOJET_REDIRECT_URI in your environment variables. The SSOJet documentation covers redirect URI registration in the dashboard setup guide.

Compliance and Security for FastAPI SSO Apps

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.

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 upsert_sso_user equivalent marks the user inactive. The get_current_user dependency checks is_active on every request and returns a 401 for deprovisioned users. The SAML vs SCIM guide explains the protocol-level difference between authentication and provisioning.

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 SSOJet's pricing page for current plan details including the flat-rate model.

Testing the OIDC Flow in FastAPI

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 http://localhost:8000.

For automated tests using pytest and FastAPI's TestClient, override the get_current_user dependency:

# tests/conftest.py
import pytest
from fastapi.testclient import TestClient
from main import app
from auth.dependencies import get_current_user
from models.user import User

def make_test_user(**kwargs):
    user = User()
    user.id = kwargs.get("id", 1)
    user.email = kwargs.get("email", "dev@enterprise.com")
    user.role = kwargs.get("role", "member")
    user.connection_id = kwargs.get("connection_id", "conn_test_123")
    user.is_active = True
    return user

@pytest.fixture
def auth_client():
    """TestClient with a mock authenticated user."""
    test_user = make_test_user()

    app.dependency_overrides[get_current_user] = lambda: test_user
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()

@pytest.fixture
def admin_client():
    """TestClient with a mock admin user."""
    from auth.dependencies import require_admin
    test_admin = make_test_user(role="admin")

    app.dependency_overrides[get_current_user] = lambda: test_admin
    app.dependency_overrides[require_admin] = lambda: test_admin
    client = TestClient(app)
    yield client
    app.dependency_overrides.clear()


# tests/test_auth.py
from unittest.mock import AsyncMock, patch
from fastapi.testclient import TestClient
from main import app

def test_protected_route_requires_auth():
    client = TestClient(app, follow_redirects=False)
    resp = client.get("/dashboard")
    assert resp.status_code == 401

def test_protected_route_accessible_with_session(auth_client):
    resp = auth_client.get("/dashboard")
    assert resp.status_code == 200

def test_admin_route_forbidden_for_member(auth_client):
    resp = auth_client.get("/admin/users")
    assert resp.status_code == 403

def test_callback_creates_user_and_sets_session():
    with patch("routers.auth.exchange_code", new_callable=AsyncMock) as mock_exchange, \
         patch("routers.auth.upsert_sso_user", new_callable=AsyncMock) as mock_upsert:

        mock_exchange.return_value = {
            "sub": "okta-sub-001",
            "email": "dev@enterprise.com",
            "given_name": "Dev",
            "family_name": "User",
            "connection_id": "conn_test_123",
            "groups": ["engineering"],
        }

        mock_user = make_test_user(id=42)
        mock_upsert.return_value = mock_user

        from auth.session import sign_state
        from itsdangerous import URLSafeTimedSerializer
        from config import settings

        state = sign_state(return_to="/dashboard")
        signer = URLSafeTimedSerializer(settings.session_secret_key)
        auth_cookie = signer.dumps({"verifier": "test_verifier", "state": state})

        client = TestClient(app, follow_redirects=False)
        resp = client.get(
            "/auth/callback",
            params={"code": "test_code", "state": state},
            cookies={"ssojet_auth_state": auth_cookie},
        )

        assert resp.status_code == 302
        assert resp.headers["location"] == "/dashboard"
        assert "ssojet_session" in resp.cookies

Enter fullscreen mode Exit fullscreen mode

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

Frequently Asked Questions

Does FastAPI have built-in SAML or OIDC support I should use instead of SSOJet?

FastAPI has no built-in SSO support. The python-oauth2 and authlib 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 httpx and python-jose, which most Python web apps already include.

How does SSOJet route different enterprise customers to their own IdP in FastAPI?

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 profile 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.

Why does my FastAPI OIDC callback fail with a missing auth state cookie?

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

How do I deprovision an enterprise user when their IT admin removes them from Okta?

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 user.is_active = False. The get_current_user dependency checks is_active on every authenticated request and returns a 401 for deprovisioned users, ending their session on the next request.

Can I use SSOJet OIDC SSO with FastAPI and a separate React or Next.js frontend?

Yes, but you need three things configured correctly. First, set allow_credentials=True 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 credentials: "include" in every fetch or axios 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.

What OIDC claims does SSOJet return after enterprise IdP authentication in FastAPI?

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 sub (stable unique user ID), email, given_name, family_name, groups (array of IdP group names), and connection_id (SSOJet's identifier for the enterprise connection). Custom IdP attributes like department, employee ID, and cost center are available in rawAttributes for applications that need them for role mapping or compliance reporting.

Final Thoughts

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 httpx and python-jose, 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.

Start a 30-day free trial of SSOJet to ship your first FastAPI enterprise SSO login today. The SSOJet documentation has step-by-step connection setup guides for Okta, Azure AD, Google Workspace, and all other supported identity providers.

Top comments (0)