<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://avneeshk.me/feed/by_tag/oauth.xml" rel="self" type="application/atom+xml" /><link href="https://avneeshk.me/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-10-18T06:23:53+00:00</updated><id>https://avneeshk.me/feed/by_tag/oauth.xml</id><title type="html">Avneesh Kasture</title><subtitle>Building secure MCP servers for clients — hardening the agentic layer against prompt injection, tool misuse, and privilege abuse. Field notes on MCP security, agentic safety, and shipping AI platforms.</subtitle><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><entry><title type="html">The user signed in twice. Was it the same user both times?</title><link href="https://avneeshk.me/blog/two-logins-two-identities/" rel="alternate" type="text/html" title="The user signed in twice. Was it the same user both times?" /><published>2026-10-18T05:20:00+00:00</published><updated>2026-10-18T05:20:00+00:00</updated><id>https://avneeshk.me/blog/two-logins-two-identities</id><content type="html" xml:base="https://avneeshk.me/blog/two-logins-two-identities/"><![CDATA[<p>Suppose an agent acts on a downstream API for the user. The user signs in to
your identity provider, so you know who they are. Then the agent runs an OAuth
flow against the downstream API, so it has a token to act with. Every action
goes into an audit log under the identity from the first step:
“alice@example.com refunded order 4471.”</p>

<p>Is that log line true?</p>

<p>Only if the downstream’s single sign-on is federated to your identity provider.
Without federation, the user signed in twice to two unrelated accounts, and the
log can name the wrong person.</p>

<p>Okay, so what does federation do here? The second login doesn’t use a
downstream password. The downstream sends the user back to your identity
provider, which already knows it’s Alice, and Alice’s account at the downstream
is the one that gets the token. Your Conditional Access and MFA policies apply
to that sign-in too, as long as they
<a href="https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-cloud-apps">target the downstream app</a>
or all resources. The identity you verified is the identity that acts.</p>

<p>Now take federation away. The second login is a separate login, and the user
types whatever credentials they like into the downstream’s own sign-in page.
Maybe they’re Alice’s. Maybe Alice is signed in to the downstream in that
browser as the shared support account, or as her personal account, or as Bob,
because Bob was using the laptop yesterday. Both tokens are real. Nothing in
the flow connects them. You verified one person in step one and got a token for
some account in step two.</p>

<p>A token proves that some account authorized the agent to act. It doesn’t say
which human was at the keyboard, and it says nothing at all about any other
token. Federation is what links the two.</p>

<p>The reason federation is missing is often mundane. The downstream account is on
a plan that doesn’t offer SSO, so there’s nothing to federate.</p>

<p>Now, you might say, “So the log is a little loose. Alice was still the one who
signed in.” Let’s run the usual check. Who is the attacker? Anyone who completes
the downstream login with an account other than Alice’s, which includes Bob from
yesterday and doesn’t require any malice. Who is the victim? Alice, whose name
is on the refund, and whoever reads the log later. What did the attacker gain?
No new access, since they already held those downstream credentials. They
gained a log that credits their actions to someone else.</p>

<p>The audit log exists to answer one question, usually under pressure: who did
this? This one looks complete and well-formed, and it points at the wrong
person. That’s worse than having no log, because people will believe this one.</p>

<p>So don’t write “the downstream is federated to your identity provider” in a
design doc until you’ve seen the SSO configuration on the downstream side.
Check that the customer’s plan supports SSO at all, because many plans don’t.
Check that SSO is required, not just available: Slack, for example, lets an
admin make it
<a href="https://slack.com/help/articles/203772216-SAML-single-sign-on">required, partially required or optional</a>,
and lets workspace owners bypass it with an email and password. Then it’s one
of these cases.</p>

<ul>
  <li><strong>Federation is there.</strong> Keep both legs. You get your access policies on the
downstream and one verified person across the whole chain.</li>
  <li><strong>Federation isn’t there, and the deployment is single-user or local.</strong> Drop
your identity-provider leg. Ask the downstream who the token belongs to,
through its <code class="language-plaintext highlighter-rouge">userinfo</code> endpoint or equivalent, and record that. The identity
in the log is then the identity that acted. Don’t make the user sign in twice
to establish a connection you can’t establish.</li>
  <li><strong>Federation isn’t there, and you need multi-user accountability.</strong> Then you
