Skip to main content

django-bastion

Enterprise SSO and identity governance for Django. Puts the admin behind your identity provider, turns the group claim into staff and superuser rights, and records who got what and when.

Version 0.1.3, and the configuration surface is now frozen. Every settings key, check id and audit event name is covered by a written deprecation policy: a renamed key is refused at startup with a message naming its replacement, and stays refused for two minor versions. What is not covered is everything else — module paths, function signatures, class layouts — so importing from bastion.protocols is holding something that moves.

Still pre-1.0, and the bus factor is 1. OIDC works end to end; SAML, SCIM and the rule engine do not exist, and the pages that mention them say so.

pip install django-bastion

Quickstart

Three edits. Assumes Microsoft Entra ID. Another provider is the same shape with a different issuer and a different provider name; the how-to pages cover the ones with quirks worth knowing.

# settings.py
INSTALLED_APPS = [
    "bastion",
    "bastion.admin.apps.BastionAdminConfig",   # replaces "django.contrib.admin"
    ...
]

AUTHENTICATION_BACKENDS = ["bastion.backends.SSOBackend"]

BASTION = {
    "CONNECTIONS": {
        "corp": {
            "provider": "entra",
            "issuer": "https://login.microsoftonline.com/<tenant-id>/v2.0",
            "client_id": env("BASTION_CLIENT_ID"),
            "client_secret": env("BASTION_CLIENT_SECRET"),
            "staff_groups": ["django-staff"],
            "superuser_groups": ["django-admins"],
        },
    },
    # require_mfa refuses admin access when the assertion showed one factor.
    # Off by default: confirm your provider emits `amr` with a real sign-in
    # first, because several make it opt-in.
    "ADMIN": {"connection": "corp", "require_mfa": True},
}
# urls.py
urlpatterns = [
    path("sso/", include("bastion.urls")),
    path("admin/", admin.site.urls),
]
$ python manage.py migrate
$ python manage.py bastion_doctor

bastion_doctor walks the path before anyone tries to use it. It reaches the provider for the discovery document, the JWKS and the signing algorithms, compares your clock against theirs, and works through the local half from the session engine to whether break-glass has anyone to alert.

Add --check-registration and it also asks the provider whether your callback URL is registered — one authorization request, no client secret, no token, because the flow is abandoned before the code is exchanged. redirect_uri_mismatch is the most common way an integration fails and the hardest to see from inside the app, where the settings are right and the provider still refuses.

Two things it still will not tell you are fine: whether the group claim is emitted and in what shape, and whether MFA will really be asserted. Neither can be known without a person completing a real login, so they come back marked unverifiable with the reason attached. A run that quietly skipped them would read better and help less. Most SSO debugging is a config typo three layers down, and a green tick over an unasked question is how you lose an afternoon to it.

That is the entire happy path. Everything below is optional.

What you get that you don't have today

The admin actually goes through SSO. Django's admin owns its own login view and ignores LOGIN_URL entirely — grep -rn LOGIN_URL django/contrib/admin/ returns nothing, in every version from 5.2 to main. Working around that is a known source of redirect loops and of the "authenticated but not staff" dead end. We subclass AdminSite (the documented seam) and fail with a real 403 page that tells the person which group they're missing and who to ask.

Groups map to roles, where your provider sends groups. Two lists per connection, staff_groups and superuser_groups, matched against the group claim. That is all of it. The ordered rule engine with a serializable condition tree is the 0.2 design and is not built yet; the roadmap says what it will look like and which two approaches were already rejected.

The qualifier is load-bearing. Google's ID token carries no group claim at all, so on a Google connection those two lists cannot match anything and roles are assigned locally instead. That is not a bug to be fixed by configuration, and finding it out after wiring everything up is the reason the provider matrix exists — it says what each profile gives you, and how far each has actually been proven.

An answer for the admins you already have. Keying accounts on (issuer, subject) and never on email is the right default and it strands every existing administrator behind a second account on their first sign-in. LINKING_POLICY = "verified_email_once" adopts the local account instead — once, only on an address the provider says is verified, only from a domain you pin, only where exactly one local account holds it and has no federated identity yet, and never onto a break-glass account. After that it pins to the subject like everything else. Both the adoption and every refusal to adopt are audited.

An audit log built for the people who will ask for it. Append-only, hash-chained, with the field set that NIST AU-3, PCI 10.2.2 and ISO 27002 8.15 independently converge on, plus a gapless sequence number so an exported sample can be shown to be complete.

