Skip to main content

CI PyPI Python 3.14+ Licensed under the MIT License

dbsc-python

A small, framework-agnostic, async Python server library for Device Bound Session Credentials (DBSC).

DBSC cryptographically binds an authenticated session to a hardware-backed device key (TPM / secure enclave). A stolen session cookie can no longer be replayed from another device: the short-lived bound cookie expires every few minutes and is only refreshable by signing a server challenge with a private key that never leaves the device.

It is pure HTTP headers: no JavaScript, no frontend assets, no database tables required. Non-DBSC browsers simply ignore the registration header and continue on normal cookie auth, so enabling it cannot lock anyone out.

This is a Python port of report-uri/dbsc-php, which is extracted from Report URI's production DBSC integration. It carries the wire-protocol corrections that only surface when integrating against a real browser (see Wire-protocol notes).

Contents

Design

  • One dependency. cryptography for ES256 verification, because the standard library has no elliptic-curve crypto. Everything else (JSON, base64url, nonces, constant-time comparison) is stdlib. About 700 lines of code, auditable in one sitting.
  • Async. Everything that touches the store or the audit log is async, so a Redis- or database-backed store never blocks the event loop. Signature verification is CPU-bound (microseconds) and stays synchronous.
  • Framework-agnostic. The library never reads the request, sends a header, or sets a cookie. Every operation takes a RequestContext you build from your framework's request and returns a DbscResponse you apply to your framework's response.
  • Storage is yours. You implement the Store protocol (Redis, a table, …). An InMemoryStore is bundled for tests and experimentation.
  • The crypto is deliberately minimal: ES256 only, signature plus a single-use challenge nonce. See the JwtVerifier docstring for why iat/exp/iss/aud are intentionally not checked.
  • Fully typed (ships py.typed), checked with ty.
  • Secure by default. Unsafe configuration is refused at startup, stored state fails closed, concurrent requests can't undo each other, and every state change is audited. SECURITY.md maps the design to the OWASP Top 10:2025 and lists what your integration must do.

Installation

Requires Python 3.14+.

uv add dbsc

or

pip install dbsc

The entry point is dbsc.DbscServer:

from dbsc import Config, DbscServer, InMemoryStore

dbsc = DbscServer(Config(cookie_name="__Host-myapp_dbsc"), InMemoryStore())

InMemoryStore is for tests and single-process experiments only. Use a shared, persistent store in production (see Storage).

Flow

        BROWSER (DBSC-capable)              YOUR APP
   ----------------------------------------------------------------
   GET  /login  (full auth done) ----->  build_registration_header_response()
                                <-----   Secure-Session-Registration: (ES256); ...
   POST /dbsc/register (signed JWT) -->  register()
                                <-----   200 + Set-Cookie __Host-…_dbsc + Sec-Secure-Session-Id
                                          + session-instructions JSON
   ... every ~few minutes ...
   POST /dbsc/refresh (no body)  ----->  issue_refresh_challenge()
                                <-----   403 + Secure-Session-Challenge="…"; id="…"
   POST /dbsc/refresh (signed JWT) -->  refresh()
                                <-----   200 + rotated Set-Cookie + new challenge
   GET  /account (every request) ----->  enforcement gate (see below)
   GET  /logout                  ----->  revoke()