need federation, or you record both identities and stop claiming they’re the
same person: “alice@example.com (signed in), acting as support@vendor
(downstream token).”</li>
</ul>

<p><strong>Exercise:</strong> You check the <code class="language-plaintext highlighter-rouge">userinfo</code> response from the downstream after the
second login, and its <code class="language-plaintext highlighter-rouge">email</code> field matches the email your identity provider
returned: both say <code class="language-plaintext highlighter-rouge">alice@example.com</code>. Doesn’t that prove the two logins were
the same person?</p>

<details class="post-answer">
  <summary>Show an answer</summary>
  
<p>No. It proves that two accounts in two separate systems have the same email string.</p>

<p>Without federation, each system decided on its own who <code class="language-plaintext highlighter-rouge">alice@example.com</code> is. The downstream might let users set their own email, or not verify it. The account might be a shared one that someone registered with Alice’s address years ago. Nobody proved to both systems that it was the same person.</p>

<p>Email matching is fine as a hint to show a human (“these look like the same person, confirm?”). It doesn’t belong in an audit log as fact. The comparison that holds up is a stable identifier that one system vouches for to the other, which is what federation provides: the downstream account is linked to the identity provider’s subject, and the downstream sent the user to your identity provider to sign in.</p>

</details>]]></content><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><category term="mcp" /><category term="identity" /><category term="oauth" /><summary type="html"><![CDATA[The two-leg auth pattern (your identity provider for who the user is, OAuth for the downstream API) only describes one person when the downstream's SSO is federated to that identity provider. Without federation the two logins are to two unrelated accounts, and the audit log can name the wrong person. How to check, and what to build when federation isn't there.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://avneeshk.me/assets/og/two-logins-two-identities.png" /><media:content medium="image" url="https://avneeshk.me/assets/og/two-logins-two-identities.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Why does the OAuth flow that worked in my demo fail against a real provider?</title><link href="https://avneeshk.me/blog/oauth-providers-disagree/" rel="alternate" type="text/html" title="Why does the OAuth flow that worked in my demo fail against a real provider?" /><published>2026-10-18T05:10:00+00:00</published><updated>2026-10-18T05:10:00+00:00</updated><id>https://avneeshk.me/blog/oauth-providers-disagree</id><content type="html" xml:base="https://avneeshk.me/blog/oauth-providers-disagree/"><![CDATA[<p>Suppose someone reports the following. They wrote an MCP server that connects
to a downstream API on the user’s behalf. They read the OAuth 2.1 draft, chose
Authorization Code with PKCE, tested against their own developer account, and
everything worked. Then the first customer tried it, and the flow failed. For a
second customer it failed somewhere else.</p>

<p>They followed the spec. So what went wrong?</p>

<p>Well, the spec describes what an authorization server <em>may</em> do, and it offers a
lot of options. Every provider picks its own subset. The demo tested the subset
one provider picked for one account. Let’s look at three places where the picks
tend to differ.</p>

<p>Start with PKCE. It’s the right default, and the 2.1 draft wants it everywhere.
Now, you might say, “The token request carries a <code class="language-plaintext highlighter-rouge">code_verifier</code>, so it proves
who I am. I don’t need the secret.” For a public client, like a desktop app
that can’t keep a secret, that usually holds.</p>

<p>Aha, but if the provider registered your app as a confidential client, PKCE
goes on top of the client secret. It doesn’t replace it. The token endpoint
expects <code class="language-plaintext highlighter-rouge">client_id</code> and <code class="language-plaintext highlighter-rouge">client_secret</code>, and a request that brings only a
<code class="language-plaintext highlighter-rouge">code_verifier</code> gets back <code class="language-plaintext highlighter-rouge">invalid_client</code>. Some older providers go further and
don’t recognize the PKCE parameters at all.</p>

<p>So before your server promises “Authorization Code with PKCE” for a downstream
API, find out which kind of client the provider thinks you are. Then read the
token endpoint’s documentation to see whether it accepts a <code class="language-plaintext highlighter-rouge">code_verifier</code>,
requires one, or ignores it. (Keep PKCE on the leg where your users sign in to
your own identity provider regardless. This is about the downstream leg.)</p>

<p>Okay, next question: does any localhost port count as a match? A desktop MCP
server usually catches the OAuth callback on a loopback address, and it’s
convenient to grab whatever port is free and put it in the redirect URI.</p>

<p>Some providers accept that. Microsoft Entra, for example, ignores the port when
it matches a <code class="language-plaintext highlighter-rouge">http://localhost</code> redirect URI, so an ephemeral port works. Other
providers, or other client types at the same provider, compare the redirect URI
to the registered one exactly. Google does this for a web application client
(its desktop client takes any loopback port), and an unregistered port fails
with <code class="language-plaintext highlighter-rouge">redirect_uri_mismatch</code>.</p>

<p>For the exact-match providers, don’t float the port. Pick a fixed loopback port
and make it configurable. Then register <code class="language-plaintext highlighter-rouge">http://localhost:&lt;port&gt;/callback</code>
exactly as your server will send it. Which rule your provider uses decides
whether the port is allowed to move, so find out before you write the listener.</p>

<p>The last one starts from a reasonable belief: “The documentation lists the
endpoint, so the account can call it.”</p>

<p>Not necessarily. Providers gate features by plan, including AI endpoints, admin
APIs, SSO and SAML configuration. A free developer account often has no admin
console at all and can’t configure SSO. Your integration works against your
account and fails against the customer’s, because the customer is on a tier
that doesn’t include the endpoint you built on. Or it goes the other way. You
tested on a trial that had everything turned on, and the trial ended.</p>

<p>Check the endpoints you depend on against the plan the customer actually has,
and tell them about the limits you find. “This works on the free plan, but the
AI endpoint is rate-capped there, and SSO needs the Business plan” is a much
better sentence to say before deployment than after.</p>

<p><strong>Postscript:</strong> The spec does have an opinion on the port question. RFC 8252,
the Best Current Practice for OAuth in native apps, says an authorization server
MUST allow any port on a loopback IP redirect URI, because a native app can’t
count on a particular port being free. It also recommends the literal
<code class="language-plaintext highlighter-rouge">127.0.0.1</code> over the name <code class="language-plaintext highlighter-rouge">localhost</code>, partly because <code class="language-plaintext highlighter-rouge">localhost</code> is a name that
something else has to resolve, and resolution can be misconfigured. Providers
that require an exact port aren’t following that advice, and you have to work
with them anyway.</p>

<p><strong>Exercise:</strong> Your server uses a fixed callback port, 8765, registered with an
exact-match provider. A user reports that the sign-in page opens, they approve
access, and then the browser shows “connection refused.” The provider’s logs
show that the authorization succeeded. What happened?</p>

<details class="post-answer">
  <summary>Show an answer</summary>
  
<p>The provider did its part. It redirected the browser to <code class="language-plaintext highlighter-rouge">http://localhost:8765/callback?code=…</code>, and nobody was listening on 8765 to receive it.</p>

<p>My guess is that the port was taken. Another program, or a second copy of your own server, was already bound to 8765, so your listener failed to start and the code went nowhere. With an exact-match provider you can’t just move to another port, because the redirect URI would no longer match the registration.</p>

<p>That’s the cost of a fixed port. Check that the listener actually bound before you open the browser. If it didn’t, say “port 8765 is in use” rather than leaving the user on a dead page. (There’s a second possibility. The redirect went to <code class="language-plaintext highlighter-rouge">localhost</code>, which resolved to <code class="language-plaintext highlighter-rouge">::1</code>, while the listener was bound only to <code class="language-plaintext highlighter-rouge">127.0.0.1</code>. RFC 8252 prefers the literal address partly to avoid that kind of resolution surprise.)</p>

</details>]]></content><author><name>Avneesh Kasture</name><email>apkasture02@gmail.com</email></author><category term="mcp" /><category term="oauth" /><summary type="html"><![CDATA[Three OAuth assumptions that pass in a demo and fail in production when an MCP server connects to a downstream API: PKCE does not replace a confidential client's secret, loopback redirect matching is exact for some providers and any-port for others, and endpoints are gated by plan tier. Check each against the provider's own reference, not the spec.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://avneeshk.me/assets/og/oauth-providers-disagree.png" /><media:content medium="image" url="https://avneeshk.me/assets/og/oauth-providers-disagree.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>