The headline of this release is first-class support for the Model Context Protocol (MCP)
authorization server role.
MCP's authorization spec is built on a stack of modern OAuth RFCs,
and this cycle landed the whole stack: Authorization Server Metadata (RFC 8414) and Protected
Resource Metadata (RFC 9728) for discovery, Dynamic Client Registration (RFC 7591 / RFC 7592) and
OAuth Client ID Metadata Documents (CIMD) so clients can register themselves, Resource Indicators
(RFC 8707) for audience-bound access tokens, and the OAuth 2.0 Security Best Current Practice
(RFC 9700) together with the RFC 9207 iss parameter. The RFC 9700 compliance gates double as a
configurable OAuth 2.1 security posture — they can reject the implicit and password grants and
enforce S256-only PKCE (legacy behavior by default in 3.4, scheduled to flip to compliant in 4.0).
The new ALLOW_LOCALHOST_LOOPBACK setting smooths the ephemeral-port loopback callback used by
native clients such as Claude Code, MCP Inspector, and mcp-remote.
Beyond MCP, the release adds a Django Ninja integration alongside the existing DRF support and
support for RP-Initiated Registration, lifts the 255-character cap on refresh tokens (mirroring the
access-token checksum scheme), makes cleartokens reclaim revoked refresh tokens sooner, and
harmonizes Bearer Authorization header parsing across the middleware.
It also carries a batch of security fixes: an unauthenticated open redirect from the
authorization endpoint (prompt=none), HS256 ID tokens being signed with the hashed client
secret, cleartext tokens and codes exposed in the Django admin, client secrets written to debug
logs, and predictable device-flow user_code generation. Longstanding operational bugs are fixed
too, including a multi-database migrate deadlock (#1591) and duplicate unique indexes that broke
fresh installs on Oracle and strict MySQL (#1656).
Before upgrading, read the breaking-changes section below: most items are makemigrations
steps for swapped models, but applications using the HS256 signing algorithm now require
hash_client_secret=False.
WARNING - POTENTIAL BREAKING CHANGES
- Applications using the
HS256signing algorithm must now be configured with
hash_client_secret=False. Previously such applications signed ID tokens with the hashed client
secret, producing tokens that relying parties could not verify.Application.clean()now raises a
ValidationErrorforHS256+hash_client_secret=True, andApplication.jwk_keyraises
ImproperlyConfiguredat signing time if the secret is hashed. To migrate an affected
application, recreate it (or reset its secret) withhash_client_secret=Falseso the plaintext
secret is stored and can be used as the shared HMAC key. - Changes to the
AbstractRefreshTokenmodel require doing amanage.py migrateafter upgrading. - If you use a swapped refresh token model (
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL) you will need to
update your custom model withmanage.py makemigrations. If your table already contains refresh
tokens you must also backfilltoken_checksumwith a data migration — adapt the batched backfill
loop fromforwards_funcin
oauth2_provider/migrations/0015_refreshtoken_token_checksum.py(dropping its swapped-model
guard, the early return, and resolving your own model instead) and keep the same operation order:
add nullable checksum → drop the old("token", "revoked")unique constraint → widentokento
TextField→ backfill → make checksum non-nullable → add the("token_checksum", "revoked")
unique constraint. - If you use a swapped application model (
OAUTH2_PROVIDER_APPLICATION_MODEL), run
manage.py makemigrationsafter upgrading:AbstractApplicationgained a
registration_sourceCharField(choicesmanual/dcr/cimd, defaultmanual) to mark
how an application was registered — for example via Dynamic Client Registration (#670). This
replaces the never-releaseddcr_createdBooleanField. Installs using the built-in Application
model just needmanage.py migrate(migration0019). - If you use a swapped application model (
OAUTH2_PROVIDER_APPLICATION_MODEL), run
manage.py makemigrationsafter upgrading: for CIMD (#1742)AbstractApplicationgained a
nullablecimd_expires_atDateTimeField, andclient_idwidened frommax_length=100to
255so a metadata-document URL fits. Installs using the built-in Application model just need
manage.py migrate(migration0020). - If you use a swapped device grant model (
OAUTH2_PROVIDER_DEVICE_GRANT_MODEL), run
manage.py makemigrationsafter upgrading: the redundant field-levelunique=Truewas removed
fromAbstractDeviceGrant.device_code(#1656), andAbstractDeviceGrant.scopechanged from
CharField(max_length=64, null=True)to a non-nullableTextField(blank=True)(#1693). When
prompted for a default for existing NULLscoperows, provide the one-off default""—
matchingoauth2_provider/migrations/0016_alter_devicegrant_scope.py. Uniqueness remains enforced by the
<app_label>_<class>_unique_device_codeconstraint. If you are doing a fresh install on
Oracle (or a MySQL backend that raises warnings as errors), you must also regenerate — or
hand-edit — your existingCreateModelmigration for the swapped model, since it still declares
both uniqueness rules and will fail the same way migration0013did. - If you use a swapped access token model (
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL) and have not
yet applied the0012_add_token_checksummigration (i.e. you are upgrading from a version
below 3.0), itstoken_checksumbackfill now deterministically skips the swapped model — the
schema operations in that migration never applied to swapped models, and the old backfill only
worked when the ordering of your app's migrations happened to allow it.migratelogs a warning
when the backfill is skipped and your table contains access tokens. Untiltoken_checksumis
backfilled those tokens will not validate; no data is lost, and tokens work again as soon as the
checksum is populated. To backfill, add a data migration to your app (ordered after your
migration that addstoken_checksum): adapt the batched backfill loop fromforwards_funcin
oauth2_provider/migrations/0012_add_token_checksum.py, dropping its swapped-model guard (the
early return) and resolving your own model instead. You can check for affected rows with
YourAccessToken.objects.filter(token_checksum__isnull=True).exists(). Installs that already
applied0012(any 3.x deployment) are unaffected.
Added
- #1373 Integration and docs for Django Ninja authentication
- #1546 Support for RP-Initiated Registration
- #1099 Add RFC 8414 OAuth 2.0 Authorization Server Metadata endpoint (
/.well-known/oauth-authorization-server) - #1743 Add RFC 9728 OAuth 2.0 Protected Resource Metadata endpoint (
/.well-known/oauth-protected-resource), plus opt-in
mixins/decorators (ProtectedResourceMetadataMixin,protected_resource_metadata) and a DRF authenticator
(OAuth2ProtectedResourceAuthentication) that advertise it via theresource_metadataWWW-Authenticatechallenge parameter - #1635 Dynamic help text on the application
client_secretfield, warning users to copy the
secret on creation and explaining it is hashed and unrecoverable when editing. The help text is
shared by both the Django admin application form and the front-end register/edit views: the
ApplicationAdminusesApplicationForm, and a sharedoauth2_provider/js/application_form.js
updates the text live as thehash_client_secretcheckbox is toggled on either surface. The
form also warns immediately when theHS256algorithm is selected while the client secret is —
or will be — hashed, instead of only surfacing the error on save. (#1697, #1740) - #670 Dynamic Client Registration Protocol (RFC 7591 / RFC 7592) —
DynamicClientRegistrationViewand
DynamicClientRegistrationManagementViewwith configurable permission classes and registration access
tokens. Dynamically registered applications are flagged withAbstractApplication.registration_source
set to"dcr"and can be filtered in the Django admin. - #1739
ALLOW_LOCALHOST_LOOPBACKsetting to extend the RFC 8252 §7.3 any-port loopback exemption tohttp://localhostredirect URIs (opt-in, defaultFalse) - #1742 Support for OAuth Client ID Metadata Documents (CIMD,
draft-ietf-oauth-client-id-metadata-document). A client may present anhttpsURL as its
client_id; whenCIMD_ENABLEDis on the server fetches, validates and persists the metadata
document as a public application (SSRF-hardened fetch, failure backoff and an in-flight fetch cap).
Applications resolved this way carryAbstractApplication.registration_sourceset to"cimd".
Registration can be gated withCIMD_REGISTRATION_PERMISSION_CLASSES(default allow-all;
HostAllowlistCIMDPermissionrestricts it toCIMD_ALLOWED_HOSTS), and the
clearcimdapplicationsmanagement command prunes expired CIMD applications that hold no live
tokens. Seedocs/cimd.rst. - #1751 Advertise the Dynamic Client Registration endpoint as
registration_endpointin the RFC 8414
authorization server metadata document whenDCR_ENABLEDis on - #1626 RFC 8707 "Resource Indicators" support
- clients can optionally specify
resourceparameter during authorization or access token requests - Resource binding stored in Grant, AccessToken and RefreshToken models
- Token introspection endpoint returns
audclaim for tokens with resource indicators
- clients can optionally specify
- #1749 RFC 9700 (OAuth 2.0 Security Best Current Practice) compliance
gates, each controlled by aCOMPLIANT_BCP_RFC9700_<topic>setting that defaults toFalse(current behavior,
warns when the discouraged behavior is used) and is scheduled to default toTruein 4.0 (enforces the
compliant behavior):COMPLIANT_BCP_RFC9700_IMPLICIT_GRANT(§2.1.2),
COMPLIANT_BCP_RFC9700_PASSWORD_GRANT(§2.4),COMPLIANT_BCP_RFC9700_PKCE_METHOD(§2.1.1),
COMPLIANT_BCP_RFC9700_ACCESS_TOKEN_TRANSPORT(§4.3.2),COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS(§4.4),
andCOMPLIANT_BCP_RFC9700_TOKEN_STORAGE(§4). Enforced behaviors are also removed from the RFC 8414
authorization-server metadata and the OIDC discovery document, so both stay consistent with what the
server accepts. - #1749 RFC 9207
issauthorization-response parameter and the
authorization_response_iss_parameter_supportedmetadata field (mix-up defense), gated by
COMPLIANT_BCP_RFC9700_AUTHZ_RESPONSE_ISS. - #1749 Config-validation gates for the RFC 9700 recommendations expressed through existing settings (the settings stay
canonical; the gate only sets validation severity — insecure value → check Warning while the gate isFalse,
check Error once it isTrue):COMPLIANT_BCP_RFC9700_REFRESH_TOKEN
(REFRESH_TOKEN_REUSE_PROTECTION, §4.14.2),COMPLIANT_BCP_RFC9700_REDIRECT_URI_SCHEME
(ALLOWED_REDIRECT_URI_SCHEMES, §2.1),COMPLIANT_BCP_RFC9700_REDIRECT_URI_MATCHING
(ALLOW_URI_WILDCARDS, §4.1.1), andCOMPLIANT_BCP_RFC9700_PKCE_REQUIRED(PKCE_REQUIRED, §2.1.1). - #1749 A
--deploysecurity system check that flags every RFC 9700 recommendation currently on a non-compliant value
(warningsoauth2_provider.W001–W010, errorsoauth2_provider.E002–E005when the corresponding
config-validation gate is enabled), plus an error (oauth2_provider.E001) for the incompatible combination of
hashed token storage and a non-zeroREFRESH_TOKEN_GRACE_PERIOD_SECONDS. - #1749 New
docs/security.rstpage mapping each RFC 9700 recommendation to the corresponding setting. The demo IdP
exposes every gate as anOAUTH2_PROVIDER_COMPLIANT_BCP_RFC9700_*environment variable so the Docker image and the
e2e suite can exercise both gate positions. - #1660 Extract the
HttpRequestcreation inOAuth2Validator.validate_userinto an overridable
build_http_requestmethod, so subclasses can pass extra attributes through to their authentication backends.
Changed
- #1732 Bearer
Authorizationheader parsing is now harmonized across the codebase via a shared
oauth2_provider.utils.parse_bearer_tokenhelper implementing RFC 7235 / RFC 6750 semantics.
As a result,OAuth2TokenMiddlewareandOAuth2ExtraTokenMiddlewarenow accept the scheme
case-insensitively (e.g. a lowercasebearerheader, which is RFC-correct, is no longer
ignored) and no longer mis-parse non-Bearer schemes that merely start withBearer
(e.g.BearerX tokenwas previously treated as a Bearer token and is now rejected). - #1688
cleartokensnow removes revoked refresh tokens onceREFRESH_TOKEN_GRACE_PERIOD_SECONDS
has passed, instead of keeping them untilREFRESH_TOKEN_EXPIRE_SECONDS. When
REFRESH_TOKEN_REUSE_PROTECTIONis enabled, revoked tokens are still kept until they expire so
that token reuse can be detected. - #1601
RefreshToken.tokenis now aTextFieldand lookups use a new SHA-256token_checksum
field, removing the 255 character limit so long refresh tokens (e.g. Microsoft's JWT refresh
tokens) are supported. This mirrors theAccessToken.token_checksumapproach introduced in 3.0.0
(#1447). The revocation endpoint also looks up access tokens by checksum now, restoring an indexed
lookup there. - #1652 The
0012_add_token_checksumbackfill now computes checksums in batchedbulk_update
calls (1000 rows per statement) instead of saving each access token individually, sharply
reducing how long the migration locks the access token table on large installations. Running
cleartokensbefore upgrading is still the best preparation for tables with many expired
tokens. See the warning above if you use a swapped access token model.
Deprecated
- #1749 Using the OAuth 2.0 implicit grant, the resource owner password credentials grant, the PKCE
plain
code_challenge_method, or an access token in the URI query string now emits aDeprecationWarning, per
RFC 9700. Each is gated by the corresponding
COMPLIANT_BCP_RFC9700_*setting, whose default is scheduled to flip toTrue(enforcing rejection) in 4.0. - #1700 Deprecate the
AUTHENTICATION_SERVER_EXP_TIME_ZONEsetting. Token introspectionexpvalues are
Unix timestamps and are always interpreted as UTC per RFC 7662/RFC 7519. The setting still works
for backwards compatibility but now emits aDeprecationWarningand will be removed in a future
release.
Fixed
- #1619 Accept wildcard
redirect_uriswhose hostname uses the double-dash form required for
Netlify deploy-preview URLs (https://*--sitename.netlify.app). The validator previously stripped
only a single leading hyphen after removing the*, leaving a hostname that began with-and was
rejected byURIValidator; it now strips up to two leading hyphens while rejecting longer runs. - #694
ReadWriteScopedResourceMixin.__new__()no longer forwards positional/keyword arguments to
object.__new__(), which raisedTypeError: object.__new__() takes exactly one argumentwhen
instantiating any view mixing this in with any argument at all — notably breaking Django REST
Framework'scls(**initkwargs)view instantiation. - #1006 A
client_idorusernamecontaining a NUL (\x00) byte no longer causes a 500 error
on database backends (e.g. PostgreSQL) that raiseValueErrorinstead of executing the query;
such values are now correctly treated as not matching any client/user. - #1738 Fix the
rw_protected_resourcedecorator accumulating the read/write scope on a shared list
across requests. The required-scope list was built once at decoration time and appended to on
every request, so after a write (POST) request thewritescope stayed in the list and a
subsequent read (GET) request with a read-only token was wrongly rejected. The behaviour was
request-order dependent, not thread-safe, and also mutated a caller-suppliedscopeslist. The
read/write scope is now added to a fresh per-request list. - #1693
AbstractDeviceGrant.scopeis now aTextField(blank=True)like the other grant and token
models, instead ofCharField(max_length=64, null=True). 64 characters is well below the limits
common in the broader OAuth ecosystem (Okta allows 1024, Google 2048), so longer scope strings
no longer fail or get truncated in the device authorization flow. Existing rows with a NULL
scope are backfilled to an empty string by migration0016. - #1593 Use
pkinstead ofidinclear_expired()andRefreshToken.revoke()so token models with a custom primary key field are supported. - #1594 Fix introspection token expiry handling to consistently use UTC and avoid the deprecated
datetime.utcfromtimestamp. - #1696 Fix
auth_timein oauth2 validator when user has never logged in. - #1603 Honor user-overridden
OIDC_SERVER_CLASSwhenOIDC_ENABLEDisTrueandOAUTH2_SERVER_CLASSis not explicitly set; previously only the default was used in this fallback path. - #1591 Fix
migratehanging on0012_add_token_checksumwhen a database router or
multi-database configuration is in use. TheRunPythondata migrations in0006and0012now
pin their queries toschema_editor.connection.alias, so the backfill runs on the connection
performing the migration instead of being routed to a second connection that deadlocks against
the migration transaction's own locks. This also makes both migrations backfill the correct
database when migrating a non-default alias (migrate --database=...). Thanks to Igor Petrik for
the diagnosis and fix. - #1656 Remove the redundant
unique=TrueonDeviceGrant.device_code, which duplicated the
unique_device_codeUniqueConstraintand created two identical unique indexes on the same
column. Fresh installs failed on Oracle (ORA-02261) and on MySQL backends that raise database
warnings as errors (ER_DUP_INDEX, 1831). Migration0013is fixed in place because the
duplicate was created insideCREATE TABLE, so a follow-up migration could never fix fresh
installs. Databases that already applied the old0013keep one harmless extra unique index;
it can optionally be dropped by hand. Thanks to Febin Micheal Antony (#1659) and
moscowmule2240 (#1718) for the fixes.
Security
- #1734 Generate device-flow
user_codevalues with the cryptographically securesecretsmodule
instead of the predictablerandommodule (Mersenne Twister). Theuser_codeis a device
authorization credential and must be unguessable per
RFC 8628 sections 5.1 and 5.2. - #1735 Stop writing client secrets to the logs. On a failed client authentication, the
OAuth2Validator
logged the submittedclient_secret(and, for Basic auth, the base64client_id:client_secret
credential string) atDEBUGlevel. These messages now log at most theclient_id(when it is
available; the base64/unicode decode-failure paths log a generic message with no credential), so
password-equivalent client secrets and raw credential strings no longer leak into log files or
aggregators. - #1736 Stop exposing cleartext access tokens, refresh tokens, and authorization codes in the Django
admin. The defaultAccessTokenAdmin,RefreshTokenAdmin, andGrantAdminclasses listed the
rawtoken/codeinlist_displayand included them insearch_fields. Because these values
are stored in cleartext, any staff user with view access saw replayable credentials, and
searching placed them in the?q=query string (captured by access logs and browser history).
The columns are now masked (last characters only) and are no longer searchable (search is
available by application and user instead). The rawtoken/codefield is also excluded from
the admin change/view form, which showed the editable cleartext field to any staff user with view
access; a masked read-only value is shown instead. Adding tokens/codes through the admin is now
disabled (has_add_permissionreturnsFalseon theAccessToken,RefreshToken,Grant, and
IDTokenadmins) — these are issued by the OAuth flows and are not meant to be hand-created, and
the add form would otherwise present an editable cleartext field. Relatedly, theAccessToken,
RefreshToken, andGrantmodel__str__methods no longer return the raw token/code (which the
admin renders in a row's change-page title and breadcrumbs, and which also appears inrepr()and
logs); they now return a"<Model> #<pk>"identifier. - #1737 Fix HS256-signed ID tokens being signed with the hashed client secret. When an application
used theHS256algorithm withhash_client_secret=True(the default), the ID token was signed
with the stored password-hash string as the HMAC key instead of the shared client secret, so a
relying party holding the real (plaintext) secret could never verify the signature — and a
password hash was misused as a MAC key.HS256now requireshash_client_secret=False:
Application.clean()rejects the combination, andjwk_keyraisesImproperlyConfigured
rather than emit an unverifiable token.HS256with an empty client secret is likewise rejected
(an empty HMAC key would make ID tokens trivially forgeable). See the breaking-changes note above. - #1719 Fix an unauthenticated open redirect from the authorization endpoint. A
prompt=nonerequest from
an unauthenticated user was redirected to the suppliedredirect_uriwith alogin_requirederror
before the client andredirect_uriwere validated, allowing an attacker to redirect a victim's
browser to an arbitrary origin. The request is now validated against a registered client before any
redirect, per OpenID Connect Core 1.0 section 3.1.2.6.
Reported by Brian Lee (SSLab, Georgia Tech).