Skip to main content

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

  1. User submits the registration form — username, password, and key_password (passphrase for the private key).
  2. CreateView creates the User object as usual.
  3. ECPGenerateMixin runs 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_password and stores both PEM strings in the session.
  4. 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

  1. The login page fetches a nonce: GET /ecp/challenge/.
  2. User fills in username and password, selects their private_key.pem file, and enters their key_password.
  3. 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.
  4. The form submits username, password, nonce_id, and signature.
  5. 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.
  6. 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

Metadata

Release files for django-ecp-signer 0.2.0

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-ecp-signer 0.2.0
File Size Uploaded
django_ecp_signer-0.2.0.tar.gz 22.2 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-ecp-signer 0.2.0
File Interpreter ABI Platform
django_ecp_signer-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 45.4 kB

Release files / django_ecp_signer-0.2.0.tar.gz

Download URL django_ecp_signer-0.2.0.tar.gz
Size 22.2 kB
Tags Source
SHA-256 checksum
How to use checksums
5020b7f631798d9e05fbb90bba74f6de087ee2a6ead83ab63537b36b015bf21d
BLAKE2b-256 checksum
How to use checksums
f17dee05c394849290ecc2c9ac450869bab1f64a84c4047ab9ae1bdebebb8229
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

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 Mar 27, 2026.

Transparency log

Release files / django_ecp_signer-0.2.0-py3-none-any.whl

Download URL django_ecp_signer-0.2.0-py3-none-any.whl
Size 23.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ace476ec9f503ebd89961e8daed7606bc3ffb0c68e0039d800d8e9edbf34202c
BLAKE2b-256 checksum
How to use checksums
92c8cda0b33f6998bfbba0078070313f4ecf659ea7dc79a5a01d191180ac0027
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.7

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 Mar 27, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files

0.1.9

2 release files

0.1.7

2 release files

0.1.4

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

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