prerequisite
OAuth basics
Access tokens, scopes, the authorization code flow with PKCE, issuers and audiences: the vocabulary MCP authorization is built from.
Before this
This page assumes you are comfortable with:
Why you need this
A remote MCP server that reads someone's calendar must know two things on every request: who is asking, and whether they are allowed to. MCP does not invent its own answer. It uses OAuth 2.1, the same system behind every "Sign in with..." button and "Allow this app to see your files?" screen. This page covers the OAuth vocabulary on its own, so that Authorization for remote servers can focus on what MCP adds.
The idea
Authentication versus authorization
Authentication answers "who are you?", usually with a password or a passkey. Authorization answers "what may you do?". OAuth is about authorization: it lets a person give an app limited access to their data without giving the app their password.
Four roles
| OAuth role | Plain meaning | Calendar example |
|---|---|---|
| Resource owner | The person whose data it is | You |
| Client | The app that wants access | A chat app on your laptop |
| Authorization server | Checks who you are, asks your consent, issues tokens | The calendar company's login service |
| Resource server | Holds the data, accepts tokens | The calendar API |
The word "client" means the same thing in OAuth as in MCP here: the app's side of the connection. The authorization server and the resource server can be one program or two.
Access tokens and the bearer header
An access token is a string the authorization server issues that says "the holder may do these things, until this time". The client sends it on every request in an HTTP header:
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOi...
"Bearer" means whoever holds the token can use it, like cash. That is why tokens must never appear in a URL (URLs end up in logs and browser history) and why they expire, often within an hour.
Scopes
A scope is a named permission, such as calendar:read or calendar:write. The client asks for scopes, the person approves them, and the token carries only what was approved. Asking for the smallest set that does the job is the principle of least privilege: if the token leaks, the damage is limited to those scopes.
Issuer and audience
Many access tokens are JSON Web Tokens (JWTs): three base64url pieces joined by dots, the middle one a JSON object of claims. Two claims matter most:
iss, the issuer: which authorization server made this token.aud, the audience: which resource server the token is for.
A resource server must check both. It accepts only tokens from the issuer it trusts, and only tokens whose audience is itself. Suppose your calendar token, with audience "calendar API", is handed to a photo service. If the photo service skipped the audience check, it would accept a token never meant for it, and anyone who stole a calendar token could use it there too. Checking aud is what stops a token for one server from working at another.
Worked example
You connect a chat app to your calendar. Below is the authorization code flow, the standard OAuth flow for an app acting on behalf of a person.
Step 0: the client makes a PKCE pair
PKCE (Proof Key for Code Exchange, said "pixie") protects the flow against someone who intercepts the authorization code in step 3. Before starting, the client:
- Picks a random secret, the code verifier: 43 to 128 characters from letters, digits, and
-._~. - Hashes it with SHA-256, a function that turns any input into 32 bytes that cannot be run backwards.
- Encodes those 32 bytes in base64url (a text encoding of bytes using letters, digits,
-and_) and drops the trailing=padding. That is the code challenge. This method is calledS256.
This script does exactly that:
import base64
import hashlib
verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" # 43 chars from the unreserved set
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
print("code_verifier: ", verifier, f"({len(verifier)} chars)")
print("SHA-256 bytes: ", digest.hex(" "))
print("byte count: ", len(digest))
print("code_challenge:", challenge, f"({len(challenge)} chars)")
print("wrong verifier:", base64.urlsafe_b64encode(
hashlib.sha256(b"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXl").digest()).rstrip(b"=").decode())
Run with python pkce.py:
code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk (43 chars)
SHA-256 bytes: 13 d3 1e 96 1a 1a d8 ec 2f 16 b1 0c 4c 98 2e 08 76 a8 78 ad 6d f1 44 56 6e e1 89 4a cb 70 f9 c3
byte count: 32
code_challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM (43 chars)
wrong verifier: P5uWm2WHuiZkzwI-fJYP30ZhimUR2kOTekHrkt0PwoU
32 bytes is 256 bits; base64url packs 6 bits per character, so characters. This verifier and challenge are the example pair printed in the PKCE standard itself (RFC 7636), which is a handy check that your own code is right. The last line changes only the verifier's final character, k to l, and the challenge is completely different. That is why a thief who sees the challenge cannot work out the verifier.
Steps 1 to 6
| Step | From, to | What is sent | Why |
|---|---|---|---|
| 1 | Client opens the person's browser at https://auth.example.com/authorize |
response_type=code, client_id, redirect_uri, scope=calendar:read, state (a random value), code_challenge=E9Mel...w-cM, code_challenge_method=S256 |
Asks for permission. The secret verifier stays in the client. |
| 2 | Authorization server to person | Login page, then "Allow Chat App to read your calendar?" | Authentication, then consent to the scope. |
| 3 | Browser to redirect_uri |
code=SplxlOBeZQQYbYS6WxSbIA, the same state |
A short-lived, single-use authorization code, not yet a token. The client checks state matches. |
| 4 | Client to https://auth.example.com/token |
grant_type=authorization_code, the code, redirect_uri, client_id, code_verifier=dBjf...EjXk |
Trades the code for tokens. |
| 5 | Authorization server to client | access_token, token_type: Bearer, expires_in: 3600, refresh_token, scope: calendar:read |
It hashes the verifier, gets E9Mel...w-cM, matches step 1, so it issues tokens. |
| 6 | Client to https://calendar.example.com |
Authorization: Bearer <access_token> |
The resource server checks signature, expiry, iss, aud, and scope, then answers. |
If an attacker grabbed the code in step 3, step 4 still fails for them: they have the code but not the verifier.
Refresh tokens
When the access token expires an hour later, the client does not send you back to the login page. It posts grant_type=refresh_token and the refresh token from step 5 to the token endpoint and gets a fresh access token. Refresh tokens live longer, so they are kept more carefully, and for apps that cannot keep a secret (anything installed on a person's device) OAuth 2.1 requires the authorization server to issue a new refresh token each time and retire the old one, so a stolen refresh token stops working once either party uses it.
In a server's life
This is the groundwork for stage 4, "Secure it". A remote MCP server plays the resource server role, the host's MCP client plays the OAuth client, and a separate service usually plays the authorization server. Authorization for remote servers adds how the client finds that authorization server and registers with it.
Common mistakes
- Token in the query string. Symptom: access tokens show up in server logs and proxy logs. Always use the
Authorizationheader. - Skipping the audience check. Symptom: tokens issued for some other service work at yours, and a breach elsewhere becomes a breach of your server.
- Asking for every scope up front. Symptom: people decline a consent screen listing permissions they do not understand, and a leaked token can do everything.
- Using
plaininstead ofS256for PKCE. Symptom: the challenge equals the verifier, so anyone who sees step 1 can finish step 4. - Not checking
state. Symptom: an attacker can slip their own authorization code into your callback and link your session to their account.
Cost
The flow costs the person one browser visit and one consent click, once; after that, refresh tokens keep the app working silently. For the client it is two extra HTTP requests at sign-in (authorize, token) and one per refresh. For the resource server, checking a JWT is a signature verification and a few claim comparisons per request, small next to the work the request asks for, with the issuer's public keys fetched once and cached. The engineering cost is in getting the details right, which is why most teams use a tested library and an existing authorization server instead of writing either.
Going further
- The OAuth 2.1 draft, which folds PKCE and other best practices into the core.
- RFC 7636, the PKCE standard, whose appendix has the example pair above.
- JSON Web Tokens and how a resource server checks a JWT signature with the issuer's published keys.
- OpenID Connect, which adds authentication (an ID token) on top of OAuth.