<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Suliman Abdulrazzaq</title>
    <description>The latest articles on DEV Community by Suliman Abdulrazzaq (@suliman_abdulrazzaq_14907).</description>
    <link>https://dev.to/suliman_abdulrazzaq_14907</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3696667%2F2e124610-b88b-4782-8242-96c5b528520f.png</url>
      <title>DEV Community: Suliman Abdulrazzaq</title>
      <link>https://dev.to/suliman_abdulrazzaq_14907</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/suliman_abdulrazzaq_14907"/>
    <language>en</language>
    <item>
      <title>What DBSC Does and Doesn't Protect You From</title>
      <dc:creator>Suliman Abdulrazzaq</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:30:04 +0000</pubDate>
      <link>https://dev.to/suliman_abdulrazzaq_14907/what-dbsc-does-and-doesnt-protect-you-from-iml</link>
      <guid>https://dev.to/suliman_abdulrazzaq_14907/what-dbsc-does-and-doesnt-protect-you-from-iml</guid>
      <description>&lt;p&gt;Most writing about DBSC is either a spec or a pitch. This is neither. DBSC is a genuinely good defense against the most common session attack on the internet, and it has a precise boundary that the marketing language tends to blur. If you're deciding whether to adopt it — or trying to explain to a security review what it does and doesn't buy you — the boundary is the whole conversation.&lt;/p&gt;

&lt;p&gt;Short version: DBSC kills &lt;em&gt;remote&lt;/em&gt; cookie theft and replay. It does not turn a session into a hardware token for every threat, and exactly how much it protects depends on which tier the user lands in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The attack it's built for
&lt;/h2&gt;

&lt;p&gt;A session cookie is a bearer token: hold it, be the user. The dominant way that goes wrong at scale is &lt;strong&gt;steal the cookie, replay it from somewhere else&lt;/strong&gt;. Infostealer malware lifts cookies out of the browser profile and ships them off. XSS exfiltrates them. A malicious extension reads them. A misconfigured proxy logs them. In every case the attacker ends up with the cookie value on &lt;em&gt;their&lt;/em&gt; machine and uses it to ride your session — no password, no MFA prompt, because the cookie already cleared both.&lt;/p&gt;

&lt;p&gt;DBSC binds the session to a private key that lives on the user's device and never travels. When the attacker replays the cookie from their machine, the next refresh demands a signature their machine can't produce. The session dies. That's the core win, and it's a big one: it neutralizes the single most common takeover path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tiers protect different amounts
&lt;/h2&gt;

&lt;p&gt;This is the part that matters most and gets glossed over. DBSC binding comes in two strengths.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Threat&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;dbsc&lt;/code&gt; (native, TPM/Secure Enclave)&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;bound&lt;/code&gt; (Web Crypto polyfill, IndexedDB)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Remote cookie theft + replay&lt;/td&gt;
&lt;td&gt;Stopped&lt;/td&gt;
&lt;td&gt;Stopped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MFA bypass via stolen cookie&lt;/td&gt;
&lt;td&gt;Stopped&lt;/td&gt;
&lt;td&gt;Stopped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infostealer reading the browser profile on disk&lt;/td&gt;
&lt;td&gt;Stopped (key is in hardware)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Not stopped&lt;/strong&gt; (encrypted blob on disk is recoverable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malware running inside the browser process&lt;/td&gt;
&lt;td&gt;Not stopped&lt;/td&gt;
&lt;td&gt;Not stopped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The native tier (&lt;code&gt;dbsc&lt;/code&gt;) puts the private key inside a TPM or Secure Enclave. No software on the machine — including malware running as the user — can read it. That's the strong form, and it's Chromium-on-supported-hardware only today.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;bound&lt;/code&gt; tier is a polyfill for everyone else (Firefox, Safari, older Chromium). It uses a non-extractable Web Crypto key in IndexedDB. The JavaScript API genuinely can't export that key — so XSS can't steal it, which is why remote theft is still defeated. But the encrypted key blob does sit in the browser profile directory on disk. Infostealer malware running with the victim's privileges can read that directory and, depending on the OS keystore, decrypt it. So the polyfill defends against &lt;em&gt;remote&lt;/em&gt; theft but not against &lt;em&gt;on-device&lt;/em&gt; malware with disk access.&lt;/p&gt;

&lt;p&gt;If you treat &lt;code&gt;bound&lt;/code&gt; as if it were &lt;code&gt;dbsc&lt;/code&gt;, you've overstated your protection to the one audience (security reviewers) who will check. Be precise: &lt;code&gt;bound&lt;/code&gt; defeats remote cookie theft; only &lt;code&gt;dbsc&lt;/code&gt; additionally defeats local infostealers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does not protect, at any tier
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Malware inside the browser process.&lt;/strong&gt; If the attacker is executing in the page or the browser itself, they have the live, authenticated session the same way the user does. They don't need to steal a cookie; they &lt;em&gt;are&lt;/em&gt; the session. DBSC binds the cookie to the device — and the malware is on the device, in the browser. No cookie-binding scheme fixes this; it's a different problem (endpoint security).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Non-cookie and non-JWT credentials.&lt;/strong&gt; DBSC binds session &lt;em&gt;cookies&lt;/em&gt;. It says nothing about Primary Refresh Tokens (PRTs), Kerberos tickets, OAuth refresh tokens stored outside the cookie jar, or API keys in a config file. If your real session-bearing secret isn't the cookie DBSC bound, DBSC isn't protecting it. This catches people in enterprise SSO setups where the cookie is only one link in the chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Devices without the hardware.&lt;/strong&gt; No TPM, no Secure Enclave, no native tier — the user falls back to the polyfill (weaker, as above) or to nothing. You don't get hardware binding on hardware that can't do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authentication itself.&lt;/strong&gt; A session exists &lt;em&gt;before&lt;/em&gt; DBSC binds it. DBSC protects an already-authenticated session; it is not a login mechanism and not a replacement for MFA. It makes a stolen post-login cookie useless — it does nothing about phishing the login itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The window between refreshes
&lt;/h2&gt;

&lt;p&gt;Session-level binding (registration + refresh) re-checks the key on a cycle — say every ten minutes. Between two checks, a cookie stolen &lt;em&gt;just now&lt;/em&gt; is still valid, because the next signature check hasn't happened yet. That's a real, if small, window.&lt;/p&gt;

&lt;p&gt;Closing it requires the optional per-request proof: a signature on individual sensitive requests, not just on refresh. With that in place on a guarded route, a stolen cookie fails on the &lt;em&gt;first&lt;/em&gt; such request rather than surviving until the next refresh. It's opt-in per route because it has a cost (the client signs each guarded call), and many apps accept the refresh-cycle window for everything except payments and credential changes. The point is to know the window exists and decide deliberately, route by route, rather than assume binding is instantaneous everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to think about adopting it
&lt;/h2&gt;

&lt;p&gt;DBSC isn't a silver bullet and it isn't snake oil. It's a sharp tool with a clean edge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It eliminates the most common real-world takeover (remote cookie replay) for essentially all modern browsers.&lt;/li&gt;
&lt;li&gt;It gives you a &lt;em&gt;stronger&lt;/em&gt; guarantee on Chromium-with-TPM (defeats local infostealers too) and an honest, weaker-but-still-useful one everywhere else.&lt;/li&gt;
&lt;li&gt;It does not absolve you of endpoint security, MFA, protecting non-cookie credentials, or guarding the login itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The right mental model is defense in depth, not replacement. DBSC closes the cookie-replay door — the door attackers walk through most often — while leaving the others exactly where they were. That's worth a lot, precisely because it's a specific, verifiable claim and not a vague "more secure."&lt;/p&gt;

&lt;p&gt;If you want the mechanics behind these guarantees, I wrote up &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/blob/main/docs/blog/dbsc-explained.md" rel="noopener noreferrer"&gt;how the protocol actually works&lt;/a&gt; and the &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/blob/main/docs/security/threat-model.md" rel="noopener noreferrer"&gt;full server-side threat model&lt;/a&gt; is public. The honest boundary is the selling point: you can hand it to a security team and every line holds up.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authentication</category>
      <category>threatmodeling</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Implementing DBSC Server-Side: A Language-Agnostic Guide</title>
      <dc:creator>Suliman Abdulrazzaq</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:27:14 +0000</pubDate>
      <link>https://dev.to/suliman_abdulrazzaq_14907/implementing-dbsc-server-side-a-language-agnostic-guide-152m</link>
      <guid>https://dev.to/suliman_abdulrazzaq_14907/implementing-dbsc-server-side-a-language-agnostic-guide-152m</guid>
      <description>&lt;p&gt;If you're building a DBSC server outside Node — Go, Python, Rust, Java, PHP — you don't need a library, you need the wire contract: which endpoints to expose, the exact bytes in each header, how to verify the proofs, and the order of the checks. This is that contract, framework-free, with short pseudo-code instead of any one language's idioms. The companion &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/blob/main/docs/blog/implementing-dbsc-on-express.md" rel="noopener noreferrer"&gt;Express tutorial&lt;/a&gt; shows it concretely in Node; this is the version you port.&lt;/p&gt;

&lt;p&gt;Everything below is drawn from a &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/blob/main/spec" rel="noopener noreferrer"&gt;language-neutral spec&lt;/a&gt; with &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/tree/main/spec/vectors" rel="noopener noreferrer"&gt;test vectors&lt;/a&gt; — concrete inputs and expected outputs your implementation can self-check against without driving a real browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The surface area
&lt;/h2&gt;

&lt;p&gt;You implement two HTTP endpoints and one response header. That's the whole native protocol.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;POST /dbsc/registration&lt;/code&gt; — the browser sends its newly generated public key here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;POST /dbsc/refresh&lt;/code&gt; — the browser re-proves possession of the key here, on a cycle.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;Secure-Session-Registration&lt;/code&gt; response header you attach to your login response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Plus a small amount of state: a session record and a short-lived challenge store.&lt;/p&gt;

&lt;h2&gt;
  
  
  The headers, exactly
&lt;/h2&gt;

&lt;p&gt;Case-insensitive on inbound. Some Chromium builds straddle a rename, so accept the legacy names and emit both.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Carries&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server → Browser&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Secure-Session-Registration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;"start a session" instruction, after login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server → Browser&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Secure-Session-Challenge&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a fresh challenge JTI, in the 403 that starts a refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser → Server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Secure-Session-Response&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the JWS proof, on registration and refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser → Server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Sec-Secure-Session-Id&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the session id on refresh (the cookie is gone by then)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Legacy inbound names you MUST also accept: &lt;code&gt;Sec-Session-Response&lt;/code&gt;, &lt;code&gt;Sec-Session-Registration&lt;/code&gt;. Legacy outbound names you SHOULD also emit: &lt;code&gt;Sec-Session-Registration&lt;/code&gt;, &lt;code&gt;Sec-Session-Challenge&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The registration header value is a strict little grammar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(&amp;lt;alg&amp;gt;);path="&amp;lt;registrationPath&amp;gt;";challenge="&amp;lt;jti&amp;gt;";id="&amp;lt;boundCookieName&amp;gt;"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Joined by &lt;code&gt;;&lt;/code&gt; with &lt;strong&gt;no spaces&lt;/strong&gt;, values double-quoted, algorithm in parentheses (&lt;code&gt;ES256&lt;/code&gt; or &lt;code&gt;RS256&lt;/code&gt;). &lt;code&gt;path&lt;/code&gt; is where the browser POSTs its key — not the refresh URL. &lt;code&gt;id&lt;/code&gt; is the bound cookie's name; Chromium requires it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The proofs
&lt;/h2&gt;

&lt;p&gt;Both registration and refresh send a compact JWS: &lt;code&gt;&amp;lt;protected&amp;gt;.&amp;lt;payload&amp;gt;.&amp;lt;signature&amp;gt;&lt;/code&gt;, each segment base64url.&lt;/p&gt;

&lt;p&gt;Registration JWS — carries the public key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;header:&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dbsc+jwt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jwk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"P-256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"y"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;payload:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;challenge&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;signature:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ECDSA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;P&lt;/span&gt;&lt;span class="mi"&gt;-256&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;over&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;protected&amp;gt;.&amp;lt;payload&amp;gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;by&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;key&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;matching&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;jwk&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refresh JWS — identical, but with &lt;strong&gt;no &lt;code&gt;jwk&lt;/code&gt;&lt;/strong&gt; (you already stored the key). A refresh JWS that includes a &lt;code&gt;jwk&lt;/code&gt; is a protocol error and must be rejected.&lt;/p&gt;

&lt;p&gt;The registration JWS is self-signed: the key is in the header, the signature is by that key. Verifying it proves possession without the private key ever leaving the device.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying a JWS (the part to get exactly right)
&lt;/h2&gt;

&lt;p&gt;This is where an implementation either holds or quietly leaks. Pseudo-code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verify_dbsc_jws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compact_jws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_jwk_or_none&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;split_on_dot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;compact_jws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;base64url_decode_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Algorithm allowlist — reject everything else BEFORE loading a key.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alg&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ES256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RS256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALG_NOT_ALLOWED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# this rejects "none" and HS256 confusion
&lt;/span&gt;
    &lt;span class="c1"&gt;# 2. Pick the key.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expected_jwk_or_none&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="c1"&gt;# registration: key is in the header
&lt;/span&gt;        &lt;span class="n"&gt;jwk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwk&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                               &lt;span class="c1"&gt;# refresh: use the stored key, ignore any header jwk
&lt;/span&gt;        &lt;span class="n"&gt;jwk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expected_jwk_or_none&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Verify signature over the raw "&amp;lt;protected&amp;gt;.&amp;lt;payload&amp;gt;" bytes.
&lt;/span&gt;    &lt;span class="n"&gt;signing_input&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;header_b64&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;payload_b64&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;crypto_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signing_input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;base64url_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIGNATURE_INVALID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;base64url_decode_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# contains jti
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The algorithm allowlist in step 1 is not optional. If you skip it, an attacker can send &lt;code&gt;alg: "none"&lt;/code&gt; (no signature) or &lt;code&gt;alg: "HS256"&lt;/code&gt; and try to make you HMAC with the public key as the secret. Reject anything that isn't &lt;code&gt;ES256&lt;/code&gt;/&lt;code&gt;RS256&lt;/code&gt; before you touch a key.&lt;/p&gt;

&lt;h2&gt;
  
  
  State you keep
&lt;/h2&gt;

&lt;p&gt;Two stores, abstracted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Session:   { id, tier, lastRefreshAt, ... }       # tier in {none, dbsc, bound}
BoundKey:  { sessionId, kind, jwk, algorithm }     # kind in {native, bound}
Challenge: { jti, sessionId, consumed, expiresAt } # single-use, short-lived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A session can hold two bound keys (one &lt;code&gt;native&lt;/code&gt;, one &lt;code&gt;bound&lt;/code&gt;) — that's how Chromium does both hardware-backed refresh and software per-request proofs. Key them by &lt;code&gt;kind&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The one hard requirement on the challenge store: &lt;strong&gt;consume must be atomic&lt;/strong&gt;. The JTI is single-use, and a non-atomic check-then-delete opens a replay window. Use a single atomic operation — a Lua script on Redis, &lt;code&gt;UPDATE ... WHERE consumed = false&lt;/code&gt; on SQL — that both checks and flips in one step and tells you whether &lt;em&gt;you&lt;/em&gt; were the one who consumed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registration handler, in order
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;dbsc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;registration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;jws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Secure-Session-Response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MISSING_RESPONSE_HEADER&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_dbsc_jws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_jwk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# self-signed
&lt;/span&gt;    &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;alg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;

    &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;challenges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="n"&gt;exists&lt;/span&gt;          &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHALLENGE_NOT_FOUND&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consumed&lt;/span&gt;    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHALLENGE_CONSUMED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expired&lt;/span&gt;     &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHALLENGE_EXPIRED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;this_session&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JTI_MISMATCH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="n"&gt;native&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SESSION_ALREADY_REGISTERED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;challenges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consume_atomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;   &lt;span class="c1"&gt;# the race guard
&lt;/span&gt;        &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CHALLENGE_CONSUMED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;store&lt;/span&gt; &lt;span class="n"&gt;BoundKey&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;native&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;algorithm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;alg&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dbsc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastRefreshAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;json_session_config&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="n"&gt;bound&lt;/span&gt; &lt;span class="n"&gt;cookie&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Refresh handler, in order
&lt;/h2&gt;

&lt;p&gt;The session id comes from the &lt;code&gt;Sec-Secure-Session-Id&lt;/code&gt; header — the cookie is gone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;on&lt;/span&gt; &lt;span class="n"&gt;POST&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;dbsc&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sec-Secure-Session-Id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;no&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Secure-Session-Response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# first leg — no proof yet
&lt;/span&gt;        &lt;span class="n"&gt;jti&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;new_challenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Secure-Session-Challenge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="n"&gt;challenge&lt;/span&gt; &lt;span class="n"&gt;cookie&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;                                      &lt;span class="c1"&gt;# MUST be 403, never 401
&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bound_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;native&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KEY_NOT_FOUND_NATIVE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verify_dbsc_jws&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jws&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected_jwk&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# stored key
&lt;/span&gt;    &lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="nf"&gt;challenge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# exists/unconsumed/unexpired/belongs
&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;verification&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;challenges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consume_atomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;          &lt;span class="c1"&gt;# demotion is what kills the replayed cookie
&lt;/span&gt;        &lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIGNATURE_INVALID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;challenges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consume_atomic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jti&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastRefreshAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# tier stays "dbsc"
&lt;/span&gt;    &lt;span class="n"&gt;respond&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;json_session_config&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="n"&gt;bound&lt;/span&gt; &lt;span class="n"&gt;cookie&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The JSON session config
&lt;/h2&gt;

&lt;p&gt;Both successful handlers return this (200, &lt;code&gt;Content-Type: application/json&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_identifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;sessionId&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"refresh_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/dbsc/refresh"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"include_site"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"scope_specification"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"credentials"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cookie"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;boundCookieName&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Path=/; Secure; HttpOnly; SameSite=Lax"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules that fail silently if you break them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It must be 200 with this body.&lt;/strong&gt; A 204, or a 200 with no body, makes Chromium treat the session as opted-out and abandon it. No error is raised.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;credentials[0].attributes&lt;/code&gt; must match your real &lt;code&gt;Set-Cookie&lt;/code&gt; byte-for-byte.&lt;/strong&gt; Any drift — a different &lt;code&gt;SameSite&lt;/code&gt;, an extra space — and the browser drops the binding. Generate the cookie and this string from the same source so they can't diverge.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Status codes that actually matter
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Status&lt;/th&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Browser does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;403&lt;/code&gt; + &lt;code&gt;Secure-Session-Challenge&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;refresh needs proof&lt;/td&gt;
&lt;td&gt;signs and retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;401&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;ignores it; session dies.&lt;/strong&gt; Never use 401 here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; + JSON config&lt;/td&gt;
&lt;td&gt;registration/refresh ok&lt;/td&gt;
&lt;td&gt;updates session, replays request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;200&lt;/code&gt; without JSON (e.g. &lt;code&gt;204&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;treats as opt-out; session dies&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Things that aren't in the protocol but you still need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS, with &lt;code&gt;__Host-&lt;/code&gt; cookies.&lt;/strong&gt; Chrome drops them over plain HTTP. Non-negotiable in production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate-limit the two endpoints.&lt;/strong&gt; They're unauthenticated by nature (the proof &lt;em&gt;is&lt;/em&gt; the auth). The algorithm is your call; the requirement is that you have one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behind a TLS-terminating proxy, derive &lt;code&gt;https&lt;/code&gt; from the forwarded protocol&lt;/strong&gt; if you put an explicit &lt;code&gt;scope.origin&lt;/code&gt; in the config. Get the scheme wrong and Chromium drops the session.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Self-checking without a browser
&lt;/h2&gt;

&lt;p&gt;The hardest part of building this is that almost every mistake fails &lt;em&gt;silently&lt;/em&gt; — the session just doesn't bind, with no error to chase. That's why the spec ships &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/tree/main/spec/vectors" rel="noopener noreferrer"&gt;test vectors&lt;/a&gt;: real registration headers, JWS proofs, and per-request proofs with known inputs and expected outputs. Run your implementation against those before you ever point a browser at it. If your verifier accepts the sample registration JWS and produces the sample header byte-for-byte, you've eliminated the whole class of silent wire-format bugs in one pass.&lt;/p&gt;

&lt;p&gt;The protocol is genuinely small — two endpoints and a header. The discipline is in the details: the atomic consume, the 403-not-401, the byte-exact cookie, the algorithm allowlist. Get those four right and the rest follows.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>backend</category>
      <category>authentication</category>
    </item>
    <item>
      <title>Implementing Device Bound Session Credentials (DBSC) on Express</title>
      <dc:creator>Suliman Abdulrazzaq</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:25:33 +0000</pubDate>
      <link>https://dev.to/suliman_abdulrazzaq_14907/implementing-device-bound-session-credentials-dbsc-on-express-3jjm</link>
      <guid>https://dev.to/suliman_abdulrazzaq_14907/implementing-device-bound-session-credentials-dbsc-on-express-3jjm</guid>
      <description>&lt;p&gt;A stolen session cookie is a full account takeover. The attacker copies the cookie out of the browser profile — infostealer malware does exactly this, at scale — pastes it into their own browser, and they &lt;em&gt;are&lt;/em&gt; you. Every defense we've layered on (SameSite, Secure, HttpOnly, short TTLs) reduces the blast radius but doesn't close the hole: a bearer token is a bearer token, and whoever holds it wins.&lt;/p&gt;

&lt;p&gt;Device Bound Session Credentials (DBSC) closes it. Chrome shipped it to stable in 146. The idea is small and the consequences are large: at login the browser generates an EC P-256 keypair &lt;em&gt;inside the device's hardware key store&lt;/em&gt; — TPM 2.0 on Windows, Secure Enclave on Apple Silicon — and hands your server only the public key. The private key never leaves the hardware and can't be exported, not even by malware running as the user. Your server binds the session to that key. Every few minutes the browser proves it still holds the key by signing a server challenge. Copy the cookie to another machine and the next refresh fails, because that machine can't produce the signature. The session dies within one refresh cycle.&lt;/p&gt;

&lt;p&gt;This post is the server side, on Express, end to end. I'll use &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit" rel="noopener noreferrer"&gt;&lt;code&gt;dbsc-toolkit&lt;/code&gt;&lt;/a&gt; — the library I wrote and verified against Chrome 147 on real TPM 2.0 hardware — so the protocol plumbing is handled and we can focus on what you actually wire.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're building
&lt;/h2&gt;

&lt;p&gt;Three moving parts on the server:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Two protocol routes&lt;/strong&gt; — &lt;code&gt;/dbsc/registration&lt;/code&gt; (the browser POSTs its public key here) and &lt;code&gt;/dbsc/refresh&lt;/code&gt; (the browser re-proves possession here). You don't write these; the middleware mounts them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A bind call in your login route&lt;/strong&gt; — after you've authenticated the user the usual way, you start the binding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An optional guard&lt;/strong&gt; on sensitive routes that demands a fresh proof from the bound device.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your existing auth doesn't change. DBSC rides alongside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;dbsc-toolkit express
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Framework and storage drivers are optional peer deps, so you only pull what you use. Memory storage is fine to start; swap in Redis or Postgres for anything that has to survive a restart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The minimum working server
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;randomUUID&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:crypto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createDbsc&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dbsc-toolkit/express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MemoryStorage&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dbsc-toolkit/storage/memory&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="c1"&gt;// Configure once. install() mounts the protocol routes, the bound-route&lt;/span&gt;
&lt;span class="c1"&gt;// JSON parser, the browser SDK, and `trust proxy` — all in one call.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dbsc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createDbsc&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MemoryStorage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;dbsc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;install&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... your real authentication: verify password, look up the user ...&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;dbsc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/me&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dbsc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;not authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole server. &lt;code&gt;dbsc.bind()&lt;/code&gt; is doing five things under the hood: it writes the session row, issues a single-use challenge, builds the &lt;code&gt;Secure-Session-Registration&lt;/code&gt; response header, sets both the legacy and current header names for compatibility, and sets the short-lived cookies Chrome needs to complete registration. You call one function in the one place you already have — the login route.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTTPS is not optional
&lt;/h2&gt;

&lt;p&gt;DBSC uses &lt;code&gt;__Host-&lt;/code&gt; prefixed cookies, which Chrome will only accept over HTTPS with the Secure flag and no Domain attribute. On plain &lt;code&gt;http://localhost&lt;/code&gt; Chrome silently drops them and nothing binds. For local development either run a TLS proxy in front of your server (&lt;code&gt;local-ssl-proxy --source 3001 --target 3000&lt;/code&gt;) or push to any host that terminates HTTPS at the edge. If you set &lt;code&gt;secure: false&lt;/code&gt; for HTTP-only local testing, native DBSC still won't engage — Chromium refuses the protocol over HTTP — but the polyfill path (more on that below) works over Web Crypto, which is enough to exercise the flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching it work
&lt;/h2&gt;

&lt;p&gt;Open the app in a Chromium 146+ browser over HTTPS, open DevTools → Network, and hit &lt;code&gt;POST /login&lt;/code&gt;. Within a second or so you'll see a request you never wrote: &lt;code&gt;POST /dbsc/registration&lt;/code&gt;, initiated by the browser itself. That request carries a JWS signed by the freshly minted hardware key. The server verifies the self-signature, stores the public key under the session ID, sets the &lt;code&gt;__Host-dbsc-session&lt;/code&gt; cookie, and returns a JSON session config that tells the browser how and when to refresh.&lt;/p&gt;

&lt;p&gt;Hit &lt;code&gt;GET /me&lt;/code&gt; afterward and you'll see &lt;code&gt;tier: "dbsc"&lt;/code&gt;. That's the proof the session is hardware-bound.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tier model, and the browsers that aren't Chrome
&lt;/h2&gt;

&lt;p&gt;Native DBSC is Chromium-only today. Firefox and Safari don't speak it yet. If you stopped at native, you'd be protecting maybe a third of your users and leaving everyone else on plain bearer cookies — which is an awkward thing to put in a security review.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;dbsc-toolkit&lt;/code&gt; also ships a Web Crypto polyfill. It does the same session binding with a non-extractable &lt;code&gt;CryptoKey&lt;/code&gt; held in IndexedDB. It's a notch weaker than a TPM — the key lives in the browser's storage rather than a separate security chip, so it doesn't defend against malware with full access to the user's own machine — but it defeats every &lt;em&gt;remote&lt;/em&gt; cookie-replay scenario, which is the threat that matters for theft-at-scale. That gives you three states, exposed as &lt;code&gt;res.locals.dbsc.tier&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dbsc&lt;/code&gt; — native, hardware-backed key (Chromium + TPM/Secure Enclave).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bound&lt;/code&gt; — polyfill key in IndexedDB (Firefox, Safari, older Chromium).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;none&lt;/code&gt; — unbound or stale.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One server, every modern browser, no per-browser branching in your code. If you specifically want native-only and no polyfill, there's a &lt;code&gt;bound: false&lt;/code&gt; switch — but the default covers everyone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guarding the routes that matter
&lt;/h2&gt;

&lt;p&gt;Binding the session is half the value. The other half is &lt;em&gt;requiring&lt;/em&gt; a fresh, per-request proof before you do something sensitive — a payment, a password change, exporting data. That's one guard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;requireProof&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dbsc-toolkit/express&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/payment&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;requireProof&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dbsc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;requireProof()&lt;/code&gt; rejects any request that isn't coming from the bound device — a replayed cookie from elsewhere never gets here. It works the same across all three tiers, so you write the guard once and it does the right thing whether the user is on a TPM or the polyfill.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one gotcha that bites client code
&lt;/h2&gt;

&lt;p&gt;There's a timing detail worth knowing before it confuses you. Binding completes &lt;em&gt;after&lt;/em&gt; the login response returns. On Chromium the browser POSTs &lt;code&gt;/dbsc/registration&lt;/code&gt; a few hundred milliseconds later; on Firefox/Safari the polyfill first probes for native support for a few seconds and &lt;em&gt;then&lt;/em&gt; registers. Either way, if your frontend checks the session the instant login resolves, it sees &lt;code&gt;tier: "none"&lt;/code&gt; even on a fully supported browser — not because anything failed, but because binding hasn't happened yet.&lt;/p&gt;

&lt;p&gt;Don't poll to wait it out; the delay is variable and you'd be guessing. The browser SDK gives you a promise that resolves exactly when binding finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;outcome&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;initBoundDbsc&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;unbound&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;outcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phase&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// bound — now it's safe to call your guarded routes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why I wouldn't hand-roll the server side
&lt;/h2&gt;

&lt;p&gt;The flow above looks simple from the application's seat, and that's the point — the library is absorbing a set of wire-format rules that are individually small and collectively brutal to discover. A few I burned real time on, all of which the W3C draft does not make obvious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The refresh endpoint must answer 403 on a missing or invalid proof — never 401.&lt;/strong&gt; Chromium silently ignores a 401 and lets the session die instead of restarting the challenge. The first time I returned the "correct" 401, the session just quietly stopped refreshing and I had no error to chase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registration and refresh must respond &lt;code&gt;200&lt;/code&gt; with the JSON session-config body, not a bare &lt;code&gt;204&lt;/code&gt;.&lt;/strong&gt; A 204 looks perfectly fine in DevTools and makes Chromium terminate the session anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;credentials[].attributes&lt;/code&gt; string in that JSON has to match the real &lt;code&gt;Set-Cookie&lt;/code&gt; header byte-for-byte.&lt;/strong&gt; Any drift and the browser drops the binding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Challenge consumption has to be atomic.&lt;/strong&gt; The JTI is single-use; a non-atomic check-then-delete opens a replay window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these throw an exception. They fail by the session quietly not binding, which is the worst kind of bug to debug against a browser. That's the work the library exists to have already done — it's verified end-to-end against Chrome 147 on real TPM hardware, and the exact wire contract is written up as a &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/blob/main/spec/README.md" rel="noopener noreferrer"&gt;language-neutral spec&lt;/a&gt; with round-trip test vectors if you do want to implement it yourself in another language.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The runnable demo with both cookie-session and JWT login modes: &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/tree/main/examples/express" rel="noopener noreferrer"&gt;&lt;code&gt;examples/express&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Adapters for Fastify, Hono, and Next.js App Router ship in the same package.&lt;/li&gt;
&lt;li&gt;The protocol spec and test vectors, if you're implementing DBSC anywhere outside Node: &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/tree/main/spec" rel="noopener noreferrer"&gt;&lt;code&gt;spec/&lt;/code&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;DBSC is the first session-security primitive in a long time that actually moves the bearer-token problem instead of just shrinking it. Chrome shipping it to stable means it's no longer a research toy — it's something you can turn on for real users this quarter. The server side is genuinely a few lines; the hard part was the wire details, and those are written down now.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>node</category>
      <category>express</category>
    </item>
    <item>
      <title>DBSC Explained: How Device Bound Session Credentials Actually Work</title>
      <dc:creator>Suliman Abdulrazzaq</dc:creator>
      <pubDate>Fri, 12 Jun 2026 12:17:55 +0000</pubDate>
      <link>https://dev.to/suliman_abdulrazzaq_14907/dbsc-explained-how-device-bound-session-credentials-actually-work-2ppc</link>
      <guid>https://dev.to/suliman_abdulrazzaq_14907/dbsc-explained-how-device-bound-session-credentials-actually-work-2ppc</guid>
      <description>&lt;p&gt;A session cookie is a bearer token. Whoever holds it is the user — the server has no way to tell the real browser apart from a copy of the cookie pasted into a different one. That single property is behind a huge share of real-world account takeovers: infostealer malware lifts cookies out of the browser profile, ships them to an attacker, and the attacker is logged in as you without ever touching your password or your MFA.&lt;/p&gt;

&lt;p&gt;Device Bound Session Credentials (DBSC) is the W3C protocol that removes the "whoever holds it" part. Chrome shipped it to stable in version 146. This post walks the entire protocol end to end with no framework and no specific language — just what travels on the wire and why each piece is shaped the way it is. If you've read the &lt;a href="https://github.com/w3c/webappsec-dbsc" rel="noopener noreferrer"&gt;W3C draft&lt;/a&gt; and wanted the version that explains the &lt;em&gt;reasoning&lt;/em&gt;, this is that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core idea in one paragraph
&lt;/h2&gt;

&lt;p&gt;At login, the browser generates a public/private keypair &lt;strong&gt;inside the device's hardware key store&lt;/strong&gt; — a TPM 2.0 on Windows, the Secure Enclave on Apple Silicon Macs. It sends the server only the public key. The private key never leaves the hardware and cannot be exported, not even by code running as the user. The server ties the session to that public key. From then on, the browser proves it still holds the matching private key by signing server-issued challenges. A cookie copied to another machine can't produce those signatures — that machine doesn't have the key — so the copied session dies on its next refresh.&lt;/p&gt;

&lt;p&gt;The cookie still travels normally. DBSC doesn't encrypt it or hide it. It makes the cookie &lt;em&gt;insufficient on its own&lt;/em&gt;: possession stops being proof of identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two flows, one session
&lt;/h2&gt;

&lt;p&gt;There are exactly two server endpoints and three things the server does: it tells the browser to make a key (registration), it re-checks the key periodically (refresh), and it exposes a session &lt;strong&gt;tier&lt;/strong&gt; so the application can refuse requests that aren't backed by a live binding.&lt;/p&gt;

&lt;p&gt;Crucially, the server is &lt;em&gt;reactive&lt;/em&gt;. It doesn't initiate anything. It sets one response header after login and then answers two endpoints when the browser comes knocking. The browser decides when to register and when to refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registration: binding the session
&lt;/h2&gt;

&lt;p&gt;Registration happens right after your normal login succeeds. The login response carries an extra header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Secure-Session-Registration: (ES256);path="/dbsc/registration";challenge="&amp;lt;jti&amp;gt;";id="__Host-dbsc-session"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That value is a small grammar, joined by semicolons with no spaces: the algorithm in parentheses, the &lt;code&gt;path&lt;/code&gt; where the browser should POST its key, a one-time &lt;code&gt;challenge&lt;/code&gt; (a JTI — a unique nonce), and the &lt;code&gt;id&lt;/code&gt; naming the cookie the bound session will use.&lt;/p&gt;

&lt;p&gt;The browser sees that header, generates its hardware keypair, and — on its own, within about a second — POSTs to the path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant TPM as Hardware key store
    participant Browser
    participant Server
    Note over Browser,Server: user logs in normally
    Server--&amp;gt;&amp;gt;Browser: 200 + Secure-Session-Registration header
    Browser-&amp;gt;&amp;gt;TPM: generate EC P-256 keypair
    TPM--&amp;gt;&amp;gt;Browser: public key (private stays inside)
    Browser-&amp;gt;&amp;gt;Server: POST /dbsc/registration&amp;lt;br/&amp;gt;Secure-Session-Response: &amp;lt;JWS&amp;gt;
    Server-&amp;gt;&amp;gt;Server: verify self-signature, store public key
    Server--&amp;gt;&amp;gt;Browser: 200 + JSON session config + bound cookie
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proof the browser sends is a JWS (JSON Web Signature) with a specific shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;Protected&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;header:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"typ"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dbsc+jwt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jwk"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"P-256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"y"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Payload:&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"jti"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;the challenge from the header&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;Signature:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="err"&gt;ECDSA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;P&lt;/span&gt;&lt;span class="mi"&gt;-256&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;over&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;header.payload,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;made&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;with&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;private&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clever part: this JWS is &lt;strong&gt;self-signed&lt;/strong&gt;. The public key is embedded in the header, and the signature is made by the matching private key. So the server can verify the signature using only what's in the message — and that verification &lt;em&gt;proves the sender holds the private key&lt;/em&gt; without the private key ever being transmitted. The server stores the public key against the session, marks the session as bound (&lt;code&gt;tier: dbsc&lt;/code&gt;), and responds 200 with a JSON config telling the browser where and how often to refresh.&lt;/p&gt;

&lt;p&gt;A detail that trips up everyone who implements this by hand: that response &lt;strong&gt;must be 200 with the JSON body&lt;/strong&gt;. A bare &lt;code&gt;204 No Content&lt;/code&gt; looks correct in DevTools and causes Chromium to treat the whole thing as an opt-out and silently abandon the session. There's no error — the binding just never happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Refresh: re-proving possession, forever
&lt;/h2&gt;

&lt;p&gt;The bound cookie is deliberately short-lived (ten minutes in the reference flow). When it expires, the browser refreshes the binding &lt;em&gt;before&lt;/em&gt; it replays whatever request the user was making. This is the heartbeat that makes a stolen cookie useless.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sequenceDiagram
    participant TPM as Hardware key store
    participant Browser
    participant Server
    Note over Browser: bound cookie expires
    Browser-&amp;gt;&amp;gt;Server: POST /dbsc/refresh&amp;lt;br/&amp;gt;Sec-Secure-Session-Id: &amp;lt;sessionId&amp;gt;
    Note right of Server: no proof yet
    Server--&amp;gt;&amp;gt;Browser: 403 + Secure-Session-Challenge: "&amp;lt;new jti&amp;gt;"
    Browser-&amp;gt;&amp;gt;TPM: sign the new challenge
    TPM--&amp;gt;&amp;gt;Browser: signature
    Browser-&amp;gt;&amp;gt;Server: POST /dbsc/refresh&amp;lt;br/&amp;gt;Secure-Session-Response: &amp;lt;JWS&amp;gt;
    Server-&amp;gt;&amp;gt;Server: verify against stored key
    Server--&amp;gt;&amp;gt;Browser: 200 + fresh cookie + JSON config
    Browser-&amp;gt;&amp;gt;Server: replay the user's original request
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in here are easy to get wrong and silent when you do.&lt;/p&gt;

&lt;p&gt;First, the session is identified by the &lt;code&gt;Sec-Secure-Session-Id&lt;/code&gt; &lt;strong&gt;header&lt;/strong&gt;, not by a cookie — because the bound cookie has already expired by the time refresh runs. If you try to read the session from a cookie here, there's nothing to read.&lt;/p&gt;

&lt;p&gt;Second, that first response &lt;strong&gt;must be 403&lt;/strong&gt;, not 401. The 403 with a &lt;code&gt;Secure-Session-Challenge&lt;/code&gt; header is the signal that makes Chromium sign and retry. A 401 — which feels like the "more correct" status for "you haven't proven who you are" — is silently ignored, and the session quietly dies. This one cost me a full day of staring at a session that just stopped refreshing with no error anywhere.&lt;/p&gt;

&lt;p&gt;The refresh JWS is the same as the registration JWS with one difference: it carries &lt;strong&gt;no&lt;/strong&gt; embedded &lt;code&gt;jwk&lt;/code&gt;. The server already has the public key from registration, so the browser just signs the new challenge with the same private key. A refresh JWS that &lt;em&gt;does&lt;/em&gt; include a &lt;code&gt;jwk&lt;/code&gt; is a protocol error.&lt;/p&gt;

&lt;p&gt;The security payoff lives in the failure path. If the signature doesn't verify — which is exactly what happens when an attacker replays a stolen cookie from their own machine, because their machine has no matching key — the server demotes the session to &lt;code&gt;tier: none&lt;/code&gt; and rejects it. The stolen session is dead. The real browser, with the real key, sails through the same refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tier means
&lt;/h2&gt;

&lt;p&gt;After all this, the server exposes one value to the application: the session's tier.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Bound by&lt;/th&gt;
&lt;th&gt;Key lives in&lt;/th&gt;
&lt;th&gt;Defeats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;dbsc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Native protocol, signature verified&lt;/td&gt;
&lt;td&gt;Hardware (TPM / Secure Enclave)&lt;/td&gt;
&lt;td&gt;Remote cookie theft &lt;strong&gt;and&lt;/strong&gt; on-device malware reading the profile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;bound&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Web Crypto polyfill, signature verified&lt;/td&gt;
&lt;td&gt;Non-extractable key in browser storage (IndexedDB)&lt;/td&gt;
&lt;td&gt;Remote cookie theft. &lt;strong&gt;Not&lt;/strong&gt; on-device malware.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Nothing bound, or a refresh failed&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Nothing a bare cookie doesn't already&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;bound&lt;/code&gt; tier exists because native DBSC is Chromium-only today — Firefox and Safari don't speak it. A Web Crypto polyfill does the same binding with a non-extractable key in IndexedDB. It's a notch weaker (the key is in browser storage, not a separate chip, so it doesn't stop malware with disk access on the user's own machine) but it defeats every &lt;em&gt;remote&lt;/em&gt; replay, which is the threat that matters at scale. The point is that the application reads one &lt;code&gt;tier&lt;/code&gt; field and doesn't care which protocol produced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The piece most explanations skip: per-request proofs
&lt;/h2&gt;

&lt;p&gt;Session-level binding (registration + refresh) leaves a small window: between two refresh cycles, a freshly stolen cookie is still valid because the next signature check hasn't happened yet. To close that, a server can demand a proof on individual sensitive requests, not just on refresh:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Dbsc-Bound-Proof: ts=&amp;lt;timestamp&amp;gt;;sig=&amp;lt;signature&amp;gt;;bh=&amp;lt;body hash&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client signs &lt;code&gt;&amp;lt;sessionId&amp;gt;.&amp;lt;METHOD&amp;gt;.&amp;lt;path&amp;gt;.&amp;lt;timestamp&amp;gt;.&amp;lt;bodyHash&amp;gt;&lt;/code&gt; with the bound key. A guarded route verifies that signature before doing anything sensitive. A request riding a stolen cookie from another device can't produce the signature — there's no key on that device — so it's rejected on the &lt;em&gt;first&lt;/em&gt; guarded request, not after the next refresh.&lt;/p&gt;

&lt;p&gt;One subtlety here is the reason Chromium sessions carry two keys. The hardware key signs refresh challenges, but by design it's never exposed to JavaScript, so it &lt;em&gt;can't&lt;/em&gt; sign an arbitrary request message. So a Chromium session co-registers a second, software (polyfill) key specifically for per-request proofs. The route guard always checks that bound key, which is why the same guard works identically on Chrome, Firefox, and Safari.&lt;/p&gt;

&lt;h2&gt;
  
  
  What DBSC does not do
&lt;/h2&gt;

&lt;p&gt;Being precise about this is what separates understanding the protocol from cargo-culting it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It does &lt;strong&gt;not&lt;/strong&gt; stop malware running &lt;em&gt;inside&lt;/em&gt; the browser process. If the attacker is the browser, they get the live session like the user does.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;bound&lt;/code&gt; (polyfill) tier does &lt;strong&gt;not&lt;/strong&gt; stop infostealer malware with read access to the browser profile on disk. Only the native &lt;code&gt;dbsc&lt;/code&gt; tier, with the key in a TPM or Secure Enclave, does.&lt;/li&gt;
&lt;li&gt;It does &lt;strong&gt;not&lt;/strong&gt; replace authentication. A session already exists before DBSC binds it; DBSC protects the session, it doesn't establish identity.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote a whole separate piece on the threat boundary because it's the part security engineers care about most and the part marketing copy always blurs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters now
&lt;/h2&gt;

&lt;p&gt;DBSC is the first session-security primitive in a long time that moves the bearer-token problem instead of just shrinking it. SameSite, Secure, HttpOnly, short TTLs — all of those reduce the blast radius of a stolen cookie. DBSC makes the stolen cookie &lt;em&gt;not work&lt;/em&gt;. And as of Chrome 146 it's in stable, which means it's no longer a research demo — it's something you can turn on for real users.&lt;/p&gt;

&lt;p&gt;If you want to implement the server side, the full wire contract is written up as a &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit/blob/main/spec" rel="noopener noreferrer"&gt;language-neutral spec&lt;/a&gt; with round-trip test vectors, and there's a &lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit" rel="noopener noreferrer"&gt;Node reference implementation&lt;/a&gt; (&lt;code&gt;dbsc-toolkit&lt;/code&gt;) verified end-to-end against Chrome 147 on real TPM 2.0 hardware. The protocol is small. The details are merciless. Both are worth knowing before you ship it.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>authentication</category>
      <category>browsers</category>
    </item>
    <item>
      <title>How to Stop Stolen Session Cookies in Node.js using Device Bound Session Credentials (DBSC)</title>
      <dc:creator>Suliman Abdulrazzaq</dc:creator>
      <pubDate>Sun, 24 May 2026 22:39:56 +0000</pubDate>
      <link>https://dev.to/suliman_abdulrazzaq_14907/how-to-stop-stolen-session-cookies-in-nodejs-using-device-bound-session-credentials-dbsc-58op</link>
      <guid>https://dev.to/suliman_abdulrazzaq_14907/how-to-stop-stolen-session-cookies-in-nodejs-using-device-bound-session-credentials-dbsc-58op</guid>
      <description>&lt;p&gt;Web applications still rely heavily on session cookies — and that creates a serious security problem:&lt;/p&gt;

&lt;p&gt;If a session cookie gets stolen (via XSS, malware, logs, or proxy leaks), it can often be replayed from another device with no resistance.&lt;/p&gt;

&lt;p&gt;This is exactly the gap that Device Bound Session Credentials (DBSC) aims to solve.&lt;/p&gt;

&lt;p&gt;DBSC is a W3C specification that binds a session to a device-held cryptographic key instead of treating cookies as pure bearer tokens.&lt;/p&gt;

&lt;p&gt;In this article, I’ll show a practical Node.js implementation using dbsc-toolkit, an open-source library that brings DBSC support to real-world backend frameworks.&lt;/p&gt;

&lt;p&gt;🔐 What DBSC Changes&lt;/p&gt;

&lt;p&gt;Traditional cookies:&lt;/p&gt;

&lt;p&gt;Whoever has the cookie → owns the session&lt;/p&gt;

&lt;p&gt;DBSC model:&lt;/p&gt;

&lt;p&gt;Session is tied to a device key (TPM / Secure Enclave / WebCrypto fallback)&lt;br&gt;
Stolen cookies alone are useless on another device&lt;br&gt;
Server verifies proof of device possession on requests&lt;br&gt;
⚙️ What dbsc-toolkit provides&lt;/p&gt;

&lt;p&gt;dbsc-toolkit is a Node.js implementation of DBSC with:&lt;/p&gt;

&lt;p&gt;Session registration flow&lt;br&gt;
Challenge / response verification&lt;br&gt;
Session binding + validation&lt;br&gt;
Express / Fastify / Hono / Next.js support&lt;br&gt;
Redis / PostgreSQL / Memory storage adapters&lt;br&gt;
Optional Web Crypto fallback for non-Chromium browsers&lt;br&gt;
🚀 Quick Example (Express)&lt;br&gt;
import express from "express";&lt;br&gt;
import { randomUUID } from "node:crypto";&lt;br&gt;
import { createDbsc } from "dbsc-toolkit/express";&lt;br&gt;
import { MemoryStorage } from "dbsc-toolkit/storage/memory";&lt;/p&gt;

&lt;p&gt;const app = express();&lt;br&gt;
app.use(express.json());&lt;/p&gt;

&lt;p&gt;const dbsc = createDbsc({ storage: new MemoryStorage() });&lt;br&gt;
dbsc.install(app);&lt;/p&gt;

&lt;p&gt;app.post("/login", async (req, res) =&amp;gt; {&lt;br&gt;
  await dbsc.bind(res, randomUUID(), { userId: req.body.username });&lt;br&gt;
  res.json({ ok: true });&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.get("/me", (req, res) =&amp;gt; {&lt;br&gt;
  res.json(res.locals.dbsc);&lt;br&gt;
});&lt;/p&gt;

&lt;p&gt;app.listen(3000);&lt;br&gt;
🧪 Why this matters&lt;/p&gt;

&lt;p&gt;Most modern auth systems still rely on bearer-based sessions (cookies or JWTs).&lt;/p&gt;

&lt;p&gt;That means:&lt;/p&gt;

&lt;p&gt;XSS → session theft&lt;br&gt;
logs → session leakage&lt;br&gt;
proxy leaks → replay attacks&lt;br&gt;
malware → full account takeover&lt;/p&gt;

&lt;p&gt;DBSC changes the model from:&lt;/p&gt;

&lt;p&gt;"Who has the token?"&lt;/p&gt;

&lt;p&gt;to&lt;/p&gt;

&lt;p&gt;"Who owns the device that can prove the key?"&lt;/p&gt;

&lt;p&gt;🌐 Compatibility&lt;/p&gt;

&lt;p&gt;dbsc-toolkit works with:&lt;/p&gt;

&lt;p&gt;Node.js (Express, Fastify, Hono, Next.js)&lt;br&gt;
Chrome (native DBSC on supported versions)&lt;br&gt;
Firefox / Safari (WebCrypto fallback)&lt;br&gt;
Redis / PostgreSQL / Memory storage&lt;br&gt;
📦 Repository&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/SulimanAbdulrazzaq/dbsc-toolkit" rel="noopener noreferrer"&gt;https://github.com/SulimanAbdulrazzaq/dbsc-toolkit&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;💬 Notes&lt;/p&gt;

&lt;p&gt;This project is based on the current W3C DBSC specification and is intended for experimentation, prototyping, and early adoption in Node.js authentication systems.&lt;/p&gt;

&lt;p&gt;Feedback, security review, and spec alignment suggestions are welcome.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>auth</category>
      <category>security</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
