Skip to main content

quart-security

quart-security is a native async authentication extension for Quart.

It is designed as a practical replacement path for Flask-Security style session auth in Quart applications, without Flask shims and without Flask-Login.

What You Get

Core auth

  • Session-based login and logout
  • Email/password registration
  • Password change flow (including OAuth-style users that don’t know an initial random password)
  • current_user proxy
  • @auth_required("session") and @roles_required(...)

MFA

  • TOTP setup and verification
  • Recovery code generation and one-time consumption

WebAuthn (passkeys / security keys)

  • Credential registration
  • Passwordless sign-in (first factor)
  • Authenticated verification flow (step-up / second factor)
  • Credential deletion

Extension and compatibility surface

  • Quart extension pattern (Security(app, datastore))
  • Flask-Security-style endpoint naming through url_for_security()
  • Signals for auth lifecycle events
  • Overridable templates under templates/security/
  • Datastore hooks can be implemented as async methods or simple sync methods

Non-Goals (Current Scope)

This project intentionally focuses on session auth and MFA currently in active use:

  • No token-based API auth
  • No SMS/email OTP
  • No account locking workflow
  • No remember-me token system

Installation

Install from repository

uv add git+https://github.com/level09/quart-security.git

Local development

uv sync --group dev
uv run pytest -q

Package build backend: flit.

Quick Integration

from quart import Quart
from quart_security import Security, SQLAlchemyUserDatastore

import os

# Models must include the fields required by enabled features.
from myapp.models import db, User, Role, WebAuthnCredential


def create_app():
    app = Quart(__name__)

    app.config.update(
        SECRET_KEY=os.environ["SECRET_KEY"],
        SECURITY_POST_LOGIN_VIEW="/dashboard",
        SECURITY_POST_REGISTER_VIEW="/login",
    )

    db.init_app(app)
    datastore = SQLAlchemyUserDatastore(
        db,
        User,
        Role,
        webauthn_model=WebAuthnCredential,
    )

    Security(app, datastore)
    return app

Required Model Surface

Your user/role models are expected to provide the fields used by active features.

Minimum practical user fields:

  • fs_uniquifier
  • email
  • password
  • active
  • roles

For tracking / MFA / WebAuthn features:

  • last_login_at, current_login_at, last_login_ip, current_login_ip, login_count
  • tf_primary_method, tf_totp_secret, mf_recovery_codes
  • fs_webauthn_user_handle
  • relationship/association for stored WebAuthn credentials

Required for the default lockout policy:

  • failed_login_count
  • locked_until

These fields are required when login or MFA lockout is enabled. The built-in counter limits account-level guesses. Deploy an application or edge rate limiter for source IP and distributed abuse controls.

The database must enforce unique normalized emails, fs_uniquifier values, WebAuthn user handles, and credential IDs. Use a stable primary key separate from fs_uniquifier: this value changes when security settings change.

Key Configuration

The extension uses SECURITY_* keys for migration-friendly configuration.

Core:

  • SECURITY_PASSWORD_HASH (default: argon2 / argon2id; set to pbkdf2_sha512 to keep old behavior)
  • SECURITY_PASSWORD_SALT (legacy salted-hash verification only)
  • SECURITY_PASSWORD_LENGTH_MIN (default: 12)
  • SECURITY_PASSWORD_BREACH_CHECK (default: True) - HIBP k-anonymity check on register/change
  • SECURITY_PASSWORD_BREACH_COUNT_MIN (default: 1) - minimum breach count to reject
  • SECURITY_ARGON2_MEMORY_COST / SECURITY_ARGON2_TIME_COST / SECURITY_ARGON2_PARALLELISM - override argon2 params (production defaults: 19456/2/1)
  • SECURITY_LOGIN_MAX_ATTEMPTS (default: 5)
  • SECURITY_LOCKOUT_MINUTES (default: 15)
  • SECURITY_REGISTERABLE
  • SECURITY_CHANGEABLE
  • SECURITY_TRACKABLE
  • SECURITY_CSRF_PROTECT (default: True)
  • SECURITY_COOKIE_SECURE (default: True; sets SESSION_COOKIE_SECURE)
  • SECURITY_FRESHNESS (default: 60 minutes)

Existing pbkdf2_sha512 and bcrypt hashes continue to verify after the argon2 default change. They are transparently rehashed to argon2id on the user's next successful login.

