django-passkeys-admin
A passkey-only Django admin: staff sign in with a passkey and nothing else, while every other account keeps its password. Built on django-passkeys.
Requires Python 3.12 or newer and Django 5.2 LTS or newer. Every supported combination — Python 3.12/3.13/3.14 against Django 5.2/6.0/6.1 — is exercised by the test suite on every push.
The gap this fills
django-passkeys provides the WebAuthn ceremonies and an authentication backend
that accepts a password or a passkey. Pointing LOGIN_URL at a passkey page
changes which form staff see; it does not change which credential the backend
accepts, so a staff password keeps working everywhere it worked before.
django-otp-webauthn adds a passkey as a second factor, and django-allauth is a
whole account system. None of them takes passwords away from the admin.
This package does, and it covers what going passwordless then requires:
| Guarantee | How it holds |
|---|---|
| Passkey only, for staff only | The backend refuses a staff account its own correct password, and refuses a passkey to anyone who is not staff. |
| The session lasts as long as the passkey | Every admin request checks that the passkey which opened the session still exists and is enabled. Deleting a passkey shuts out every session it opened. |
| The first passkey | A passwordless admin cannot be entered to enrol the first passkey. manage.py enrol_passkey prints a signed, single-use link that grants a short session able to do exactly that and nothing else. |
| No way back in through a reset | A password reset form mixin withholds resets from staff accounts. |
| Never the last passkey | The management page refuses to delete the last passkey an account holds. |
| Rate limited | Starting an authentication ceremony is limited per client address. |
Installation
pip install django-passkeys-admin
Add both apps, the backend and the settings django-passkeys needs:
INSTALLED_APPS = [
...
"passkeys",
"passkeys_admin",
]
AUTHENTICATION_BACKENDS = ["passkeys_admin.backends.PasskeyOnlyStaffBackend"]
FIDO_SERVER_ID = "example.com"
FIDO_SERVER_NAME = "Example"
PASSKEYS_ALLOW_NO_PASSKEY_FIELD = True
FIDO_SERVER_ID is the WebAuthn relying party: the domain the admin is served
from, with no scheme or port. PASSKEYS_ALLOW_NO_PASSKEY_FIELD makes the
django-passkeys backend return nothing, rather than raise, when it is handed a
request carrying neither a password nor a passkey — which any other login form
in the project will eventually do.
Run the migrations, which create the django-passkeys table:
python manage.py migrate
Then make the admin site passkey-only. Either replace the default site:
# yourproject/apps.py
from django.contrib.admin.apps import AdminConfig
class PasskeyAdminConfig(AdminConfig):
default_site = "passkeys_admin.sites.PasskeyAdminSite"
INSTALLED_APPS = [
"yourproject.apps.PasskeyAdminConfig", # in place of "django.contrib.admin"
...,
]
or mix it into the site you already have:
from django.contrib import admin
from passkeys_admin.sites import PasskeyAdminMixin
class AdminSite(PasskeyAdminMixin, admin.AdminSite): ...
The passkey pages and the ceremony endpoints are the site's own URLs, under
passkeys/, so there is nothing to add to urlpatterns. Do not include
passkeys.urls: its views let any signed-in account register a passkey and
delete or disable its last one, which is what this package exists to prevent.
The first passkey
Nobody can sign in to a passkey-only admin before they have a passkey, so the first one is enrolled from a link:
python manage.py enrol_passkey alice --base-url https://example.com
The argument is whatever the user model's USERNAME_FIELD holds. The link is
valid for fifteen minutes and void as soon as it is followed, or as soon as the
account's passkeys change. It signs the user in to the passkey page alone: the
rest of the admin stays closed until they sign in with the passkey they have
just enrolled.
Usage
Linking the passkey page
Each staff member manages their passkeys at admin/passkeys/. To put it beside
the other user links, override userlinks in your admin/base_site.html:
{% block userlinks %}
<a href='{% url "admin:passkeys" %}'>{% translate "Passkeys" %}</a> /
{{ block.super }}
{% endblock userlinks %}
Password resets
Mix PasskeyOnlyPasswordResetMixin into every password reset form the project
offers. Without it, a staff account could still be handed a password it has no
use for in the admin, but that every other password login would accept.
from django.contrib.auth.forms import PasswordResetForm
from passkeys_admin.forms import PasskeyOnlyPasswordResetMixin
class ResetForm(PasskeyOnlyPasswordResetMixin, PasswordResetForm):
pass
Tests
A test that reaches the admin needs a session a passkey opened. force_login()
no longer is one, so the package ships a mixin that is:
from django.test import TestCase
from passkeys_admin.testing import PasskeyLoginMixin
class DashboardTests(PasskeyLoginMixin, TestCase):
def test_the_index_loads(self):
self.passkey_login(self.staff_user)
...
Settings
| Setting | Default | |
|---|---|---|
PASSKEYS_ADMIN_REQUIRED |
True |
False restores password logins for staff, for a development machine whose origin is not the deployed one. |
PASSKEYS_ADMIN_ENROLMENT_MAX_AGE |
900 |
Seconds an enrolment link, and the session it grants, stays valid. |
PASSKEYS_ADMIN_CEREMONY_RATE |
30 |
Authentication ceremonies one client address may start per window. |
PASSKEYS_ADMIN_CEREMONY_WINDOW |
60 |
Length of that window, in seconds. |
Notes
- Browsers. The ceremonies parse their options with
PublicKeyCredential.parseCreationOptionsFromJSON()andparseRequestOptionsFromJSON(), which every browser has shipped since Safari 18.4 (Chrome and Edge 129, Firefox 119). The browser's answer is serialised field by field rather than withtoJSON(), which some password manager extensions replace with an implementation that drops fields or throws. - Behind a proxy, the rate limit counts
REMOTE_ADDR, and the address there is the proxy's unless the server is told to trust its forwarding header —--forwarded-allow-ipsfor gunicorn and uvicorn. Otherwise every client shares one counter. The rate also needs a cache shared by the workers: with the default per-process cache, the effective rate is the configured one times the number of workers. - Manifest static storage. django-passkeys ships
bootstrap-toggle.min.jswith asourceMappingURLto a map it does not ship, whichManifestStaticFilesStoragerefuses atcollectstatic. This package uses none of django-passkeys' static files, butcollectstaticcollects them all the same. - One admin site.
enrol_passkeyreverses the link through theadminnamespace, which resolves to the site namedadmin, or to the only one there is.
License
MIT — see LICENSE.
Metadata
Release files for django-passkeys-admin 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| django_passkeys_admin-1.0.0.tar.gz | 342.4 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_passkeys_admin-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 361.9 kB
Release files / django_passkeys_admin-1.0.0.tar.gz
| Download URL | django_passkeys_admin-1.0.0.tar.gz |
|---|---|
| Size | 342.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
db31a0179e711a5cdbe22d31bb9b40dd8b120a1d3065e5a92ae2d37b5e34593b
|
|
BLAKE2b-256 checksum How to use checksums |
35860964d9ac0c40217b01261b0626f9f949cdd8660f46efe75b57b79730e117
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / django_passkeys_admin-1.0.0-py3-none-any.whl
| Download URL | django_passkeys_admin-1.0.0-py3-none-any.whl |
|---|---|
| Size | 19.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
790a5e56a3e11bf669819d877ab5ccd759c126690873639feff338a4d8132fdd
|
|
BLAKE2b-256 checksum How to use checksums |
e4e81fb31e973e9a8b779fb67ec68ba49df0d6ddd46c6902cda49bce65cc3fbe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.4 {"installer":{"name":"uv","version":"0.13.4","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for django-passkeys-admin 1.0.0
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.