Photo by Bruno Alves on Unsplash
Goal
I plan to implement an MCP server secured with OAuth. I will use a third-party identity provider (WorkOS for this blog post, but general rules would be the same for other services). The created resource is meant to be used by users in their MCP clients.
The main idea is to build a serverless solution that might integrate with the existing authentication flow. From the user's perspective, using MCP Server should feel similar to using other services.
Problem
The current MCP specification assumes that the MCP Server will handle authorization as well. It might work if you have a single MCP server, but it doesn't fit well into an enterprise landscape, with dozens of services and a separate layer for identity provider.
Architecture
I will create two separate services behind the API Gateway. One is responsible for the authorization bridge with WorkOS, the other for the actual MCP server logic.
I use a stateless version of the MCP server, so both services will run on Lambda Functions.
API Gateway will be responsible for authorizing the calls to the resource server (using Lambda authorizer). With this approach, my MCP resource server won't know anything about authorization logic.
Implementation
The whole code is available in this repo
Initial setup
To be honest, I don't fully understand the MCP specification, especially when it comes to authorization. Moreover, MCP clients do not rush to implement the updated specification, which makes things even more tricky.
I will test my server with goose
( open source AI client ) using the mcp-remote
package for handling a Streamable HTTP
type of MCP server.
To begin, I create a simple mock authorizer that returns Unauthorized
by default.
// authorizer/src/main.rs
use std::collections::HashMap;
use lambda_runtime::{run, service_fn, tracing, Error, LambdaEvent};
use serde::{Deserialize, Serialize};
mod generic_handler;
// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html
#[derive(Deserialize)]
pub struct Request {
pub headers: HashMap<String, String>
}
#[derive(Serialize)]
pub struct Response {
pub isAuthorized: bool,
pub context: HashMap<String, String>
}
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing::init_default_subscriber();
run(service_fn(function_handler)).await
}
async fn function_handler(event: LambdaEvent<Request>) -> Result<Response, Error> {
// auth logic
Ok(Response {
isAuthorized: false,
context: HashMap::new()
})
}
My initial MCP resource server returns a dummy message in response to POST request on the /mcp
route
// mcp-resource/src/main.rs
use axum::{http::StatusCode, response::IntoResponse, routing::post, Router};
use tower_http::trace::{DefaultMakeSpan, TraceLayer};
use tracing_subscriber::EnvFilter;
const PORT: u16 = 8080;
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(
|_| EnvFilter::new("debug")
)
)
.init();
let app = Router::<()>::new()
.route("/mcp", post(mcp_handler))
.layer(
TraceLayer::new_for_http()
.make_span_with(DefaultMakeSpan::new()
.include_headers(true)
)
);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", PORT)).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
pub async fn mcp_handler() -> impl IntoResponse {
"hello"
}
Finally, the infrastructure defines a HTTP API Gateway and both functions. My MCP resource server runs the axum
application behind Lambda Web Adapter
infrastructure/lib/infrastructure-stack.ts
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { RustFunction } from '@cdklabs/aws-lambda-rust'
export class MCPAuthLambda extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const authorizer = new RustFunction(this, 'Authorizer', {
entry: '../authorizer/Cargo.toml',
environment: {
RUST_LOG: 'info'
}
})
const adapterLayer = cdk.aws_lambda.LayerVersion.fromLayerVersionArn(
this,
'WebAdapterLayer',
'arn:aws:lambda:us-east-1:753240598075:layer:LambdaAdapterLayerX86:25'
);
const mcpResourceServer = new RustFunction(this, 'McpResourceServer', {
entry: '../mcp-resource',
layers: [ adapterLayer ],
environment: {
RUST_LOG: 'info'
}
})
const httpApi = new cdk.aws_apigatewayv2.HttpApi(this, 'MCP-Auth-API');
httpApi.addRoutes({
path: '/mcp',
authorizer: new cdk.aws_apigatewayv2_authorizers.HttpLambdaAuthorizer(
'Authorizer', authorizer, {
responseTypes: [cdk.aws_apigatewayv2_authorizers.HttpLambdaResponseType.SIMPLE]
}),
methods: [cdk.aws_apigatewayv2.HttpMethod.GET, cdk.aws_apigatewayv2.HttpMethod.POST],
integration: new cdk.aws_apigatewayv2_integrations.HttpLambdaIntegration(
"mcp-resource",
mcpResourceServer)
});
new cdk.CfnOutput(
this,
'ApiUrl',
{
value: httpApi.url!
}
)
}
}
Local testing
I use SAM local to run APIs locally and test. To be able to do so with AWS CDK, I run cdk synth
in the infrastructure
directory, and then sam local start-api -t MCPAuthLambda.template.json
in the cdk.out
directory.
Now I will add my MCP
to the goose
by starting
goose configure
and adding mcp-remote
extension:
Now, when I start goose, it tries to use my MCP server:
Ok, as expected, the goose tries to connect to the /mcp
route, and receives the 401 response. Then it tries to get /.well-known/oauth-authorization-server
and fails.
The next step would be to start implementing our authorization logic.
Authorization Server
/.well-known/oauth-authorization-server
According to the specification, the first step in the auth process is metadata discovery. In other words, the MCP client queries for the information about expected endpoints, and falls back to the default ones if the .well-known
endpoint is not available.
Let's start from the well-known
endpoint
// mcp-authorize/src/main.rs
use axum::{debug_handler, response::IntoResponse, routing::get, Json, Router};
const PORT: u16 = 8081;
#[tokio::main]
async fn main() {
let app = Router::<()>::new().route(
"/.well-known/oauth-authorization-server",
get(well_known_handler),
);
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", PORT))
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}
#[debug_handler]
async fn well_known_handler() -> impl IntoResponse {
Json(WellKnownAnswer {
authorization_endpoint: "http://localhost:3000/authorize".to_string(),
registration_endpoint: "http://localhost:3000/register".to_string(),
grant_types_supported: vec!["authorization_code".to_string(), "refresh_token".to_string()],
scopes_supported: vec!["email".to_string(), "offline_access".to_string(), "openid".to_string(), "profile".to_string()],
response_modes_supported: vec!["query".to_string()],
response_types_supported: vec!["code".to_string()],
token_endpoint: "http://localhost:3000/token".to_string(),
issuer: "https://fresh-lamb-82-staging.authkit.app".to_string(),
})
}
#[derive(serde::Serialize)]
pub struct WellKnownAnswer {
pub authorization_endpoint: String,
pub registration_endpoint: String,
pub grant_types_supported: Vec<String>,
pub scopes_supported: Vec<String>,
pub response_modes_supported: Vec<String>,
pub response_types_supported: Vec<String>,
pub token_endpoint: String,
pub issuer: String,
}
I also updated the infrastructure with the newly created service
// ...
const mcpAuthorizationServer = new RustFunction(this, 'McpAuthorizationServer', {
entry: '../mcp-authorize',
layers: [ adapterLayer ],
environment: {
RUST_LOG: 'debug',
PORT: '8081',
}
})
//...
httpApi.addRoutes({
path: '/.well-known/oauth-authorization-server',
methods: [cdk.aws_apigatewayv2.HttpMethod.GET],
integration: new cdk.aws_apigatewayv2_integrations.HttpLambdaIntegration(
"mcp-authorization",
mcpAuthorizationServer)
});
// ...
Rebuild the project with cdk synth
and run sam local
Now, when starting goose
, I can see that we are one step further
/register
MCP spec expects that the OAuth flow allows dynamic client registration. In my case, I don't plan to register the new client_id
each time, and I will use /register
endpoint to plug the configured WorkOS
server.
I take the client_id from the main page of the WorkOS
dashboard, https://dashboard.workos.com/get-started
According to the RFC 7591, the client_id
is the only required field in the response.
It turned out that mcp-remote
also requires the redirect_uris
property in the response. I will take it from the payload of the request sent to the /register
endpoint
// mcp-authorize/src/main.rs
// ...
let app = Router::<()>::new().route(
"/.well-known/oauth-authorization-server",
get(well_known_handler),
)
.route("/register", post(registration_handler));
// ...
#[debug_handler]
async fn registration_handler(Json(req): Json<ClientRegistrationRequest>) -> impl IntoResponse {
println!("{:?}", req);
Json(ClientRegistrationAnswer{
client_id: CLIENT_ID.to_string(),
redirect_uris: req.redirect_uris,
response_types: req.response_types,
grant_types: req.grant_types,
client_name: req.client_name
})
}
#[derive(Debug, Deserialize)]
pub struct ClientRegistrationRequest {
pub client_name: String,
pub redirect_uris: Vec<String>,
pub grant_types: Vec<String>,
pub token_endpoint_auth_method: String,
pub response_types: Vec<String>,
}
#[derive(Serialize)]
pub struct ClientRegistrationAnswer {
client_id: String,
redirect_uris: Vec<String>,
response_types: Vec<String>,
grant_types: Vec<String>,
client_name: String
}
//...
I add the endpoint to APIs
// ...
httpApi.addRoutes({
path: '/register',
methods: [cdk.aws_apigatewayv2.HttpMethod.POST],
integration: new cdk.aws_apigatewayv2_integrations.HttpLambdaIntegration(
"mcp-authorization",
mcpAuthorizationServer)
});
And start goose
one more time
Now the default browser is opened with the call for the /authorize
endpoint
/authorize
When performing authorization, my goal is to return redirect
to the user with the proper parameters, so WorkOS
will perform heavy lifting.
I built the logic based on the documentation.
The only tricky part is that I can't simply use the redirect_uri
sent by the MCP client, as I have not added it to the list of allowed URIs in the WorkOS Dashboard. It happens because we are not registering the client dynamically, so our underlying authorization server doesn't know about the specific redirect_uri
sent with the request.
I solve it by storing the original redirect uri as a state, and using the URI registered on my server:
// ...
#[debug_handler]
async fn authorization_handler(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let original_uri = params.get("redirect_uri").unwrap();
let state = BASE64_STANDARD.encode(original_uri);
let local_redirect = "http://localhost:3000/callback";
let url = reqwest::Url::parse_with_params(
"https://api.workos.com/user_management/authorize",
&[
("response_type", "code"),
("client_id", CLIENT_ID),
("redirect_uri", local_redirect),
("code_challenge", params.get("code_challenge").unwrap()),
("code_challenge_method", "S256"),
("provider", "authkit"),
("state", state.as_str()),
("scope", "openid profile email offline_access"),
],
)
.unwrap();
Redirect::temporary(url.as_str())
}
// ...
BTW - when prototyping, I use some hardcoded variables, and I will clean them up later, when deploying to AWS.
Now I start goose
again and see a login page open in the default browser:
It is super convenient to have the whole UI implemented by WorkOS AuthKit
.
/callback
This endpoint is not part of the specification, but it is needed for statically created clients with registered redirect_uris. I will get the response from the authorize
action and redirect it to the original redirect_uri
stored in the request state
// ...
#[debug_handler]
async fn callback_handler(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let code = params.get("code").unwrap();
let state = params.get("state").unwrap();
let original_redirect_uri = String::from_utf8(BASE64_STANDARD.decode(state).unwrap()).unwrap();
let response_url = reqwest::Url::parse_with_params(
original_redirect_uri.as_str(),
&[("code", &code), ("state", &state)],
)
.unwrap();
Redirect::temporary(response_url.as_str())
}
// ...
Now, after logging in, I can see that the code was properly redirected
/token
The final part of the authorization process is to exchange the code for the token.
Our flow allows two types of flows authorization_code
and redresh_token
. Token handler checks the type of the request, and goes to the WorkOS to get the actual token.
async fn token_handler(State(state): State<AppState>, Form(form): Form<HashMap<String, String>>) -> impl IntoResponse {
let grant_type = form.get("grant_type").unwrap();
let client = reqwest::Client::new();
let request = match grant_type.as_str() {
"authorization_code" => {
let code = form.get("code").unwrap();
let code_verifier = form.get("code_verifier").unwrap();
let client_id = form.get("client_id").unwrap();
json!({
"client_id": client_id,
"client_secret": state.workos_client_secret,
"grant_type": "authorization_code",
"code": code,
"code_verifier": code_verifier
})
}
"refresh_token" => {
let refresh_token = form.get("refresh_token").unwrap();
let client_id = form.get("client_id").unwrap();
json!({
"client_id": client_id,
"client_secret": state.workos_client_secret,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
})
}
_ => panic!("Invalid grant type"),
};
println!("token request: {}", request);
let res = client
.post("https://api.workos.com/user_management/authenticate")
.body(serde_json::to_string(&request).unwrap())
.header("Content-Type", "application/json")
.send()
.await
.unwrap();
let authkit_response: AuthkitAuthResult =
serde_json::from_str(res.text().await.unwrap().as_str()).unwrap();
println!("token response {:?}", &authkit_response);
let expires_at = chrono::Utc::now().timestamp() + 3600;
let token_result = TokenResponse {
access_token: authkit_response.access_token,
refresh_token: authkit_response.refresh_token,
token_type: "Bearer".to_string(),
expires_at,
};
Json(token_result)
}
//...
#[derive(Serialize, Deserialize, Debug)]
pub struct TokenResponse {
access_token: String,
refresh_token: String,
token_type: String,
expires_at: i64,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct AuthkitAuthResult {
access_token: String,
refresh_token: String,
}
// ...
Quick check and it looks that the authorization flow is successfully finished, as now I can see the error from the initial /mcp
endpoint - I need to implement API Gateway authorizer
Authorizer
I've planned to set up everything and test locally before even deploying to AWS. It turned out that it is not possible to fully test the lambda authorizer with sam local start-api
command.
Lambda authorizer is quite straightforward. I am getting jwks
and decode JWT
token.
// authorizer/src/token.rs
use jsonwebtoken::{decode_header, DecodingKey, TokenData};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
pub struct Claims {
pub sub: String,
pub exp: usize,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JWK {
pub kid: String,
pub kty: String,
pub alg: String,
pub n: String,
pub e: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JWKS {
pub keys: Vec<JWK>,
}
pub async fn get_jwks(jwks_url: String) -> JWKS {
let jwks_resp = reqwest::get(jwks_url).await.unwrap();
let jwks: JWKS = jwks_resp.json().await.unwrap();
jwks
}
pub async fn check_token(token: &str, keys: &JWKS) -> Result<TokenData<Claims>, String> {
let header = decode_header(token).unwrap();
let kid = header.kid.ok_or("No kid found in token header")?;
let jwk = keys.keys.iter()
.find(|k| k.kid == kid)
.ok_or("No matching kid found in jwks")?;
let decoding_key = DecodingKey::from_rsa_components(&jwk.n, &jwk.e)
.map_err(|op| format!("Error: {:?}", op))?;
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
validation.validate_exp;
let token_data = jsonwebtoken::decode::<Claims>(token, &decoding_key, &validation)
.map_err(|op| format!("Error: {:?}", op))?;
println!("{:?}", token_data.claims);
Ok(token_data)
}
And the handler
// authorizer/src/main.rs
use std::collections::HashMap;
use lambda_runtime::{run, service_fn, tracing, Error, LambdaEvent};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use token::JWKS;
mod token;
// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html
#[derive(Deserialize)]
pub struct Request {
pub headers: HashMap<String, String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Response {
pub is_authorized: bool,
pub context: HashMap<String, String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UnauthorizedResponse {
pub error_message: String,
}
#[derive(Serialize)]
#[serde(untagged)]
pub enum AuthorizerResponse {
PermissionResponse(Response),
Unauthorized(UnauthorizedResponse),
}
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing::init_default_subscriber();
let jwks_url = std::env::var("JWKS_URL").expect("JWKS_URL must be set");
let jwks = token::get_jwks(jwks_url).await;
println!("JWKS: {:?}", &jwks);
run(service_fn(|ev| function_handler(&jwks, ev))).await
}
async fn function_handler(
jwks: &JWKS,
event: LambdaEvent<Request>,
) -> Result<AuthorizerResponse, Error> {
let token_header = event.payload.headers.get("authorization");
if token_header.is_none() {
println!("No token header");
return Ok(AuthorizerResponse::Unauthorized({
UnauthorizedResponse {
error_message: "Unauthorized".to_string(),
}
}));
}
let token = token_header.unwrap().replace("Bearer ", "");
let claims = token::check_token(token.as_str(), jwks).await;
// auth logic
match claims {
Ok(tk) => {
println!("Claims: {:?}", tk);
Ok(AuthorizerResponse::PermissionResponse(Response {
is_authorized: true,
context: HashMap::new(),
}))
}
Err(e) => {
println!("Error: {:?}", e);
Ok(AuthorizerResponse::Unauthorized({
UnauthorizedResponse {
error_message: "Unauthorized".to_string(),
}
}))
}
}
}
The only interesting part is that when I return isAuthorized
as false, it is translated by API Gateway to a 403
status. It won't work with MCP flow, which requires a 401
status to trigger authorization discovery.
I have moved hardcoded things to env
variables and deployed the project to AWS.
MCP Server
The last missing part is the actual MCP Server
For simplicity's sake, I take the Counter
example from the examples for official Rust MCP SDK
Test
To test the whole solution, I add the new extension to goose
and start the program
I can see the login window
After logging in, I am properly redirected to the localhost used by goose
Now I can use my MCP counter service that runs on Lambda behind AWS API Gateway. The connection is secured by the token obtained by the client from the WorkOS server
The whole code is available in this repo
Summary
In this blog post, I implemented the authorization service for the MCP Server using WorkOS
as an identity provider. This way, clients using my server in their MCP clients would be able to log in using a third-party identity provider. This is a common flow in organizations that probably already have this provider set up.
At the moment, MCP clients don't fully implement the updated MCP schema, so even using Streamable HTTP
type of servers requires some kind of workarounds, not to speak of authorization flow. I believe that with time, the ecosystem will mature, and providing clients with secure MCP servers will become more straightforward.
Top comments (3)
Did you know that serverless adoption on AWS Lambda grew by over 40% year-over-year in 2023? This shows how organizations are increasingly turning to solutions like Lambda for scalable and secure backend authentication flows, just like this MCP OAuth setup!
Been cool seeing steady progress - it adds up. what do you think actually keeps things growing over time? habits? luck? just showing up?
Some comments may only be visible to logged-in visitors. Sign in to view all comments.