Django authentication via ECDSA digital signatures and PKCS#12 certificates
Project description
django-ecp-auth
Two-factor authentication for Django using ECDSA digital signatures. Plugs into any existing project via two mixins — no replacement of existing auth logic required.
pip install django-ecp-signer
What is this?
django-ecp-auth adds a digital signature layer on top of standard Django username/password authentication.
| Without package | With package |
|---|---|
| username + password | username + password + private key file |
Upon registration, the server generates an ECDSA key pair. The user downloads the private key as an encrypted .pem file (protected by a passphrase they choose) and the public certificate as a separate .pem file. On every login, JavaScript reads the private key file, decrypts it with the passphrase, signs a one-time server challenge, and submits the signature. The server verifies the signature against the stored public certificate.
The private key never leaves the user's machine. The server stores only the public certificate.
Key concepts
private_key.pem — downloaded once after registration. Encrypted with a passphrase (AES-256-CBC). Used client-side to sign the challenge. Never sent to the server.
certificate.pem — the public X.509 certificate. Stored in the database server-side. Also given to the user so they can re-upload it later (e.g. to a new server instance) without generating a new key pair.
Nonce — a random string generated by the server for each login attempt. Signed by the client, consumed atomically on the server (SELECT FOR UPDATE) after first use to prevent replay attacks.
Registration flow
- User submits the registration form —
username,password, andkey_password(passphrase for the private key). CreateViewcreates theUserobject as usual.ECPGenerateMixinruns automatically:- Generates an ECDSA P-256 key pair.
- Builds a self-signed X.509 certificate valid for 365 days.
- Saves only the public certificate to the database (
ECPCertificate). - Encrypts the private key with
key_passwordand stores both PEM strings in the session.
- Frontend offers two separate downloads:
GET /ecp/keys/?file=private_key→private_key.pem(encrypted, keep secret)GET /ecp/keys/?file=certificate→certificate.pem(public, safe to share)
Each file is cleared from the session individually after it is served.
Login flow
- The login page fetches a nonce:
GET /ecp/challenge/. - User fills in
usernameandpassword, selects theirprivate_key.pemfile, and enters theirkey_password. - JavaScript reads the file, decrypts the private key locally, signs the nonce (ECDSA SHA-256), then discards the key from memory. The key file and passphrase are never sent to the server.
- The form submits
username,password,nonce_id, andsignature. - The server runs two checks in sequence:
- Password check — standard Django
authenticate(username, password). - Signature check — fetches the certificate from the database, atomically consumes the nonce, and verifies the ECDSA signature.
- Password check — standard Django
- On success: session is opened, user is redirected.
Re-registering a certificate (BYOK)
A user can upload their saved certificate.pem to replace the one stored on the server — useful when moving to a new server instance, after a database reset, or when bringing a self-generated key pair:
POST /ecp/certificate/
Content-Type: multipart/form-data
certificate=<certificate.pem>
The server validates that the certificate is not expired and that its Common Name (CN) matches the username, then stores it. The user's existing private_key.pem continues to work for signing.
What is stored where
| Location | What |
|---|---|
| User's machine | private_key.pem (encrypted). Without it, login is impossible. |
| User's machine | certificate.pem (public). Needed only for re-registration. |
| Database | Hashed password, public certificate (ECPCertificate), one-time nonces (ECPNonce). No private key ever. |
Security
| Threat | Defense |
|---|---|
| Replay attack | Nonce consumed atomically (SELECT FOR UPDATE) after first use |
| Concurrent replay | Transaction lock prevents two requests from using the same nonce |
| Forged signature | ECDSA verification fails without the real private key |
| Stolen certificate | Server fetches cert from DB, not from client — cert alone is useless |
| Expired / not-yet-valid cert | Full validity window checked on every login |
| Stale nonce | Nonces expire after 5 minutes (configurable) |
| Stolen key file | Encrypted with AES-256-CBC — useless without the passphrase |
| Password stolen, no key file | Cannot produce a valid signature |
| Key file stolen, no passphrase | Cannot decrypt the private key |
| Nonce spam | Rate limit: 10 requests per minute per IP → 429 |
| Proxy IP bypass | Rate limiter reads X-Forwarded-For when present |
Setup
1. settings.py
INSTALLED_APPS = [
...
'ecp_auth',
]
AUTHENTICATION_BACKENDS = [
'ecp_auth.backends.ECPAuthenticationBackend',
]
# Optional: nonce lifetime (default 5 minutes)
from datetime import timedelta
NONCE_LIFETIME = timedelta(minutes=5)
2. urls.py
path("ecp/", include("ecp_auth.urls")),
3. views.py
from ecp_auth.mixins import ECPGenerateMixin, ECPLoginMixin
class RegisterView(ECPGenerateMixin, CreateView):
...
class LoginView(ECPLoginMixin, FormView):
...
ECPGenerateMixin reads the user from form.instance and must be used with CreateView. The registration form must include a key_password field — this passphrase encrypts the private key.
ECPLoginMixin expects the login form to have:
| Field | Description |
|---|---|
username |
User's username |
password |
User's password |
signature |
DER-encoded ECDSA signature of the nonce (bytes), produced client-side |
nonce_id |
Primary key of the nonce returned by /ecp/challenge/ |
4. Migrate
python manage.py migrate
Endpoints
| Method | URL | Auth | Description |
|---|---|---|---|
| GET | /ecp/challenge/ |
No | Returns a one-time nonce. Rate limited to 10 req/min per IP. |
| GET | /ecp/keys/ |
Yes | Returns private_key and certificate as JSON. One-time. |
| GET | /ecp/keys/?file=private_key |
Yes | Downloads private_key.pem. Clears only the key from session. |
| GET | /ecp/keys/?file=certificate |
Yes | Downloads certificate.pem. Clears only the cert from session. |
| POST | /ecp/certificate/ |
Yes | Upload a custom certificate.pem to replace the stored one. |
Challenge response
{ "nonce": "3f8a1c...", "nonce_id": 42 }
Keys response (JSON)
{
"private_key": "-----BEGIN ENCRYPTED PRIVATE KEY-----\n...",
"certificate": "-----BEGIN CERTIFICATE-----\n..."
}
File download
Returns application/x-pem-file with Content-Disposition: attachment. Each file is cleared from the session only when it is served.
Certificate upload
Returns {"status": "ok"} on success, or {"error": "..."} with status 400 on failure.
Maintenance
Remove stale nonces periodically to prevent table bloat:
python manage.py cleanup_nonces
Deletes all nonces that are used or older than NONCE_LIFETIME. Safe to run via cron or Celery beat.
Exceptions
All exceptions inherit from ECPAuthError.
ECPAuthError
├── InvalidSignatureError
├── CertificateExpiredError
├── InvalidCertificateError
├── NonceExpiredError
└── NonceNotFoundError
Requirements
- Python >= 3.12
- Django >= 4.2
- cryptography >= 42.0
Running tests
pip install pytest pytest-django
pytest tests/ -v
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_ecp_signer-0.2.0.tar.gz.
File metadata
- Download URL: django_ecp_signer-0.2.0.tar.gz
- Upload date:
- Size: 22.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5020b7f631798d9e05fbb90bba74f6de087ee2a6ead83ab63537b36b015bf21d
|
|
| MD5 |
8c320cc827435d78cd4fc0650e2a403a
|
|
| BLAKE2b-256 |
f17dee05c394849290ecc2c9ac450869bab1f64a84c4047ab9ae1bdebebb8229
|
Provenance
The following attestation bundles were made for django_ecp_signer-0.2.0.tar.gz:
Publisher:
publish.yml on zakhkatya/django-ecp-signer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_ecp_signer-0.2.0.tar.gz -
Subject digest:
5020b7f631798d9e05fbb90bba74f6de087ee2a6ead83ab63537b36b015bf21d - Sigstore transparency entry: 1189045792
- Sigstore integration time:
-
Permalink:
zakhkatya/django-ecp-signer@d4bc7cfb4a53f51d130419e57d2c77050f87b269 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/zakhkatya
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d4bc7cfb4a53f51d130419e57d2c77050f87b269 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_ecp_signer-0.2.0-py3-none-any.whl.
File metadata
- Download URL: django_ecp_signer-0.2.0-py3-none-any.whl
- Upload date:
- Size: 23.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ace476ec9f503ebd89961e8daed7606bc3ffb0c68e0039d800d8e9edbf34202c
|
|
| MD5 |
3a7ee6c8f43d8261bb579487532b43f1
|
|
| BLAKE2b-256 |
92c8cda0b33f6998bfbba0078070313f4ecf659ea7dc79a5a01d191180ac0027
|
Provenance
The following attestation bundles were made for django_ecp_signer-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on zakhkatya/django-ecp-signer
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_ecp_signer-0.2.0-py3-none-any.whl -
Subject digest:
ace476ec9f503ebd89961e8daed7606bc3ffb0c68e0039d800d8e9edbf34202c - Sigstore transparency entry: 1189045805
- Sigstore integration time:
-
Permalink:
zakhkatya/django-ecp-signer@d4bc7cfb4a53f51d130419e57d2c77050f87b269 -
Branch / Tag:
refs/tags/v0.2.1 - Owner: https://github.com/zakhkatya
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d4bc7cfb4a53f51d130419e57d2c77050f87b269 -
Trigger Event:
push
-
Statement type: