<?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: Dimitrij Drus</title>
    <description>The latest articles on DEV Community by Dimitrij Drus (@dadrus).</description>
    <link>https://dev.to/dadrus</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%2F3995380%2F190f289b-5e4c-4cd3-9e93-126f66edaa52.png</url>
      <title>DEV Community: Dimitrij Drus</title>
      <link>https://dev.to/dadrus</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dadrus"/>
    <language>en</language>
    <item>
      <title>Stop Using Bearer Tokens Like House Keys: DPoP with Heimdall</title>
      <dc:creator>Dimitrij Drus</dc:creator>
      <pubDate>Sun, 21 Jun 2026 14:01:53 +0000</pubDate>
      <link>https://dev.to/dadrus/stop-using-bearer-tokens-like-house-keys-dpop-with-heimdall-36je</link>
      <guid>https://dev.to/dadrus/stop-using-bearer-tokens-like-house-keys-dpop-with-heimdall-36je</guid>
      <description>&lt;p&gt;You've built an API. You protected it with OAuth 2.0. You're using JWTs. You feel secure.&lt;/p&gt;

&lt;p&gt;You're not.&lt;/p&gt;

&lt;p&gt;A Bearer token is exactly what the name says — whoever bears it gets in. Steal the token from a log file, a network trace, or a compromised client — and you own the API. No questions asked.&lt;/p&gt;

&lt;p&gt;DPoP (&lt;a href="https://www.rfc-editor.org/rfc/rfc9449.html" rel="noopener noreferrer"&gt;RFC 9449&lt;/a&gt;) fixes this with sender-constrained access tokens: instead of presenting a token and getting in, the client must prove on every request that it holds the private key the token was issued for. A stolen token without the private key is useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  How DPoP Actually Works
&lt;/h2&gt;

&lt;p&gt;DPoP ties an access token to a specific key pair. Here's the core idea:&lt;/p&gt;

&lt;p&gt;The client generates an asymmetric key pair — typically EC P-256. When requesting a token, it sends the public key embedded into a self-signed JWT to the Authorization Server. The server verifies that signature, embeds the public key in the access token as the &lt;code&gt;cnf&lt;/code&gt; (confirmation) claim, and issues the token.&lt;/p&gt;

&lt;p&gt;On every API call, the client must now prove it still holds the corresponding private key by attaching a signed proof JWT — the DPoP proof. This proof contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;htm&lt;/code&gt; — the HTTP method of the request&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;htu&lt;/code&gt; — the URL of the request&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;jti&lt;/code&gt; — a unique identifier to prevent replay&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ath&lt;/code&gt; — a hash of the access token it accompanies&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The resource server checks that the proof is signed with the key referenced in the token's &lt;code&gt;cnf&lt;/code&gt; claim, that it matches the current request, and that it hasn't been seen before. A stolen token is useless without the private key — you can't forge a valid proof. The resource server can also require a nonce in the proof — a short-lived value that further limits the validity window of each proof. More on that below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Setup
&lt;/h2&gt;

&lt;p&gt;Four components, running locally via Docker Compose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Keycloak       → issues DPoP-bound access tokens
DPoP Client    → proves possession on every request
Heimdall       → validates token + proof, enforces deny-by-default
traefik/whoami → simulates the upstream service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heimdall sits in front of your API. The upstream service knows nothing about DPoP — it receives verified requests and gets on with its job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Flow
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Client generates EC key pair (P-256)
2. Client performs the Authorization Code Grant Flow + PKCE
3. Keycloak issues access token bound to client's public key (cnf claim)
4. Client calls API:
   Authorization: DPoP &amp;lt;access_token&amp;gt;
   DPoP: &amp;lt;signed proof JWT&amp;gt;
5. Heimdall verifies:
   - token validity (issuer, expiry, algorithms)
   - proof matches token (ath claim)
   - proof matches this request (htm, htu claims)
   - proof hasn't been seen before (jti replay check)
   - nonce is fresh (DPoP-Nonce challenge)
