<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:cc="http://cyber.law.harvard.edu/rss/creativeCommonsRssModule.html">
    <channel>
        <title><![CDATA[Stories by Ashutosh Pal on Medium]]></title>
        <description><![CDATA[Stories by Ashutosh Pal on Medium]]></description>
        <link>https://medium.com/@ashutoshpal_47054?source=rss-8145bd4ddbe7------2</link>
        <image>
            <url>https://cdn-images-1.medium.com/fit/c/150/150/1*P2-0Kpv-qu_6PRrK9TyLjA.jpeg</url>
            <title>Stories by Ashutosh Pal on Medium</title>
            <link>https://medium.com/@ashutoshpal_47054?source=rss-8145bd4ddbe7------2</link>
        </image>
        <generator>Medium</generator>
        <lastBuildDate>Sun, 24 May 2026 02:25:55 GMT</lastBuildDate>
        <atom:link href="https://medium.com/@ashutoshpal_47054/feed" rel="self" type="application/rss+xml"/>
        <webMaster><![CDATA[yourfriends@medium.com]]></webMaster>
        <atom:link href="http://medium.superfeedr.com" rel="hub"/>
        <item>
            <title><![CDATA[OAuth 2.0, PKCE, and DPoP: A Story I Learned Building an MCP Gateway]]></title>
            <link>https://medium.com/@ashutoshpal_47054/oauth-2-0-pkce-and-dpop-a-story-i-learned-building-an-mcp-gateway-e2492240a64d?source=rss-8145bd4ddbe7------2</link>
            <guid isPermaLink="false">https://medium.com/p/e2492240a64d</guid>
            <category><![CDATA[mcps]]></category>
            <category><![CDATA[oauth2]]></category>
            <category><![CDATA[cybersecurity]]></category>
            <category><![CDATA[ai-agent]]></category>
            <category><![CDATA[software-engineering]]></category>
            <dc:creator><![CDATA[Ashutosh Pal]]></dc:creator>
            <pubDate>Fri, 22 May 2026 23:06:00 GMT</pubDate>
            <atom:updated>2026-05-22T23:06:00.394Z</atom:updated>
            <content:encoded><![CDATA[<p>For the last few weeks, I have been building an MCP (Model Context Protocol) gateway. On paper it sounds boring — it just sits between an AI agent and a bunch of tools. But the moment you let an LLM-powered agent talk to your real APIs on a user’s behalf, <em>every weakness in your auth layer becomes a weakness in your AI</em>. Once that sank in, I realised I didn’t really understand the auth layer I was about to depend on.</p><p>So I went down the rabbit hole on OAuth 2.0 — not the “click ‘Sign in with Google’ and copy a token” version, but the <em>why does every modern spec keep adding more layers on top of it</em> version. What I found is that the protocol I thought I knew is actually three separate ideas stacked on top of each other, each one a response to an attack the previous one couldn’t see coming.</p><p>This post is what I wish I had read before I started reading the RFCs. Let’s begin with the part everyone thinks they know.</p><h3>The Three-Legged Dance</h3><p>Before OAuth, sharing data between apps was barbaric. If you wanted a calendar app to read your Gmail, you handed it your Gmail password. The calendar app got full access, forever, to everything. Lose trust in the app? Change your Google password and pray you remembered every other app you gave it to.</p><p>OAuth 2.0 fixed this with a deceptively simple idea: <strong>never share the password — share a scoped, revocable, time-limited token instead.</strong> Three actors are involved: the <strong>user</strong> (resource owner), the <strong>client app</strong> that wants to act on the user’s behalf, and the <strong>authorization server</strong> that knows who the user is. (Plus the resource server holding the data, which just trusts the auth server.)</p><p>Here is the flow, stripped to its essentials:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*Yvtzmk905IxDFKh2BAxhUQ.png" /></figure><p>The key move is <strong>step 4</strong>. The auth server does <em>not</em> hand the access token directly to the client. It hands the user’s browser a short-lived <strong>authorization code</strong>, and the browser delivers it to the client. The client then trades that code (plus its client_secret) for the real token over a back-channel call the user&#39;s browser never sees.</p><p>This <strong>front-channel code, back-channel token</strong> split is what makes the original flow safe. The token never touches the address bar, never lands in browser history, never shows up in a referrer header.</p><h3>Hold on — anyone can hit /authorize?</h3><p>The first time I traced this, I had a sinking feeling. The client_secret only appears at step 5. Step 2 is just a URL the browser visits. The client_id is public — literally embedded in every &quot;Sign in with Google&quot; button on the internet. So what stops an attacker from crafting their own /authorize URL with my app&#39;s client_id?</p><p>The answer is the part of OAuth nobody talks about loudly enough: <strong>the security boundary is the registered </strong><strong>redirect_uri, not the </strong><strong>client_id.</strong></p><p>When you register a client, you hand the auth server an exact list of allowed redirect URIs. From then on, the auth server will <em>only</em> deliver codes to one of those URIs:</p><ul><li>Attacker sets redirect_uri=https://evil.com/steal → not in the allowlist → request rejected before a login page is ever shown.</li><li>Attacker sets the legit redirect_uri=https://myapp.com/callback → the code lands at the <em>real</em> app&#39;s server. The attacker never sees it.</li></ul><p>The auth server isn’t trying to authenticate <em>who is asking</em>. It just controls <em>where the result goes</em>. That’s a subtle but profound design choice: <strong>client_id is an identifier, not a credential.</strong> At /authorize, identification is all you need, because the delivery address is what matters.</p><p>Modern auth servers enforce <strong>exact-match</strong> redirect URI validation. No regex, no “starts with,” no “same host” — exact string match. The whole argument collapses if you allow wildcards there.</p><p>This worked beautifully for server-side web apps. Then mobile happened.</p><h3>When Mobile Broke It</h3><p>Mobile apps, SPAs, CLI tools, AI agents — suddenly the “client” was no longer a server you controlled. It was an iPhone app, a React bundle in a browser, a Python script on someone’s laptop.</p><p>These are <strong>public clients</strong>. They cannot keep a secret. A client_secret baked into an iOS binary is one strings command away from anyone. A secret in a JavaScript bundle is just... served to the world.</p><p>That alone would have been bad. But mobile gave us a second, sneakier problem.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*fg32NUvLqoHDAG_lsTg9Mg.png" /></figure><p>The legitimate app kicks off OAuth with a redirect to myapp://callback. The user logs in, the auth server tries to deliver the code — but on most mobile OSes, <strong>any app can register that custom URI scheme</strong>. If a malicious app got there first, the OS may happily hand the code to the attacker instead. With no real client_secret to differentiate the two apps, the auth server can&#39;t tell impostor from real.</p><p>The redirect URI defence from the previous section doesn’t help here. Both apps are claiming the same URI.</p><h3>Enter PKCE (RFC 7636, pronounced “pixy”)</h3><p>PKCE — <em>Proof Key for Code Exchange</em> — fixes this with a beautifully simple idea: <strong>let the client prove, at token-exchange time, that it is the same instance that started the flow</strong>, without needing a pre-shared secret.</p><ol><li>Before kicking off the flow, the client generates a random code_verifier — say, 64 bytes that only live in memory.</li><li>It hashes the verifier with SHA-256, base64url-encodes the result, and calls that the code_challenge.</li><li>The auth request carries the code_challenge (not the verifier).</li><li>The auth server stores code ↔ challenge.</li><li>At token exchange, the client must produce the original code_verifier.</li><li>The auth server hashes it, compares to the stored challenge, and only issues a token if they match.</li></ol><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*pMTcccVJhNs49uBZfikcxQ.png" /></figure><p>The attacker who intercepts the <em>code</em> never sees the <em>verifier</em>. SHA-256 is one-way, so they can’t derive it from the challenge either. The stolen code is a dead letter.</p><h3>What PKCE actually proves</h3><p>Crucial mental model: <strong>PKCE does not authenticate the client.</strong> Anyone with a known client_id can still start a PKCE flow with their own challenge. PKCE proves something different — and arguably more important:</p><blockquote>PKCE welds the two ends of one flow together. Whoever finishes at /token is the same party who started at /authorize.</blockquote><p>That property defeats both code interception (the attack above) <em>and</em> code injection (an attacker splicing their own code into a victim’s callback). The verifier you generated at the start is the only thing that lets you finish.</p><p>Two practical notes:</p><ul><li><strong>Always use </strong><strong>S256</strong>, never plain. plain exists only for ancient platforms with no SHA-256.</li><li><strong>PKCE and </strong><strong>state solve different problems.</strong> state stops <em>injection from outside</em> (CSRF on the callback); PKCE stops <em>interception of the result</em>. Use both.</li></ul><h3>“But my server can keep a secret — why PKCE there?”</h3><p>If your client is a confidential web app that holds a client_secret, doesn&#39;t the secret already do what PKCE does? Mechanically, yes — but PKCE protects against three things the secret alone cannot:</p><ol><li><strong>Secret leaks happen</strong> — committed .env files, log lines, container layers. The per-flow verifier was never persisted; a leaked secret alone is not enough to redeem stolen codes.</li><li><strong>Authorization-code injection.</strong> An attacker tricks a victim’s browser into completing the legit server’s callback with the <em>attacker’s</em> code, binding the victim’s session to the attacker’s account. The verifier mismatch shuts that down server-side.</li><li><strong>Codes leak through paths the secret doesn’t cover</strong> — open redirects, referrer headers, broken proxies. The secret protects only the token endpoint.</li></ol><p>How does PKCE work when the client is a server? The verifier lives <strong>server-side</strong> — in a session store, Redis, or a signed cookie — keyed by state. The browser never sees it.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*nzkv04gBoVd9Hjij7KeG4w.png" /></figure><p>Both the client_secret and the code_verifier are validated on /token. Belt and suspenders. This is why OAuth 2.1 now mandates PKCE for <strong>every</strong> authorization-code flow.</p><p>So PKCE locks down the handshake. Are we done?</p><p>Not quite.</p><h3>And Then Tokens Get Stolen</h3><p>Here is the dirty secret that took me a while to internalise: <strong>once the token is issued, OAuth 2.0 stops protecting you.</strong></p><p>An access token is a <em>bearer</em> token. It is literally a string. Whoever holds it, uses it. The resource server checks the signature or runs introspection and grants access. It does not care <em>who</em> is sending the request.</p><p>That is fine if your token always travels over TLS, never gets logged, never sits in a proxy, never lives in browser memory next to a malicious extension, never gets exfiltrated by a compromised dependency. In a system like an MCP gateway — where an agent’s runtime, the user’s machine, multiple downstream APIs, and a half-dozen libraries are all in the request path — that assumption gets shaky fast.</p><p>If an attacker grabs the token, they can replay it from anywhere until it expires. PKCE does nothing here; PKCE protected the handshake. The token is already out.</p><h3>Enter DPoP (RFC 9449)</h3><p>DPoP — <em>Demonstrating Proof-of-Possession</em> — was finalised in September 2023 and is rapidly becoming the answer for public clients (which, in 2026, includes basically every AI agent). The MCP spec calls it out explicitly as the recommended hardening. So this one hits very close to home.</p><p>The idea: <strong>bind the token to a key only the client has.</strong> Make it <em>sender-constrained</em>. A stolen token without the matching private key is just bytes.</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*6Bc029MsN6ztMmug7XJauw.png" /></figure><ol><li><strong>Once, at startup</strong>, the client generates an EC P-256 key pair. The private key stays on the device. Forever.</li><li><strong>For every token request</strong>, the client signs a short-lived JWT — a <strong>DPoP proof</strong> — with its private key and sends it in a DPoP: header.</li><li>The auth server verifies the proof and issues an access token carrying cnf.jkt — the SHA-256 thumbprint of the client&#39;s public key. The token is now <em>bound</em> to that key.</li><li><strong>For every API call</strong>, the client signs a <em>fresh</em> proof — this time also including ath, a hash of the access token being presented.</li><li>The resource server checks that cnf.jkt matches the public key in the proof <em>and</em> that the proof&#39;s signature is valid for this exact request.</li></ol><h3>Let’s open up the envelopes</h3><p>The summary above hides what is actually on the wire. Here is what the JWTs look like at each hop, drawn straight from RFC 9449:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*mrM789oMrj__VXs92H-5zw.png" /></figure><p>Four things to notice in the JSON:</p><ul><li><strong>The public key travels with every proof, in the JOSE header (</strong><strong>jwk).</strong> No JWKS endpoint needed. The resource server verifies the signature with the embedded JWK, then checks its SHA-256 thumbprint against cnf.jkt. Self-contained.</li><li><strong>htm and </strong><strong>htu make proofs per-request.</strong> A proof minted for GET /resource cannot be reused for POST /transfer.</li><li><strong>ath binds the proof to one specific token.</strong> Stolen proof + different token = rejected.</li><li><strong>jti enables replay detection.</strong> Resource servers cache seen jtis for the proof&#39;s validity window; duplicates get 401&#39;d.</li></ul><p>Even with the access token <em>and</em> a captured proof in hand, an attacker is stuck with one specific request, on one specific token, within a tiny time window — and replay detection catches the second attempt.</p><p>A few subtleties I found genuinely elegant:</p><ul><li><strong>Refresh tokens get bound too.</strong> Stealing a refresh token without the private key is useless.</li><li><strong>Optional server-issued </strong><strong>DPoP-Nonce</strong> shrinks the replay window further.</li><li><strong>No PKI.</strong> Unlike mTLS, there are no certificates to manage. The client just generates a key pair locally. Huge for AI agents and CLIs.</li></ul><p>This changes the threat model entirely. Even if a downstream tool, a logging pipeline, or a buggy library leaks a token, the leak is inert. The attacker would also need the private key, which never leaves the client.</p><h3>Putting It Together</h3><p>Three mental models worth holding onto:</p><figure><img alt="" src="https://cdn-images-1.medium.com/max/1024/1*HTBBrI2iREjunZeLUrGDVQ.png" /></figure><ol><li><strong>client_id is an identifier, not a credential.</strong> Public clients are inherently unauthenticated; the protocol relies on controlled delivery (redirect URI) instead.</li><li><strong>PKCE doesn’t authenticate the client.</strong> It welds the two ends of one flow together. A weaker-sounding guarantee than authentication, but exactly the right one for public clients.</li><li><strong>DPoP shifts the question from “is this token valid?” to “is this token in the right hands right now?”</strong> This is the layer that finally protects you from your own infrastructure leaking.</li></ol><p>Each layer assumes the previous one and patches a class of attack that became possible only when the world changed — mobile apps for PKCE, AI agents and sprawling service meshes for DPoP. If you are touching identity in 2026 — building an MCP gateway, shipping an AI agent, or wiring up any public client — you probably want all three. PKCE is table stakes. DPoP is the part that lets you sleep at night.</p><h3>Further Reading</h3><ul><li><a href="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</a> — OAuth 2.0</li><li><a href="https://datatracker.ietf.org/doc/html/rfc7636">RFC 7636</a> — PKCE</li><li><a href="https://www.ietf.org/rfc/rfc9449.html">RFC 9449</a> — DPoP</li><li><a href="https://datatracker.ietf.org/doc/html/rfc9700">RFC 9700</a> — OAuth 2.0 Security Best Current Practice</li><li>The MCP authorization spec — leans on OAuth 2.1 and points at sender-constrained tokens as the future.</li></ul><img src="https://medium.com/_/stat?event=post.clientViewed&referrerSource=full_rss&postId=e2492240a64d" width="1" height="1" alt="">]]></content:encoded>
        </item>
    </channel>
</rss>