Single sign-on (SSO) across multiple Django applications means one login that is shared by several apps you own — a user signs in once and is recognised by every service in your estate, without a separate password per app. There is no single "Django SSO setting"; you pick an architecture based on whether your apps share a domain, who owns the identities, and how strict your security needs are.
There are four practical patterns:
- Run your own OAuth2/OIDC provider with
django-oauth-toolkit. One Django service becomes the identity provider (IdP); every other app is an OIDC client (usingmozilla-django-oidcor Authlib). This is the recommended default for most estates. - Shared session across subdomains — apps under one parent domain share a session cookie, a session store (Redis/DB), and the same
SECRET_KEY. Simple, but limited to one domain and a shared user database. - SAML SSO via
djangosaml2/python3-saml— when an enterprise IdP (Okta, Entra ID, ADFS) mandates SAML 2.0. - Token/JWT sharing — a central service issues signed JWTs that API-style services validate independently. Good for SPA/microservice estates.
If you would rather not run an identity provider at all and prefer a hosted external IdP, see our companion guide on single sign-on in Django with Auth0 (OIDC). This post focuses on SSO between several Django apps you control.
Compare the four approaches
| Approach | Best for | Cross-domain? | Single logout | Standards-based | Complexity |
|---|---|---|---|---|---|
OIDC provider (django-oauth-toolkit) |
Many owned apps, possibly on different domains | Yes | Yes (RP-initiated / back-channel) | Yes (OAuth2 + OIDC) | Medium |
| Shared session + cookie domain | A few apps on one parent domain sharing a DB | No (subdomains only) | Yes (clear the shared store) | No (Django-internal) | Low |
SAML (djangosaml2) |
An external/enterprise IdP that requires SAML | Yes | Yes (SLO) | Yes (SAML 2.0) | High |
JWT sharing (SimpleJWT) |
API / SPA / microservice estates | Yes | Hard (stateless) | Partial | Medium |
The OIDC-provider route is the most future-proof: it gives you real cross-domain SSO, standard tokens, refresh-token rotation, and single logout, while keeping every app loosely coupled. The shared-session trick is the quickest win but only when your apps already live on the same parent domain and share user records.
Approach 1 — Run your own OIDC provider with django-oauth-toolkit
Here one Django service plays the identity provider. It owns the users and exposes the standard OIDC endpoints (/authorize/, /token/, /userinfo/, plus discovery and JWKS). Every other app becomes an OIDC client that redirects users to the provider to log in. This is the same model Auth0 or Okta use — you are just hosting the IdP yourself.
First, generate an RSA key for signing ID tokens and install the toolkit:
# Generate an RSA private key used to sign OIDC ID tokens (RS256)
openssl genrsa -out oidc.key 4096
pip install "django-oauth-toolkit>=3.0"Then enable the OIDC provider in the IdP project's settings.py:
# settings.py (identity-provider service)
import os
INSTALLED_APPS = [
# ...
"django.contrib.auth",
"django.contrib.contenttypes",
"oauth2_provider",
]
OAUTH2_PROVIDER = {
# Turn on OpenID Connect on top of OAuth2
"OIDC_ENABLED": True,
"OIDC_RSA_PRIVATE_KEY": os.environ["OIDC_RSA_PRIVATE_KEY"], # contents of oidc.key
"PKCE_REQUIRED": True, # require PKCE for every client
"SCOPES": {
"openid": "OpenID Connect scope",
"profile": "User profile",
"email": "User email address",
},
"ACCESS_TOKEN_EXPIRE_SECONDS": 3600, # 1 hour
"REFRESH_TOKEN_EXPIRE_SECONDS": 14 * 86400, # 14 days
"ROTATE_REFRESH_TOKEN": True, # rotate on every refresh
}Wire up the endpoints. Mounting the toolkit's URLs under o/ exposes /o/authorize/, /o/token/, /o/userinfo/, /o/.well-known/openid-configuration/, and /o/.well-known/jwks.json:
# urls.py (identity-provider service)
from django.urls import path, include
urlpatterns = [
path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")),
# ... your own login view / admin etc.
]Run migrations, then register each client app as an OAuth2 Application. You can do this in the Django admin at /o/applications/ or from the shell. Copy the generated client_secret immediately — recent versions hash it at rest:
python manage.py migrate
# python manage.py shell
from oauth2_provider.models import Application
app = Application.objects.create(
name="Billing App",
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
redirect_uris="https://billing.example.com/oidc/callback/",
algorithm=Application.RS256_ALGORITHM, # sign ID tokens with RS256
)
print(app.client_id, app.client_secret) # store these in the client app's envWire a client app to your provider (mozilla-django-oidc)
On each client app, point mozilla-django-oidc at the provider you just stood up. The client never sees passwords — it only exchanges an authorization code for tokens and reads the user profile.
# settings.py (a client app)
import os
INSTALLED_APPS = ["mozilla_django_oidc", "..."]
AUTHENTICATION_BACKENDS = [
"mozilla_django_oidc.auth.OIDCAuthenticationBackend",
"django.contrib.auth.backends.ModelBackend",
]
OIDC_RP_CLIENT_ID = os.environ["OIDC_RP_CLIENT_ID"]
OIDC_RP_CLIENT_SECRET = os.environ["OIDC_RP_CLIENT_SECRET"]
OIDC_RP_SIGN_ALGO = "RS256"
OIDC_USE_PKCE = True
IDP = "https://sso.example.com/o"
OIDC_OP_AUTHORIZATION_ENDPOINT = f"{IDP}/authorize/"
OIDC_OP_TOKEN_ENDPOINT = f"{IDP}/token/"
OIDC_OP_USER_ENDPOINT = f"{IDP}/userinfo/"
OIDC_OP_JWKS_ENDPOINT = f"{IDP}/.well-known/jwks.json"
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"# urls.py (a client app)
from django.urls import path, include
urlpatterns = [
path("oidc/", include("mozilla_django_oidc.urls")),
# ...
]
# In a template, the "Log in" link is just:
# <a href="{% url 'oidc_authentication_init' %}">Log in</a>Repeat the client config for every app, each with its own registered client_id/client_secret. Once they all trust the same provider, a user who logs into one app reaches the others without re-entering credentials — true cross-domain SSO. mozilla-django-oidc validates the ID token signature against the JWKS endpoint for you; Authlib is an equally good client library if you prefer explicit control of the flow.
Approach 2 — Shared session across subdomains
If all your apps live under one parent domain (app.example.com, billing.example.com) and can share a database, you can skip OAuth entirely and share Django's session cookie. Every app uses the same SECRET_KEY, the same session store, and a cookie scoped to the parent domain:
# settings.py — IDENTICAL across every app in the estate
import os
# The SAME secret in every app, or signed cookies won't validate across apps
SECRET_KEY = os.environ["SHARED_SECRET_KEY"]
# Leading dot => cookie is sent to every *.example.com subdomain
SESSION_COOKIE_DOMAIN = ".example.com"
SESSION_COOKIE_NAME = "estate_sessionid"
SESSION_COOKIE_SECURE = True # HTTPS only
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# CSRF must trust the sibling origins too
CSRF_COOKIE_DOMAIN = ".example.com"
CSRF_TRUSTED_ORIGINS = ["https://app.example.com", "https://billing.example.com"]
# One shared session store so every app reads the same sessions
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://sso-redis.internal:6379/0",
}
}Limits and risks — read these before choosing this route:
- Same domain only. A leading-dot cookie cannot cross to a different registrable domain, so this gives you no cross-domain SSO. It will not link
example.comandexample.org. - Shared user identity required. Each app resolves the session's
user_idagainst its ownauth_usertable, so the apps must share the user database (or carefully mirror identical user IDs). In practice this is really one logical Django project with multiple front doors. - Blast radius. A shared
SECRET_KEYplus shared store means a compromise in one app can forge sessions for all of them. Rotate the key carefully and keep the Redis/DB store private. - Logout is global. Flushing the shared session logs the user out of every app — usually desirable, but make it intentional.
It is the lowest-effort option and perfectly fine for a small cluster of internal subdomain apps backed by one database — just don't reach for it expecting real federated SSO.
Approach 3 — SAML SSO (when an enterprise IdP requires it)
If the identities live in an enterprise IdP (Okta, Microsoft Entra ID, ADFS) that mandates SAML 2.0, use djangosaml2 (built on pysaml2) or python3-saml. Your Django apps become SAML service providers; the IdP handles authentication and posts back a signed XML assertion. It is heavier than OIDC — it needs the xmlsec1 system binary and careful metadata/certificate config — so reach for it only when SAML is a hard requirement.
pip install djangosaml2 # requires the xmlsec1 system package
# settings.py (sketch)
INSTALLED_APPS += ["djangosaml2"]
AUTHENTICATION_BACKENDS = [
"djangosaml2.backends.Saml2Backend",
"django.contrib.auth.backends.ModelBackend",
]
SAML_CONFIG = {
"entityid": "https://app.example.com/saml2/metadata/",
"service": {"sp": {"allow_unsolicited": True, "want_assertions_signed": True}},
"metadata": {"remote": [{"url": "https://idp.example.com/metadata"}]},
# key_file / cert_file for signing, attribute_map for claim -> Django user mapping
}
# urls.py
# path("saml2/", include("djangosaml2.urls")),Approach 4 — Token/JWT sharing for APIs and SPAs
For an estate of API services or a JavaScript front end, a central auth service can issue signed JWTs (with djangorestframework-simplejwt) that every other service validates independently using the shared public key. Sign with RS256 so resource services only need the public key — they never hold the signing secret. The trade-off is logout: pure JWTs are stateless, so you cannot instantly revoke one. Mitigate with short access-token lifetimes (5–15 minutes), refresh-token rotation, and a token blocklist for forced revocation. For browser-based SSO across owned apps, the OIDC-provider route in Approach 1 is usually cleaner than raw JWT sharing.
Which approach should I choose?
- Multiple apps, possibly on different domains, you want it done right → run your own OIDC provider with
django-oauth-toolkit(Approach 1). It is the most flexible and standards-based, and it scales as you add apps. - A handful of internal apps under one parent domain sharing a database → shared sessions (Approach 2) are the fastest path.
- A customer or security team mandates SAML →
djangosaml2(Approach 3). - Mostly APIs / a SPA front end → JWT issuance and validation (Approach 4), ideally fronted by OIDC.
- You'd rather not host an IdP at all → use a hosted provider such as Auth0; see single sign-on in Django with Auth0.
Security best practices for Django SSO
- HTTPS everywhere. Tokens and session cookies must only travel over TLS. Set
SESSION_COOKIE_SECUREandCSRF_COOKIE_SECUREtoTrue. - Short access tokens, rotated refresh tokens. Keep access tokens to ~1 hour or less and enable
ROTATE_REFRESH_TOKENso a leaked refresh token is single-use. - Require PKCE for every OAuth client, even confidential ones.
- Lock down redirect URIs to exact, fully-qualified URLs — wildcards are an open-redirect risk.
- Rotate signing keys. Publish your RSA public keys via the JWKS endpoint so clients pick up rotation automatically; keep the private key in a secrets manager, never in source control.
- Plan single logout. Decide whether logging out of one app should end the shared session everywhere (RP-initiated logout in OIDC, store flush for shared sessions, SLO for SAML).
- Validate every token claim — issuer (
iss), audience (aud), expiry (exp), andnonce. Maintained client libraries do this for you; don't hand-roll JWT parsing.
We have built and maintained Django systems for 12+ years across 50+ delivered projects, including identity and SSO for multi-app Django estates. If you want help choosing and shipping the right model, our Django development services and broader custom software development teams do exactly this, and our AWS consulting team handles the Redis/secrets/key-rotation infrastructure behind it.
Frequently Asked Questions
What's the difference between SSO across my own apps and using Auth0?
With your own provider you host the identity layer yourself (Approach 1, django-oauth-toolkit) and keep full control of users and tokens. With Auth0 you outsource login, MFA, and federation to a hosted IdP and your apps just trust it. Both give real SSO; the choice is build-vs-buy. See our Django + Auth0 SSO guide for the hosted-IdP path.
Can the shared-session/subdomain trick work across different domains?
No. A session cookie scoped with SESSION_COOKIE_DOMAIN = ".example.com" is only sent to subdomains of that one registrable domain. It cannot link example.com to example.org. For cross-domain SSO you need a redirect-based protocol — OIDC (Approach 1) or SAML (Approach 3).
Do I need OAuth2 or OIDC for SSO between Django apps?
Use OIDC — it is the identity layer built on OAuth2 and returns a signed ID token describing the user, which is exactly what you need for login. django-oauth-toolkit speaks both; turn on OIDC_ENABLED and have your clients request the openid scope. Plain OAuth2 alone is for API authorization, not authentication.
How does single logout work across multiple apps?
It depends on the architecture. With OIDC, use RP-initiated (or back-channel) logout so signing out of one app ends the session at the provider. With shared sessions, flushing the shared store logs the user out everywhere at once. With SAML, use SAML Single Logout (SLO). Pure stateless JWTs can't be force-logged-out instantly — use short lifetimes plus a blocklist.
Is django-simple-sso still a good choice in 2026?
We don't recommend it for new projects. django-simple-sso is a niche, lightly maintained library with a custom token exchange rather than a standard protocol. For new Django estates, prefer a standards-based OIDC provider (django-oauth-toolkit + mozilla-django-oidc), which interoperates with any OIDC client and is far better documented and supported.
How do the apps share the same users?
With an OIDC provider, the IdP owns the canonical user; each client app provisions a local User on first login (just-in-time provisioning) keyed on a stable claim like sub or email. With shared sessions, the apps must share one user database so the session's user_id resolves consistently. Decide your source of truth early — the provider's directory — and map claims to local users on each app.