6. All checks pass → request forwarded to upstream
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Heimdall Config
&lt;/h2&gt;

&lt;p&gt;Two files are all it takes. The first defines the security mechanisms — how tokens are validated and how internal tokens are issued:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;secret_management&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;nonce_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jwks&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/heimdall/secrets.jwks&lt;/span&gt;
  &lt;span class="na"&gt;signing_keys&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pem&lt;/span&gt;
    &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/etc/heimdall/signer.pem&lt;/span&gt;

&lt;span class="c1"&gt;# references a key defined above used to generate dpop nonce&lt;/span&gt;
&lt;span class="na"&gt;master_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nonce_keys&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dpop-nonce-master-key-1&lt;/span&gt;

&lt;span class="na"&gt;mechanisms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;authenticators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny_all&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unauthorized&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dpop_jwt&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jwt&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;jwks_endpoint&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://keycloak.localhost:8080/realms/dpop/protocol/openid-connect/certs&lt;/span&gt;
        &lt;span class="na"&gt;assertions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;issuers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;http://keycloak.localhost:8080/realms/dpop&lt;/span&gt;
          &lt;span class="na"&gt;allowed_algorithms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;RS256&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ES256&lt;/span&gt;
          &lt;span class="na"&gt;validity_leeway&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10s&lt;/span&gt;
          &lt;span class="na"&gt;proof_of_possession&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dpop&lt;/span&gt;
            &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;max_age&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
              &lt;span class="na"&gt;nonce_required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
              &lt;span class="na"&gt;replay_allowed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
        &lt;span class="na"&gt;error_signaling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
          &lt;span class="na"&gt;include_dpop_algorithms&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;finalizers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal_token&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jwt&lt;/span&gt;
      &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;signer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;signing_keys&lt;/span&gt;
        &lt;span class="na"&gt;ttl&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;

&lt;span class="c1"&gt;# deny all requests by default&lt;/span&gt;
&lt;span class="na"&gt;default_rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;authenticator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deny_all&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;finalizer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal_token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The piece worth focusing on is &lt;code&gt;proof_of_possession&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;proof_of_possession&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dpop&lt;/span&gt;
  &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;max_age&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
    &lt;span class="na"&gt;nonce_required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;replay_allowed&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where DPoP enforcement actually happens. &lt;code&gt;max_age&lt;/code&gt; limits how long a proof is valid. &lt;code&gt;nonce_required&lt;/code&gt; forces the client to include a fresh server-issued nonce on every request. &lt;code&gt;replay_allowed: false&lt;/code&gt; means each proof can only be used exactly once.&lt;/p&gt;