A fake provider, in the package. Testing an SSO integration means producing assertions no real provider will produce on demand: an address the provider marks unverified, a group list truncated to a Graph pointer, a replayed state. bastion.testing ships a fake that does, with no certificate and no local HTTPS server — Connection takes its transport as a field, so the fake is injected rather than served.

def test_an_unverified_address_is_refused(client):
    rig = harness()
    with rig.installed():
        rig.login(client, email_verified=False)
    assert SESSION_KEY not in client.session

Testing your integration has the rest.

Advanced: break-glass

Off by default, and deliberately not part of the quickstart. It is an unauthenticated credential endpoint — the most sensitive surface this package has — and a deployment should arrive at it by deciding it needs one, not by following a getting-started page or by silencing a check.

When you do want one, the design is what you would want from an emergency route. Creating an account requires a written reason. Using one alerts a channel that does not depend on the identity provider, which matters because the outage that sent you there may be the provider. Both outcomes are recorded at critical severity, so a failed attempt is as visible as a successful one, and the account set can be restricted by network. The model refuses to delete the last active account, and there is a drill command, because an emergency account nobody has ever signed in with is an emergency account nobody knows works.

Most projects do not need it. A cloud console that can flip a flag, a shell on the box, or a second provider are all answers to "the IdP is down", and none of them adds a login route. If what you actually have is a password path serving a portal or an API rather than the admin, say so with ADMIN["local_login"] = "elsewhere" — that is the setting bastion.E023 is asking about, and enabling break-glass to satisfy that check is the one reason not to enable it.

The runbook covers setup, the drill, and what to do afterwards.

Why you might not want this

Worth reading before you adopt it.

  • If you only need social login, use django-allauth. It is mature, it has every provider, and this package is not trying to replace it.
  • If you're a B2B SaaS and can expense it, WorkOS or Stytch will get you enterprise SSO faster than building will. Check their current per-connection pricing rather than trusting a figure in someone's README. We're the better answer when self-hosting is a requirement rather than a preference, when the Django admin itself is the thing you need behind SSO, or when connection count makes per-connection pricing hurt.
  • If you need only OIDC and nothing else, mozilla-django-oidc is about 1,200 lines and you can read all of it in twenty minutes. That legibility is a real feature. Come here when you need the governance layer on top.
  • We are pre-1.0 and the bus factor is currently 1. That should disqualify us from anything you cannot afford to fork. See GOVERNANCE.md, which states this plainly rather than burying it.

Requirements

Python 3.11+, Django 5.2 LTS or newer. PostgreSQL is what we recommend in production. SQLite, MySQL and MariaDB all pass the full test suite on every push. Oracle is not supported. SUPPORT_MATRIX.md has the versions each backend was actually run against, and the version-dropping policy.

There are no [saml], [ldap] or [scim] extras. They were declared and shipped no modules, so installing one pulled in a protocol stack you had nothing to call. They come back one at a time, each with an implementation and a live tenant behind it.

Documentation

Not published to a site yet, but written and in the repository. Start at docs/index.md.

If you are evaluating rather than building, read these four in order: threat model, why you might not want this, data inventory, and the deployment checklist.

If you are integrating, your first login then the Entra guide.

Security

Report vulnerabilities privately through GitHub, not the issue tracker. SECURITY.md has the process, the response times we actually commit to, and an honest list of what we do not do.

Licence

Apache-2.0. Chosen for the patent grant rather than to match Django, because federated identity has enough patent history that the defensive-termination clause is worth having.

Metadata

Release files for django-bastion 0.1.3

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-bastion 0.1.3
File Size Uploaded
django_bastion-0.1.3.tar.gz 311.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-bastion 0.1.3
File Interpreter ABI Platform
django_bastion-0.1.3-py3-none-any.whl Python 3 none any Details

Total release size: 490.3 kB

Release files / django_bastion-0.1.3.tar.gz

Download URL django_bastion-0.1.3.tar.gz
Size 311.2 kB
Tags Source
SHA-256 checksum
How to use checksums
29672817f5d1ceefefe804cde764881b5014a9bf9274ea5a9628389f9e663a7d
BLAKE2b-256 checksum
How to use checksums
4f68c22f9423f4eac5db64ebd4ddec367d142fb5bbdcf20f772a44522c2cc03c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.

Transparency log

Release files / django_bastion-0.1.3-py3-none-any.whl

Download URL django_bastion-0.1.3-py3-none-any.whl
Size 179.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1cc0cdd202e0785b564d78a48ee60e5154892e96fbbfb6ea5a40fc06919e4844
BLAKE2b-256 checksum
How to use checksums
e7c9653f2501eba21c86b6dcd7c311794eb63c693702758904de364900f0fe33
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page