NamoID Docs

MCP server with NamoID

NamoID is the authorization server (it issues tokens). Your MCP server is the resource server (it validates them and serves tools/data to AI agents). This guide covers the resource-server side: how to advertise NamoID, validate the tokens it issues, and the audience check that is the whole point.

If you take one thing from this guide: validate that every token's aud is your MCP server, and never forward a token to anything downstream. That single check is what stops the confused-deputy attack the MCP spec is built to prevent.

Note: https://mcp.yourapp.in throughout this guide is a stand-in for your own MCP server's URL — replace it with your real domain everywhere it appears. https://namoid.in is the real NamoID issuer you point your server and agents at.

The roles

Agent (MCP client)  ──►  Your MCP server (resource server)  ──►  NamoID (authorization server)
   holds the token         validates the token                    issues the token
  • NamoID runs sign-in, MFA, consent, and issues access tokens (RS256, published via JWKS).
  • Your MCP server accepts requests with a Bearer token, validates it locally (verify the signature against NamoID's JWKS), and serves the response. It never issues tokens and never asks NamoID to validate one.

1. Advertise NamoID (Protected Resource Metadata)

An agent that hits your server without a token needs to discover where to authenticate. Implement both mechanisms:

a) A 401 with a WWW-Authenticate header pointing at your protected-resource metadata, with the scopes you need:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.yourapp.in/.well-known/oauth-protected-resource",
                         scope="tools:read"

b) A well-known metadata document at https://mcp.yourapp.in/.well-known/oauth-protected-resource that names NamoID as the authorization server:

{
  "resource": "https://mcp.yourapp.in",
  "authorization_servers": ["https://namoid.in"],
  "scopes_supported": ["tools:read", "tools:write"],
  "bearer_methods_supported": ["header"]
}

The agent reads this, then fetches NamoID's authorization-server metadata at https://namoid.in/.well-known/oauth-authorization-server (or /.well-known/openid-configuration) to learn the endpoints and PKCE support. NamoID serves both.

2. The canonical URI of your server

Pick one canonical URI for your MCP server and use it consistently — it's the resource value agents send to NamoID and the aud you'll validate. It's an absolute https URI with no fragment, e.g. https://mcp.yourapp.in or https://mcp.yourapp.in/mcp. Use the form without a trailing slash.

Agents include it on both the authorization and token requests (&resource=https%3A%2F%2Fmcp.yourapp.in), and NamoID binds the issued access token's aud to it.

3. Validate the token

On every request, validate the Bearer token locally against NamoID's JWKS:

  1. Signature — verify RS256 against the key from https://namoid.in/.well-known/jwks.json (match by kid; cache the JWKS, refetch on an unknown kid).
  2. Issueriss must be NamoID's issuer.
  3. Expiry — enforce exp/nbf with a small leeway.
  4. Audienceaud MUST be your canonical URI. Reject any token whose audience is something else.

A minimal FastAPI dependency:

import jwt
from jwt import PyJWKClient
from fastapi import Header, HTTPException

JWKS = PyJWKClient("https://namoid.in/.well-known/jwks.json")
ISSUER = "https://namoid.in"
THIS_SERVER = "https://mcp.yourapp.in"   # your canonical URI

def require_token(authorization: str = Header(default="")) -> dict:
    if not authorization.startswith("Bearer "):
        raise HTTPException(401, headers={
            "WWW-Authenticate": 'Bearer resource_metadata='
            '"https://mcp.yourapp.in/.well-known/oauth-protected-resource"'
        })
    token = authorization.removeprefix("Bearer ")
    try:
        return jwt.decode(
            token,
            JWKS.get_signing_key_from_jwt(token).key,
            algorithms=["RS256"],
            issuer=ISSUER,
            audience=THIS_SERVER,          # rejects tokens for other audiences
            leeway=30,
            options={"require": ["exp", "iss", "aud"]},
        )
    except jwt.PyJWTError:
        raise HTTPException(401)

NamoID emits the audience-bound aud claim when the agent sends the resource parameter, so audience=THIS_SERVER is doing the security work.

4. Insufficient scope → step-up

If the token is valid but lacks a scope the operation needs, return 403 with a challenge so the agent can request a stronger token:

HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope",
                         scope="tools:write",
                         resource_metadata="https://mcp.yourapp.in/.well-known/oauth-protected-resource"

Keep the scope you challenge with to the minimum the request needs.

5. Two things you must not do

Don't accept a token whose aud isn't you. Accepting tokens for other resources is the core OAuth boundary violation — it's how cross-service token reuse and confused-deputy attacks work.

Don't pass the agent's token downstream. If your MCP server calls another API, get a separate token for it. Forwarding the agent's token makes the downstream service a confused deputy.

The full flow

1. Agent → your MCP server (no token)        → 401 + WWW-Authenticate (resource_metadata, scope)
2. Agent → your /.well-known/oauth-protected-resource  → { authorization_servers: [namoid.in], ... }
3. Agent → namoid.in/.well-known/oauth-authorization-server  → AS metadata (endpoints, PKCE)
4. Agent → NamoID authorize (PKCE + resource=<your URI>)     → user signs in / consents → code
5. Agent → NamoID token (code + verifier + resource)        → access token, aud=<your URI>
6. Agent → your MCP server (Bearer token)    → you validate signature/iss/exp/AUD → serve

NamoID also supports OAuth Client ID Metadata Documents, so agents can identify with an HTTPS-URL client_id and onboard with no pre-registration — the hosted authorization server advertises client_id_metadata_document_supported in its metadata.

Resource-server checklist

☐ Serve /.well-known/oauth-protected-resource naming https://namoid.in as the authorization server
☐ Return 401 + WWW-Authenticate (resource_metadata + scope) when no/invalid token
☐ One canonical https URI for your server, no trailing slash — used as resource + aud
☐ Validate every token: RS256 via NamoID JWKS · iss · exp/nbf · aud == your URI
☐ Cache JWKS; refetch on unknown kid
☐ 403 + WWW-Authenticate error="insufficient_scope" for step-up
☐ Never accept a token for a different aud
☐ Never forward the agent's token to a downstream API — get your own