2FA:

  • SECURITY_TWO_FACTOR
  • SECURITY_TOTP_ISSUER
  • SECURITY_MULTI_FACTOR_RECOVERY_CODES
  • SECURITY_MULTI_FACTOR_RECOVERY_CODES_N

Recovery codes are displayed only when generated and are stored as keyed hashes. Existing plaintext codes remain valid until their next successful use, when the remaining codes are migrated.

Accepted TOTP time steps, recovery codes, and WebAuthn challenges cannot be reused. Disabling an authenticator requires a current TOTP or unused recovery code. Wait for the next TOTP code if the current one was just used to sign in.

WebAuthn:

  • SECURITY_WEBAUTHN
  • SECURITY_WAN_ALLOW_AS_FIRST_FACTOR
  • SECURITY_WAN_ALLOW_AS_MULTI_FACTOR
  • SECURITY_WAN_RP_ID (optional override)
  • SECURITY_WAN_RP_NAME (optional override)
  • SECURITY_WAN_EXPECTED_ORIGIN (optional override)
  • SECURITY_WAN_REQUIRE_USER_VERIFICATION (default: True)

Routing:

  • SECURITY_POST_LOGIN_VIEW
  • SECURITY_POST_REGISTER_VIEW

Route Map

Core:

  • /login
  • /register
  • /logout (POST only)
  • /change

2FA:

  • /tf-setup
  • /tf-validate
  • /tf-select
  • /mf-recovery-codes
  • /mf-recovery

WebAuthn:

  • /wan-register
  • /wan-register-response
  • /wan-signin
  • /wan-signin-response
  • /wan-verify
  • /wan-verify-response
  • /wan-delete

Template Overrides

Default templates are intentionally simple and framework-neutral.

Override by placing templates with the same names under your app’s templates/security/ directory.

Public API

from quart_security import (
    Security,
    SQLAlchemyUserDatastore,
    current_user,
    auth_required,
    roles_required,
    UserMixin,
    RoleMixin,
    hash_password,
    verify_password,
    user_authenticated,
    user_logged_out,
    password_changed,
    tf_profile_changed,
    user_registered,
    url_for_security,
)

Testing

Project tests cover:

  • password hashing/validation
  • auth and role decorators
  • register/login/logout/change-password
  • TOTP and recovery code flows
  • WebAuthn register/sign-in/verify/delete route behavior

Run:

uv run pytest -q

Run this in a HTTPS staging environment with production-like hostnames and real browser prompts:

  1. Register a passkey from /wan-register and verify it is persisted with expected name, usage, and sign_count.
  2. Complete passwordless sign-in from /wan-signin with the same credential.
  3. Complete authenticated verify flow from /wan-verify while already signed in.
  4. Delete credential from /wan-delete and confirm subsequent passkey auth fails for that credential.
  5. Repeat step 1 and step 2 with a second authenticator type (for example platform passkey + hardware key) to validate device portability assumptions.

Notes for Production

  • Run behind HTTPS for WebAuthn in non-local environments.
  • Set explicit WebAuthn RP values (SECURITY_WAN_RP_ID, SECURITY_WAN_EXPECTED_ORIGIN) when behind proxies or multiple domains.
  • Keep CSRF protection enabled unless you have a deliberate replacement.

Version 2.0.0 migration

This update requires a shared quart_security_state table. It stores expiring authentication records and verification state. Cookie contents contain opaque references, not pending authenticator secrets. All workers must use the same database. Existing login cookies are rejected after this update.

Create the table through your application's database migration before deploying the new library. For example, in an Alembic migration:

from alembic import op
from quart_security import SecurityState


def upgrade():
    SecurityState.__table__.create(op.get_bind())

This includes the expiry index. Startup checks that the table is present. Protect database access and backups because pending TOTP secrets are stored there. Expired records are removed when new state is written. Maintenance jobs can also call await security.state_store.purge_expired() in an application context and close the datastore after the job.

For a session factory, use async_sessionmaker(engine, expire_on_commit=False). The library retains the session through commits and closes factory-owned sessions at the end of each HTTP request or WebSocket connection. Read-only and failed requests also close their sessions. A supplied db.session or session instance remains owned by the host application; the host must close or roll it back. Outside a request, close factory-owned sessions with await datastore.close(). Do not share one session across concurrent tasks.

Password and MFA profile changes rotate the user's fs_uniquifier, which rejects older authenticated and pending login sessions. Logout revokes the current authentication record, including copied cookies for that session. Password changes keep the requesting session authenticated. Passkey registration and deletion also revoke older sessions. A secondary passkey cannot perform passwordless sign-in. A primary passkey uses its own user verification and does not require the account's TOTP code.