&lt;p&gt;The second file is upstream service-specific, maps routes to those mechanisms, and reconfigures them where needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rules/upstream-rules.yaml&lt;/span&gt;
&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# allow requests to /api and /api/* carrying a dpop bound access token&lt;/span&gt;
  &lt;span class="c1"&gt;# and forward them to upstream:8081&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;upstream:api&lt;/span&gt;
    &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;routes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/api/**&lt;/span&gt;
    &lt;span class="na"&gt;forward_to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;upstream:8081&lt;/span&gt;
    &lt;span class="na"&gt;execute&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;authenticator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dpop_jwt&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;finalizer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal_token&lt;/span&gt;
        &lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;claims&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;{&lt;/span&gt;
              &lt;span class="s"&gt;"url": {{ quote .Request.URL.String }},&lt;/span&gt;
              &lt;span class="s"&gt;"service": "upstream"&lt;/span&gt;
            &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The upstream receives verified requests — no auth code, no DPoP logic, nothing to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Nonce Challenge
&lt;/h2&gt;

&lt;p&gt;The first request to a protected endpoint is deliberately rejected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;HTTP 401
WWW-Authenticate: DPoP error="use_dpop_nonce"
DPoP-Nonce: &amp;lt;fresh nonce&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client includes the nonce in its next proof. This collapses the replay window from "token lifetime" to one minute — and you can reconfigure it. Heimdall generates and validates nonces using a symmetric key from &lt;code&gt;secret_management&lt;/code&gt;. The entire nonce lifecycle stays inside the proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Heimdall Catches
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attack&lt;/th&gt;
&lt;th&gt;How it's stopped&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stolen bearer token&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cnf&lt;/code&gt; claim mismatch — not bound to attacker's key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replayed DPoP proof&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jti&lt;/code&gt; seen before, &lt;code&gt;replay_allowed: false&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proof for wrong endpoint&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;htu&lt;/code&gt; mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Proof for wrong method&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;htm&lt;/code&gt; mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale proof&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;max_age: 1m&lt;/code&gt; exceeded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wrong token in proof&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ath&lt;/code&gt; mismatch&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Beyond DPoP: What You Get For Free
&lt;/h2&gt;

&lt;h3&gt;
  
  
  IDP Abstraction
&lt;/h3&gt;

&lt;p&gt;In production, identity providers change — companies consolidate systems, migrate providers, run multiple authorization servers. If your upstream services validate tokens directly against Keycloak's JWKS endpoint, every migration touches every service.&lt;/p&gt;

&lt;p&gt;There's a better way: Heimdall becomes the only issuer your upstream trusts.&lt;/p&gt;

&lt;p&gt;After validating the DPoP-bound token — nonce check, replay protection, proof-of-possession — Heimdall issues a fresh internal JWT with normalized claims your upstream service expects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;finalizer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;internal_token&lt;/span&gt;
&lt;span class="na"&gt;config&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;claims&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;{&lt;/span&gt;
      &lt;span class="s"&gt;"url": {{ quote .Request.URL.String }},&lt;/span&gt;
      &lt;span class="s"&gt;"service": "upstream"&lt;/span&gt;
    &lt;span class="s"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upstream validates against Heimdall's JWKS endpoint — nothing else. Whether the original token was DPoP-bound, which IdP issued it, or what grant flow the client used: irrelevant. Swap Keycloak for Okta or Azure AD — the upstream config doesn't change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Defense in Depth
&lt;/h3&gt;

&lt;p&gt;Perimeter validation is necessary. It's not sufficient.&lt;/p&gt;

&lt;p&gt;A compromised internal service can make direct calls to your API and reach any upstream that trusts arbitrary JWTs. The internal token closes this gap: your upstream only trusts tokens that come from Heimdall and contain the expected claims.&lt;/p&gt;

&lt;p&gt;An attacker inside your network can't forge a Heimdall-issued token without the signing key. The signature fails. The request is rejected.&lt;/p&gt;

&lt;p&gt;This is the difference between authentication at the perimeter and authentication at every hop.&lt;/p&gt;

&lt;h2&gt;
  
  
  DPoP and AI Agents
&lt;/h2&gt;

&lt;p&gt;Bearer tokens and AI agents are a bad combination. An agent operating across service boundaries — calling MCP tools, delegating to sub-agents, routing through gateways — may leak tokens through logs, traces, and intermediaries constantly.&lt;/p&gt;

&lt;p&gt;DPoP binds the token to the agent's key pair. An intercepted token is useless without the agent's private key. This is why DPoP is being discussed in MCP &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1932" rel="noopener noreferrer"&gt;SEP-1932&lt;/a&gt; as a proof-of-possession mechanism for AI agent authentication — and why Heimdall already implements it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;Full working example with Docker Compose, Keycloak setup, and DPoP client:&lt;/p&gt;

&lt;p&gt;→ &lt;a href="http://dadrus.github.io/heimdall/dev/guides/authn/dpop_bound_access_tokens" rel="noopener noreferrer"&gt;dadrus.github.io/heimdall/dev/guides/authn/dpop_bound_access_tokens&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The guide targets the &lt;code&gt;dev&lt;/code&gt; image — built from the &lt;code&gt;main&lt;/code&gt; branch and available on GHCR and Docker Hub — as DPoP support hasn't landed in a stable release yet.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>architecture</category>
      <category>security</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
