Authentication Middleware, JWT & Google IAP¶
Authentication for the /mcp endpoint is a native Litestar middleware concern. Whatever authentication middleware your application installs—a custom AbstractAuthenticationMiddleware, Litestar's OAuth2PasswordBearerAuth, litestar-security, or an identity-proxy JWT assertion validator—executes before any route handler. When MCPHandlerService dispatches a tool, resource, or route-bound prompt through the internal ASGI pipeline, the target handler inherits request.user and request.auth identically to a direct HTTP request.
litestar-mcp ships no proprietary token validator, JWKS cache, or OAuth discovery route of its own so your HTTP API and MCP surface never diverge.
1. Bring Your Own Litestar Middleware (AbstractAuthenticationMiddleware)¶
Subclass litestar.middleware.AbstractAuthenticationMiddleware and register it with DefineMiddleware on your Litestar application. The middleware inspects the inbound ASGIConnection (headers, cookies, or client certificates) and returns an AuthenticationResult(user=..., auth=...):
"""Snippet: authenticate MCP with the app's own Litestar middleware. Referenced from docs/usage/auth.rst."""
from typing import Any
from litestar import Litestar, Request, get
from litestar.connection import ASGIConnection
from litestar.exceptions import NotAuthorizedException
from litestar.middleware import AbstractAuthenticationMiddleware, AuthenticationResult, DefineMiddleware
from litestar_mcp import LitestarMCP
API_KEYS = {"let-me-in": {"sub": "demo-user", "roles": ["reader"]}}
# start-example
class ApiKeyAuthenticationMiddleware(AbstractAuthenticationMiddleware):
"""Populate ``request.user`` / ``request.auth`` from an ``X-API-Key`` header."""
async def authenticate_request(self, connection: "ASGIConnection[Any, Any, Any, Any]") -> "AuthenticationResult":
claims = API_KEYS.get(connection.headers.get("X-API-Key", ""))
if claims is None:
msg = "Invalid API key"
raise NotAuthorizedException(msg)
return AuthenticationResult(user=claims["sub"], auth=claims)
@get("/whoami", mcp_tool=True, sync_to_thread=False)
def whoami(request: "Request[Any, Any, Any]") -> "dict[str, Any]":
return {"user": request.user, "roles": request.auth["roles"]}
app = Litestar(
route_handlers=[whoami],
middleware=[DefineMiddleware(ApiKeyAuthenticationMiddleware)],
plugins=[LitestarMCP()],
)
# end-example
When a client sends POST /mcp with X-API-Key: let-me-in:
ApiKeyAuthenticationMiddleware.authenticate_requestvalidates the header on the/mcprequest and populatesscope["user"]andscope["auth"].- During
tools/callforwhoami,execute_toolsynthesizes an internal ASGI scope that copiesrequest.user,request.auth, and request headers from the parent/mcprequest. - The
whoamihandler readsrequest.user("demo-user") andrequest.auth["roles"](["reader"]) without any MCP-specific branching.
2. JWT & OAuth2PasswordBearerAuth¶
Litestar's built-in JWT backends (litestar[jwt]) work unchanged with LitestarMCP. Install litestar[jwt] alongside litestar-mcp:
Configure OAuth2PasswordBearerAuth with your retrieve_user_handler, token_secret, accepted_issuers, and accepted_audiences, then attach oauth_backend.on_app_init to Litestar(on_app_init=[...]):
def build_oauth_backend(
*,
secret: "str",
issuer: "str" = DEFAULT_ISSUER,
audience: "str" = DEFAULT_AUDIENCE,
token_url: "str" = DEFAULT_TOKEN_PATH,
exclude: "list[str] | None" = None,
) -> "OAuth2PasswordBearerAuth[AuthenticatedIdentity, Token]":
"""Build the Litestar OAuth2 bearer backend for the reference notes examples."""
return OAuth2PasswordBearerAuth[AuthenticatedIdentity, Token](
token_secret=secret,
token_url=token_url,
retrieve_user_handler=_retrieve_identity_from_token,
algorithm=DEFAULT_ALGORITHM,
exclude=exclude or ["^/.well-known/"],
accepted_issuers=[issuer],
accepted_audiences=[audience],
)
def _sign(sub: "str") -> "str":
return mint_hs256_token(sub, secret=token_secret, issuer=issuer, audience=audience)
oauth_backend = build_oauth_backend(secret=token_secret, issuer=issuer, audience=audience)
async def _provide_resolved_user(request: "Request[Any, Any, Any]") -> "AuthenticatedIdentity":
user = request.user
if not isinstance(user, AuthenticatedIdentity):
msg = "Authenticated identity is required"
raise NotAuthorizedException(msg)
return user
async def note_service_provider() -> "AsyncIterator[SQLSpecNoteService]":
async with provide_note_service(sqlspec, config) as service:
yield service
class NoteController(Controller):
path = "/notes"
dependencies = {
"note_service": Provide(note_service_provider),
"resolved_user": Provide(_provide_resolved_user),
}
@get("/", mcp_tool=True)
async def list_notes(
self, note_service: "SQLSpecNoteService", resolved_user: "AuthenticatedIdentity"
) -> "list[Note]":
rows = await note_service.list_for_owner(resolved_user.sub)
return [msgspec.convert(note_row_to_public(row), Note) for row in rows]
@post("/", mcp_tool=True)
async def create_note(
self, data: "CreateNoteInput", note_service: "SQLSpecNoteService", resolved_user: "AuthenticatedIdentity"
) -> "Note":
row = await note_service.create(title=data.title, body=data.body, owner_sub=resolved_user.sub)
return msgspec.convert(note_row_to_public(row), Note)
@delete("/{note_id:str}", status_code=HTTP_200_OK, mcp_tool=True)
async def delete_note(
self, note_id: "str", note_service: "SQLSpecNoteService", resolved_user: "AuthenticatedIdentity"
) -> "DeleteNoteResult":
deleted = await note_service.delete_for_owner(note_id, resolved_user.sub)
return DeleteNoteResult(deleted=deleted, note_id=note_id)
@get("/notes/schema", mcp_resource=True, sync_to_thread=False)
def notes_schema() -> "NotesSchema":
return NotesSchema()
@get("/app/info", mcp_resource=APP_INFO_RESOURCE_NAME, sync_to_thread=False)
def get_api_info() -> "AppInfo":
return build_app_info(backend="sqlspec", auth_mode="jwt")
async def on_startup() -> "None":
await bootstrap_schema(sqlspec, config)
return Litestar(
route_handlers=[
build_login_controller(user_directory=user_directory or DEFAULT_USER_DIRECTORY, token_signer=_sign),
NoteController,
notes_schema,
get_api_info,
],
on_startup=[on_startup],
plugins=[SQLSpecPlugin(sqlspec), LitestarMCP()],
on_app_init=[oauth_backend.on_app_init],
)
Excluding Public Discovery Paths
Pass exclude=["^/.well-known/", "^/healthz"] to OAuth2PasswordBearerAuth (or your custom AbstractAuthenticationMiddleware) when your application serves unauthenticated health checks, RFC 9728 /.well-known/oauth-protected-resource documents, or public A2A /.well-known/agent-card.json metadata alongside protected /mcp endpoints.
3. Identity Proxies & Google Cloud IAP (x-goog-iap-jwt-assertion)¶
Zero-trust identity proxies such as Google Cloud Identity-Aware Proxy (IAP) authenticate the user upstream at the load balancer and forward a signed ES256 JWT assertion in the x-goog-iap-jwt-assertion HTTP header.
To authenticate MCP and REST calls behind Google IAP, verify the x-goog-iap-jwt-assertion header against Google's public JWKS (https://www.gstatic.com/iap/verify/public_key-jwk), enforcing both issuer="https://cloud.google.com/iap" and your Cloud Run / Backend Service audience:
class IAPAuthenticationMiddleware(AbstractAuthenticationMiddleware):
"""Validate the Google IAP assertion header and populate ``request.user`` / ``request.auth``."""
def __init__(
self,
app: "ASGIApp",
*,
audience: "str",
issuer: "str" = DEFAULT_IAP_ISSUER,
jwks_url: "str" = DEFAULT_IAP_JWKS_URL,
**kwargs: "Any",
) -> "None":
super().__init__(app, **kwargs)
self._validate = build_iap_token_validator(audience=audience, issuer=issuer, jwks_url=jwks_url)
async def authenticate_request(self, connection: "ASGIConnection[Any, Any, Any, Any]") -> "AuthenticationResult":
token = connection.headers.get(IAP_HEADER_NAME)
if not token:
msg = "Missing IAP assertion"
raise NotAuthorizedException(msg)
claims = await self._validate(token)
if claims is None:
msg = "Invalid IAP assertion"
raise NotAuthorizedException(msg)
return AuthenticationResult(user=identity_from_claims(claims), auth=claims)
def build_iap_auth_middleware(
*, audience: "str", issuer: "str" = DEFAULT_IAP_ISSUER, jwks_url: "str" = DEFAULT_IAP_JWKS_URL
) -> "DefineMiddleware":
"""Build the IAP authentication middleware for the reference notes example."""
return DefineMiddleware(IAPAuthenticationMiddleware, audience=audience, issuer=issuer, jwks_url=jwks_url)
async def _provide_resolved_user(request: "Request[Any, Any, Any]") -> "AuthenticatedIdentity":
user = request.user
if isinstance(user, AuthenticatedIdentity):
return user
auth = request.scope.get("auth")
if isinstance(auth, dict):
return identity_from_claims(auth)
msg = "Missing IAP identity"
raise NotAuthorizedException(msg)
async def note_service_provider() -> "AsyncIterator[SQLSpecNoteService]":
async with provide_note_service(sqlspec, config) as service:
yield service
class NoteController(Controller):
path = "/notes"
dependencies = {
"note_service": Provide(note_service_provider),
"resolved_user": Provide(_provide_resolved_user),
}
@get("/", mcp_tool=True)
async def list_notes(
self, note_service: "SQLSpecNoteService", resolved_user: "AuthenticatedIdentity"
) -> "list[Note]":
rows = await note_service.list_for_owner(resolved_user.sub)
return [msgspec.convert(note_row_to_public(row), Note) for row in rows]
@post("/", mcp_tool=True)
async def create_note(
self, data: "CreateNoteInput", note_service: "SQLSpecNoteService", resolved_user: "AuthenticatedIdentity"
) -> "Note":
row = await note_service.create(title=data.title, body=data.body, owner_sub=resolved_user.sub)
return msgspec.convert(note_row_to_public(row), Note)
@delete("/{note_id:str}", status_code=HTTP_200_OK, mcp_tool=True)
async def delete_note(
self, note_id: "str", note_service: "SQLSpecNoteService", resolved_user: "AuthenticatedIdentity"
) -> "DeleteNoteResult":
deleted = await note_service.delete_for_owner(note_id, resolved_user.sub)
return DeleteNoteResult(deleted=deleted, note_id=note_id)
@get("/notes/schema", mcp_resource=True, sync_to_thread=False)
def notes_schema() -> "NotesSchema":
return NotesSchema()
@get("/app/info", mcp_resource=APP_INFO_RESOURCE_NAME, sync_to_thread=False)
def get_api_info() -> "AppInfo":
return build_app_info(backend="sqlspec", auth_mode="google_iap")
async def on_startup() -> "None":
await bootstrap_schema(sqlspec, config)
return Litestar(
route_handlers=[NoteController, notes_schema, get_api_info],
on_startup=[on_startup],
plugins=[SQLSpecPlugin(sqlspec), LitestarMCP()],
middleware=[build_iap_auth_middleware(audience=audience, issuer=issuer, jwks_url=jwks_url)],
)
See Notes Reference Examples Matrix for runnable SQLSpec and Advanced Alchemy variants covering no-auth, JWT, Cloud Run, and Google IAP with both Litestar Provide and Dishka (FromDishka[T]) dependency injection.
4. Authenticating Stdio & HTTP Bridge Sessions¶
In-Process Stdio (MCPStdioContext)¶
When running in-process over standard input/output (litestar mcp stdio, run_stdio, run_stdio_async, or MCP.run(transport="stdio")), every JSON-RPC frame is dispatched through the same ASGI application. You can authenticate stdio sessions in two ways:
- Header Injection via CLI Flags: Pass
--bearer-env,--bearer-cmd, or--headertolitestar mcp stdioso your application's HTTP authentication middleware validates the synthesizedAuthorization(or custom--header-name) header on every request: - Pre-Seeded Identity via
MCPStdioContext: Standalone local applications that run without HTTP token middleware can resolve identity from the host OS or environment at startup and pass it toMCPStdioContext:from litestar_mcp import MCPStdioContext from litestar_mcp.mcp.stdio import run_stdio context = MCPStdioContext( client_id="desktop-host", owner_id="user:alice", user={"sub": "alice", "roles": ["editor"]}, auth={"sub": "alice", "scopes": ["notes:read", "notes:write"]}, ) run_stdio(app, stdio_context=context)
Anonymous Stdio Has No Shared Owner ID
When MCPStdioContext.owner_id is None and no authenticated principal is present on scope["user"] or scope["auth"], litestar-mcp assigns owner_id=None rather than a shared "stdio" string. Always supply an authenticated principal or explicit owner_id when running multi-user or persisted task stores.
HTTP-to-Stdio Bridge (litestar mcp bridge)¶
When desktop MCP hosts connect to a remote Litestar deployment through litestar mcp bridge, pass bearer credentials or custom proxy headers at the CLI boundary:
When --bearer-cmd is configured and the remote Litestar server responds with HTTP 401 Unauthorized, the bridge automatically re-executes the command once to mint a fresh token and retries the request.
Next Steps¶
- First-Party
litestar-securityIntegration: Declare mechanism policies viaMCPConfig.route_opt, emit RFC 9728WWW-Authenticatechallenges, and rehydrateSecurityContextinlitestar-queuesworkers. - Authorization, Guards, Scopes & Input Safety: Enforce route guards, validate domain object ownership, and protect multi-round
requestState.