A complete reference server (stdlib asyncio, no framework) is in examples/demo_server.py. DBSC is browser-native (there's no JS API to script), so exercise it with a DBSC-capable browser over HTTPS. From a clone of the repository:

uv run python -m examples.demo_server --cert cert.pem --key key.pem

Integrating

You translate in both directions: build a RequestContext from your framework's request, and apply the returned DbscResponse to your framework's response.

from dbsc import DbscResponse, RequestContext


def dbsc_context(request, session_id: str, user_id: str) -> RequestContext:
    return RequestContext(
        session_id=session_id,  # your stable session id (see Storage)
        user_id=user_id,  # pass it whenever known; "" only before login
        origin_host_url="https://example.com",  # from your config, never the Host header
        headers=dict(request.headers),  # any casing
        cookies=dict(request.cookies),
    )


def apply(result: DbscResponse, response) -> None:
    for name, value in result.headers.items():
        response.headers[name] = value
    for cookie in result.cookies:
        if cookie.delete:
            response.delete_cookie(cookie.name, path=cookie.path)
        else:
            response.set_cookie(
                cookie.name,
                cookie.value,
                expires=cookie.expires_at,  # Unix seconds
                path=cookie.path,
                secure=cookie.secure,
                httponly=cookie.http_only,
                samesite=cookie.same_site,
            )
    if result.content_type is not None:
        response.headers["Content-Type"] = result.content_type
    if result.status is not None:  # None: leave your own status alone
        response.status_code = result.status
    if result.body is not None:
        response.body = result.body

Every DbscResponse that carries session state includes Cache-Control: no-store, so it can't be cached and replayed to someone else. Apply the headers as given.

Depending on the browser version, the signed JWT arrives in either the Secure-Session-Response or the Sec-Session-Response header, so accept both. Strip surrounding double quotes before passing it on:

jwt = (ctx.header("Secure-Session-Response") or ctx.header("Sec-Session-Response") or "").strip('"')

Registration

After full authentication (post-2FA / post-passkey), merge the offer into the login response:

apply(await dbsc.build_registration_header_response(ctx), response)

On POST /dbsc/register, return register()'s response. On any DbscError, respond 4xx and leave the user on plain cookie auth:

try:
    apply(await dbsc.register(jwt, ctx), response)
except DbscError:
    response.status_code = 401

Refresh

On POST /dbsc/refresh, a request with no JWT gets a challenge. A request with a JWT is verified under the failure contract below:

if not jwt:
    apply(await dbsc.issue_refresh_challenge(ctx), response)  # phase 1: 403 + challenge
else:
    try:
        apply(await dbsc.refresh(jwt, ctx), response)  # phase 2: 200 + rotated cookie
    except RetryableRefreshError:
        apply(await dbsc.issue_refresh_challenge(ctx), response)  # benign: 403, browser retries
    except DbscError:
        apply(await dbsc.revoke(ctx, enforcement_terminated=True), response)  # terminal
        # ...and terminate the authenticated session server-side.

MissingChallengeError, ChallengeExpiredError, and ChallengeMismatchError subclass RetryableRefreshError, so you can catch that once instead of enumerating three classes. ChallengeMismatchError is retryable, not terminal, because refresh() only reaches the challenge comparison after the JWT signature has already verified against the device key. A mismatch at that point can only be a benign race (idle session, concurrent refresh, lost 403), never forgery. The audit log shows the difference too: a retryable failure logs AuditEvent.REFRESH_RETRYABLE ("dbscRefreshRetryable"), not AuditEvent.REFRESH_FAILED.

Order matters: every RetryableRefreshError is also a DbscError, so the except RetryableRefreshError clause must come first. A lone except DbscError force-logs users out over benign races.

Terminal failures must end the session server-side. Do not rely on the browser or on cookie expiry. That is the failure mode that leaves a stolen-cookie session alive.

Logout

Call revoke() and apply its response. It deletes the DBSC state and the bound cookie, which is independent of your session cookie:

apply(await dbsc.revoke(ctx), response)

Enforcement gate

The library exposes the primitives but does not run the gate itself, because where you enforce depends on your routing. The recommended policy (also in examples/demo_server.py):

async def enforce_dbsc(ctx: RequestContext, response) -> bool:
    """Run the gate. False means the session was terminated: log out and redirect to login."""
    try:
        binding = await dbsc.get_binding(ctx)
    except CorruptStateError:
        # Present but unreadable: fail closed. Never fall through to cookie auth.
        apply(await dbsc.revoke(ctx, enforcement_terminated=True), response)
        return False

    if binding is None:
        # Never registered: unsupported browser, or not yet. Degrade to normal cookie auth.
        # (Do NOT block here. This is what makes locking out a Firefox user impossible.)
        return True

    must_check = dbsc.is_document_request(ctx) or not dbsc.is_within_registration_grace(binding)
    if must_check and not dbsc.bound_cookie_matches(binding, ctx):
        # Bound session, bad/absent device cookie: revoke, then log the user out.
        apply(await dbsc.revoke(ctx, enforcement_terminated=True), response)
        return False
    return True

Enforce on document loads and on subresources past the registration grace. Document-only enforcement would let a stolen cookie exfiltrate via XHR within the cookie lifetime. Skip the gate on the /dbsc/* endpoints themselves.

Optionally, on a document response that passed the gate, call await dbsc.advertise_refresh_challenge(binding, ctx) and apply the result. This makes the first refresh single-phase (see Wire-protocol notes).

Storage

Key DBSC state by your stable session id, in a dedicated key space, never in a read-modify-written shared session blob.

This is the one non-obvious correctness requirement. Report URI shipped DBSC with state in the PHP session blob. The post-login navigation raced the /dbsc/register POST, both rewrote the whole blob last-writer-wins, the binding was clobbered, and enforcement silently no-oped. That left exactly the stolen-cookie hole DBSC exists to close. The Store protocol documents the requirements; back it with Redis or a table keyed by session id.

A complete Redis-backed store, using redis.asyncio (not a dependency of this library):

from redis.asyncio import Redis
from redis.exceptions import WatchError

from dbsc import Binding, PendingRegistration, Store


class RedisStore(Store):
    def __init__(self, redis: Redis, challenge_ttl: int = 900, session_ttl: int = 64800) -> None:
        self._redis = redis
        self._challenge_ttl = challenge_ttl  # match Config.challenge_ttl_seconds
        self._session_ttl = session_ttl  # match your authenticated session lifetime

    async def put_pending_registration(self, session_id: str, pending: PendingRegistration) -> None:
        await self._redis.set(f"dbsc:reg:{session_id}", pending.to_json(), ex=self._challenge_ttl)

    async def get_pending_registration(self, session_id: str) -> PendingRegistration | None:
        raw = await self._redis.get(f"dbsc:reg:{session_id}")
        return None if raw is None else PendingRegistration.from_json(raw)  # corrupt -> raises

    async def delete_pending_registration(self, session_id: str) -> None:
        await self._redis.delete(f"dbsc:reg:{session_id}")

    async def put_binding(self, session_id: str, binding: Binding) -> None:
        await self._redis.set(f"dbsc:bind:{session_id}", binding.to_json(), ex=self._session_ttl)

    async def get_binding(self, session_id: str) -> Binding | None:
        raw = await self._redis.get(f"dbsc:bind:{session_id}")
        return None if raw is None else Binding.from_json(raw)  # corrupt -> raises

    async def delete(self, session_id: str) -> None:
        await self._redis.delete(f"dbsc:reg:{session_id}", f"dbsc:bind:{session_id}")

    async def commit_registration(
        self, session_id: str, offer: PendingRegistration, binding: Binding
    ) -> bool:
        reg_key, bind_key = f"dbsc:reg:{session_id}", f"dbsc:bind:{session_id}"
        async with self._redis.pipeline() as pipe:
            try:
                await pipe.watch(reg_key)  # EXEC fails if a logout or new offer changes it
                raw = await pipe.get(reg_key)
                if raw is None or PendingRegistration.from_json(raw) != offer:  # corrupt -> raises
                    return False
                pipe.multi()
                pipe.delete(reg_key)
                pipe.set(bind_key, binding.to_json(), ex=self._session_ttl)
                await pipe.execute()
            except WatchError:
                return False
        return True

    async def replace_binding(self, session_id: str, expected: Binding, new: Binding) -> bool:
        key = f"dbsc:bind:{session_id}"
        async with self._redis.pipeline() as pipe:
            try:
                await pipe.watch(key)  # EXEC fails if anyone writes or deletes the key meanwhile
                raw = await pipe.get(key)
                if raw is None or Binding.from_json(raw) != expected:  # corrupt -> raises
                    return False
                pipe.multi()
                pipe.set(key, new.to_json(), ex=self._session_ttl)
                await pipe.execute()
            except WatchError:
                return False
        return True

The rules any store must follow:

  • Separate keys. Pending registrations and bindings live under separate keys. "A binding exists" is the authoritative hard-DBSC mark.

  • TTLs. Pending registrations expire on the challenge TTL; bindings expire with the session lifetime.

  • Fail closed. get_binding() returns None only when no record exists. A present-but-unparseable record must raise CorruptStateError, which Binding.from_json() already does. The gate then fails closed instead of silently degrading to cookie auth.

  • Atomic updates. A browser's first refresh routinely races the page load that triggered it, so the server never blindly writes back state it read earlier. Two methods must be atomic in a shared store:

    • commit_registration(session_id, offer, binding) consumes the registration offer and stores the binding in one step, and only if that exact offer is still stored. Registration only consumes the offer when it succeeds, so junk attempts can't use it up. A logout or newer login that lands mid-registration wins, and so does the first of two registrations racing on one offer. Without atomicity, both racing registrations bind (the second silently replacing the first), or a binding appears for a session that just logged out. Redis WATCH/MULTI does this, as above; in SQL, a transaction that deletes the offer row (checking it's unchanged) and inserts the binding.
    • replace_binding(session_id, expected, new) writes new only if the stored binding still equals expected, and returns False otherwise, including when the record is gone. Compare the decoded Bindings, not raw JSON. Otherwise a stale write can undo a cookie rotation, which logs a legitimate user out, or bring back a session that was just revoked. Redis WATCH/MULTI does this, as above; in SQL, an UPDATE ... WHERE on the old value, or a row lock.

    There are deliberately no default implementations: a check-then-write split across awaits isn't atomic, even in one process, since two concurrent callers can both pass the check. Every Store method is abstract, so a subclass missing either of these raises TypeError when created, at startup rather than in production.

Audit logging

Pass any object with an async def log(self, event: AuditEvent, message: str, user_id: str | None) -> None method as DbscServer(..., audit=...). Every state transition is reported:

AuditEvent Value When
REGISTERED dbscRegistered A registration succeeded
REGISTRATION_FAILED dbscRegistrationFailed A registration was rejected
REFRESHED dbscRefreshed A refresh rotated the bound cookie
REFRESH_RETRYABLE dbscRefreshRetryable A benign refresh failure (issue a new challenge)
REFRESH_FAILED dbscRefreshFailed A terminal refresh failure (worth alerting on)
REVOKED dbscRevoked revoke() on a bound session
ENFORCEMENT_TERMINATED dbscEnforcementTerminated revoke(..., enforcement_terminated=True) on a bound session

AuditEvent is a StrEnum, so members compare equal to the plain string values.

API overview

Everything below is importable from dbsc. Full details are in the docstrings.

DbscServer(config, store, *, jwt_verifier=None, audit=None, clock=time.time)

Method Async Returns Purpose
build_registration_header_response(ctx) yes DbscResponse Offer DBSC after login (headers only, status=None)
register(jwt, ctx) yes DbscResponse Verify the registration JWT, create the binding (200)
issue_refresh_challenge(ctx) yes DbscResponse Rotate and return a challenge (403)
refresh(jwt, ctx) yes DbscResponse Verify the refresh JWT, rotate cookie + challenge (200)
revoke(ctx, *, enforcement_terminated=False) yes DbscResponse Delete state, emit a cookie deletion
get_binding(ctx) yes Binding | None The gate's lookup; raises CorruptStateError on unreadable state
bound_cookie_matches(binding, ctx) no bool Constant-time bound-cookie check (current or live previous value); False for another user's binding
is_document_request(ctx) no bool Sec-Fetch-Dest: document
is_within_registration_grace(binding) no bool Inside registration_grace_seconds after registration
advertise_refresh_challenge(binding, ctx) yes DbscResponse One-time seed challenge for a single-phase first refresh
session_instructions_json(ctx) yes str The session-instructions JSON, or {} if unbound

Config (keyword-only, immutable)

Field Default Notes
cookie_name "__Host-dbsc" Must carry the __Host- prefix
cookie_max_age_seconds 300 Bound-cookie lifetime, 1-3600
register_path "/dbsc/register" Absolute same-origin path
refresh_path "/dbsc/refresh" Absolute same-origin path
challenge_ttl_seconds 900 Must exceed cookie_max_age_seconds; at most 86400
registration_grace_seconds 5 Subresource exemption right after registration, 0-60
cookie_same_site "Lax" Lax, Strict or None
allowed_refresh_initiators () Host patterns; see wire-protocol notes
scope_specification () ScopeRules; see wire-protocol notes

Anything outside these rules raises ValueError when the Config is built, so a misconfiguration can't reach a response header. The same applies to ScopeRule values and to the RequestContext origin (https, or http on loopback only) and initiator overrides.

Other types: RequestContext, DbscResponse, Cookie, ScopeRule / ScopeRuleType, Binding, PendingRegistration, Store / InMemoryStore, AuditLogger / NullAuditLogger / AuditEvent, and JwtVerifier / ParsedJwt / RegistrationResult.

Exceptions: everything derives from DbscError.

Exception On refresh
MissingChallengeError, ChallengeExpiredError, ChallengeMismatchError (all RetryableRefreshError) Benign: issue a new challenge
JwtInvalidError, SessionNotFoundError, CorruptStateError Terminal: revoke and end the session

Wire-protocol notes

Baked into this library from integration testing against real Chrome. Change with care:

  • Registration is single-phase; refresh is two-phase (403 + challenge, then 200). This is the opposite of how the spec reads at first glance.

  • The first refresh can optionally be made single-phase. Steady-state refreshes already are (every 200 hands back the next challenge). Call await advertise_refresh_challenge(binding, ctx) on an ordinary authenticated document response in the registration→first-refresh window. It attaches the seed Secure-Session-Challenge once and records a one-way mark on the binding so later responses stay silent. The browser then holds a challenge when its first /dbsc/refresh fires and skips the 403. The spec forbids attaching this to the registration response (§9.2.1/§8.7: the id must name an already-existing session). The method takes a Binding, which only exists post-registration, so that misuse is structurally impossible. If the single delivery is missed, the browser falls back to the two-phase path, so there's no regression. A reactive 403 racing the advertised value is covered by single-depth challenge overlap: the immediately-previous challenge is accepted in refresh() until its own TTL, mirroring the bound-cookie overlap. Whether the browser actually skips the 403 is up to the browser. In this project's end-to-end tests (headless Chromium 149 and 150 on Linux, software keys), it usually still made the 403 round-trip, and the fallback held every time.

  • No Secure-Session-Challenge on the registration response: Chrome reports a Challenge Error. The first refresh-flow 403 issues the challenge; the binding seeds an internal one only to stay valid until then.

  • Both the cookie value and the challenge must rotate on every refresh. Re-emitting the existing cookie value makes Chrome treat it as "no refresh happened" and terminate.

  • Secure-Session-Challenge must carry the id sf-parameter naming the session.

  • challenge_ttl_seconds must exceed cookie_max_age_seconds (the Config constructor enforces this) so a challenge the browser cached just before cookie expiry is still valid when it is used.

  • The bound cookie uses __Host-, so include_site is false (no subdomain span).

  • Scope the session to what it protects, not to the whole origin. This is the default that costs you, and it is not obvious. Scope decides which requests the browser DEFERS while it refreshes an expired bound cookie, and every refresh spends a signature from a rate-limited device key. With the default whole-origin scope, your static assets are in it. A cold page load with an expired cookie fires every stylesheet, script and icon at once, and the browser attempts a separate refresh for each; it does not coalesce them. Measured against Chrome 151: seven assets, seven signing attempts in the same second, quota_exceeded, and a wedged session. Once the quota is gone refreshes stop, the browser will not register a replacement for a scope it already covers, and the site becomes indistinguishable from one with no DBSC support at all. Only clearing site data recovers it. Exclude anything that needs no session:

    Config(
        scope_specification=[
            ScopeRule.exclude(path="/assets/"),
            ScopeRule.exclude(path="/healthz"),
        ]
    )
    

    Omitted from the wire entirely when empty (the spec default). Overridable per request via RequestContext(..., scope_specification=[...]), where None inherits the Config value and [] forces the key off. path is a prefix, not an exact match.

  • allowed_refresh_initiators (spec) lists out-of-scope hosts allowed to trigger a refresh on a cross-site-initiated navigation. By default Chrome refuses (a timing side-channel mitigation). Omitted when empty (the spec default). Set a static default via Config(allowed_refresh_initiators=[...]), or override per request via RequestContext(..., allowed_refresh_initiators=[...]) (None falls back to Config, [] forces the key off). Entries pass through verbatim, wildcards included. Security note: each listed host regains the authentication-state timing oracle this mitigation removes, so list only relying parties you trust.

Contributing, security and changelog

  • Contributing: development setup, the checks CI runs, and the release process are in CONTRIBUTING.md.
  • Security: please report vulnerabilities privately; see SECURITY.md. Don't open public issues for them.
  • Changelog: CHANGELOG.md.

License

MIT (see LICENSE). © 2026 Report-URI Ltd. (the original dbsc-php) and © 2026 Jay Townsend (this Python port).

Metadata

Release files for dbsc 0.1.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 dbsc 0.1.0
File Size Uploaded Provenance
dbsc-0.1.0.tar.gz 44.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for dbsc 0.1.0
File Interpreter ABI Platform
dbsc-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 87.3 kB

Release files / dbsc-0.1.0.tar.gz

Download URL dbsc-0.1.0.tar.gz
Size 44.2 kB
Tags Source
SHA-256 checksum
How to use checksums
47910d6f7b82b9afa0a61fa60768d6cc7279a2b04f32f7f3c80bed2d9ccb3f13
BLAKE2b-256 checksum
How to use checksums
88495ca18fc5a2da6981ea72615a18522890e302775512dbebe67f1b67207e67
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 Sep 24, 2026.

Transparency log

Release files / dbsc-0.1.0-py3-none-any.whl

Download URL dbsc-0.1.0-py3-none-any.whl
Size 43.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
1f3296ccf4a2c2f834b720da93e5b0e5a3992da1dbed51571fbefa0afa7cf134
BLAKE2b-256 checksum
How to use checksums
b380d2b83446096a9cef7575bab5c17b8f8c76691585d960e261304af4006d9c
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 Sep 24, 2026.

Transparency log

Security for dbsc 0.1.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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for dbsc-0.1.0.tar.gz

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 Sep 24, 2026.

Transparency log

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.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files Full provenance

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