Password helpers now use the current application's settings. Outside an app context, pass app=app, for example hash_password(password, app=app).

Cookies default to Secure, HttpOnly, and SameSite=Lax. For local HTTP development only, set SECURITY_COOKIE_SECURE=False. An explicit SameSite value is preserved. Use a strong random secret key and explicit RP/origin settings behind proxies. The host still needs source-IP and global rate limits; account lockout does not limit registration or anonymous challenge generation. Lockout fields are required when SECURITY_LOGIN_MAX_ATTEMPTS is greater than zero. Setting it to zero explicitly disables account lockout.

Custom datastores require a shared state_store passed to Security. Its async methods must implement this contract and propagate storage errors:

Method Contract
put(payload, ttl=seconds, token=None) Persist a dictionary with an expiry and return an unpredictable reference.
get(token) Return unexpired state or None.
pop(token) Atomically consume unexpired state; exactly one caller receives it.
claim(token, ttl=seconds) Atomically reserve a key; return False while another unexpired claim exists.

A process-local dictionary is suitable only for tests. Custom datastores must also provide record_auth_failure(user, max_attempts=..., lockout_minutes=...) as an atomic increment/lock update and replace_recovery_codes(user, expected, remaining) as an atomic conditional replacement returning a boolean. rotate_uniquifier(user, expected, replacement) must atomically check the old value and persist both the new value and staged profile changes. Return False and roll back staged changes when the old value no longer matches. Concurrent profile updates return HTTP 409; the user must sign in again. Persist these changes before returning. The extension fails at initialization if an enabled control lacks its required hook or SQLAlchemy model fields.

Dependencies now include the Pillow, bcrypt, and SQLAlchemy asyncio extras and patched minimum versions of aiosmtplib, cryptography, and cbor2. The release workflow runs lint and tests before building and publishing. CI tests Python 3.11 through 3.14.

TOTP encryption and key rotation

Version 2.0.1 encrypts enrolled TOTP seeds and pending setup secrets with authenticated Fernet encryption. Legacy plaintext seeds remain readable and are encrypted on successful login or an authenticated request. Hosts should encrypt dormant records during upgrade using encrypt_totp_secret(value, app=app). This requires no schema change for a 255-character seed column.

By default, the key is derived from SECRET_KEY with a separate purpose label. Keep that key stable. For independent rotation, configure SECURITY_TOTP_ENCRYPTION_KEYS as a nonempty list of Fernet keys. The first key encrypts new data; all keys can decrypt existing data. Retain old keys while rotating existing seeds with encrypt_totp_secret, including active pending setup states until their five-minute expiry. Remove old keys only after all data and required backups have been handled. Never rotate SECRET_KEY without preserving the derived MFA key if using the default. derive_totp_encryption_key(old_secret_key) supplies that key for an explicit keyring. Protect the keyring separately from database backups.

Metadata

Release files for quart-security 2.0.1

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

Source distribution (sdist)

Source distribution for quart-security 2.0.1
File Size Uploaded
quart_security-2.0.1.tar.gz 35.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for quart-security 2.0.1
File Interpreter ABI Platform
quart_security-2.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 77.5 kB

Release files / quart_security-2.0.1.tar.gz

Download URL quart_security-2.0.1.tar.gz
Size 35.7 kB
Tags Source
SHA-256 checksum
How to use checksums
fa0fe168a819483229aa7d7292af84506c89fef0efd7d32b92e9314a96ea7d0b
BLAKE2b-256 checksum
How to use checksums
a6dee2b0f19adf6dc2f781551264a0041f4683386c7dee5927d6f702d8a04433
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 Oct 6, 2026.

Transparency log

Release files / quart_security-2.0.1-py3-none-any.whl

Download URL quart_security-2.0.1-py3-none-any.whl
Size 41.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6a90561b3cbd05a1769985358a3e453caf8683b89b3fd12374c95314f93fb5ef
BLAKE2b-256 checksum
How to use checksums
ab2dac5e2c7bfe78b3103d5dc78db8139bf0a1d2867553b2f64cf87355eed1b1
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 Oct 6, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2.0.1 This release

2 release files

2.0.0

2 release files

1.4.1

2 release files

1.4.0

2 release files

1.3.0

2 release files

1.2.1

2 release files

1.1.1

2 release files

1.1.0

2 release files

1.0.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