
MCP OAuth on AWS Lambda with WorkOS
Photo byBruno Alves onUnsplash
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 wholecode 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 withgoose
(open source AI client ) using themcp-remote
package for handling aStreamable HTTP
type of MCP server.
To begin, I create a simple mock authorizer that returnsUnauthorized
by default.
// authorizer/src/main.rsusestd::collections::HashMap;uselambda_runtime::{run,service_fn,tracing,Error,LambdaEvent};useserde::{Deserialize,Serialize};modgeneric_handler;// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#[derive(Deserialize)]pubstructRequest{pubheaders:HashMap<String,String>}#[derive(Serialize)]pubstructResponse{pubisAuthorized:bool,pubcontext:HashMap<String,String>}#[tokio::main]asyncfnmain()->Result<(),Error>{tracing::init_default_subscriber();run(service_fn(function_handler)).await}asyncfnfunction_handler(event:LambdaEvent<Request>)->Result<Response,Error>{// auth logicOk(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.rsuseaxum::{http::StatusCode,response::IntoResponse,routing::post,Router};usetower_http::trace::{DefaultMakeSpan,TraceLayer};usetracing_subscriber::EnvFilter;constPORT:u16=8080;#[tokio::main]asyncfnmain(){tracing_subscriber::fmt().with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_|EnvFilter::new("debug"))).init();letapp=Router::<()>::new().route("/mcp",post(mcp_handler)).layer(TraceLayer::new_for_http().make_span_with(DefaultMakeSpan::new().include_headers(true)));letlistener=tokio::net::TcpListener::bind(format!("0.0.0.0:{}",PORT)).await.unwrap();axum::serve(listener,app).await.unwrap();}pubasyncfnmcp_handler()->implIntoResponse{"hello"}
Finally, the infrastructure defines a HTTP API Gateway and both functions. My MCP resource server runs theaxum
application behindLambda Web Adapter
infrastructure/lib/infrastructure-stack.tsimport*ascdkfrom'aws-cdk-lib';import{Construct}from'constructs';import{RustFunction}from'@cdklabs/aws-lambda-rust'exportclassMCPAuthLambdaextendscdk.Stack{constructor(scope:Construct,id:string,props?:cdk.StackProps){super(scope,id,props);constauthorizer=newRustFunction(this,'Authorizer',{entry:'../authorizer/Cargo.toml',environment:{RUST_LOG:'info'}})constadapterLayer=cdk.aws_lambda.LayerVersion.fromLayerVersionArn(this,'WebAdapterLayer','arn:aws:lambda:us-east-1:753240598075:layer:LambdaAdapterLayerX86:25');constmcpResourceServer=newRustFunction(this,'McpResourceServer',{entry:'../mcp-resource',layers:[adapterLayer],environment:{RUST_LOG:'info'}})consthttpApi=newcdk.aws_apigatewayv2.HttpApi(this,'MCP-Auth-API');httpApi.addRoutes({path:'/mcp',authorizer:newcdk.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:newcdk.aws_apigatewayv2_integrations.HttpLambdaIntegration("mcp-resource",mcpResourceServer)});newcdk.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 runcdk synth
in theinfrastructure
directory, and thensam local start-api -t MCPAuthLambda.template.json
in thecdk.out
directory.
Now I will add myMCP
to thegoose
by starting
goose configure
and addingmcp-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 tothe 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 thewell-known
endpoint
// mcp-authorize/src/main.rsuseaxum::{debug_handler,response::IntoResponse,routing::get,Json,Router};constPORT:u16=8081;#[tokio::main]asyncfnmain(){letapp=Router::<()>::new().route("/.well-known/oauth-authorization-server",get(well_known_handler),);letlistener=tokio::net::TcpListener::bind(format!("0.0.0.0:{}",PORT)).await.unwrap();axum::serve(listener,app).await.unwrap();}#[debug_handler]asyncfnwell_known_handler()->implIntoResponse{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)]pubstructWellKnownAnswer{pubauthorization_endpoint:String,pubregistration_endpoint:String,pubgrant_types_supported:Vec<String>,pubscopes_supported:Vec<String>,pubresponse_modes_supported:Vec<String>,pubresponse_types_supported:Vec<String>,pubtoken_endpoint:String,pubissuer:String,}
I also updated the infrastructure with the newly created service
// ...constmcpAuthorizationServer=newRustFunction(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:newcdk.aws_apigatewayv2_integrations.HttpLambdaIntegration("mcp-authorization",mcpAuthorizationServer)});// ...
Rebuild the project withcdk synth
and runsam local
Now, when startinggoose
, 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 newclient_id
each time, and I will use/register
endpoint to plug the configuredWorkOS
server.
I take the client_id from the main page of theWorkOS
dashboard,https://dashboard.workos.com/get-started
According to theRFC 7591, theclient_id
is the only required field in the response.
It turned out thatmcp-remote
also requires theredirect_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// ...letapp=Router::<()>::new().route("/.well-known/oauth-authorization-server",get(well_known_handler),).route("/register",post(registration_handler));// ...#[debug_handler]asyncfnregistration_handler(Json(req):Json<ClientRegistrationRequest>)->implIntoResponse{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)]pubstructClientRegistrationRequest{pubclient_name:String,pubredirect_uris:Vec<String>,pubgrant_types:Vec<String>,pubtoken_endpoint_auth_method:String,pubresponse_types:Vec<String>,}#[derive(Serialize)]pubstructClientRegistrationAnswer{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:newcdk.aws_apigatewayv2_integrations.HttpLambdaIntegration("mcp-authorization",mcpAuthorizationServer)});
And startgoose
one more time
Now the default browser is opened with the call for the/authorize
endpoint
/authorize
When performing authorization, my goal is to returnredirect
to the user with the proper parameters, soWorkOS
will perform heavy lifting.
I built the logic based on thedocumentation.
The only tricky part is that I can't simply use theredirect_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 specificredirect_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]asyncfnauthorization_handler(Query(params):Query<HashMap<String,String>>)->implIntoResponse{letoriginal_uri=params.get("redirect_uri").unwrap();letstate=BASE64_STANDARD.encode(original_uri);letlocal_redirect="http://localhost:3000/callback";leturl=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 startgoose
again and see a login page open in the default browser:
It is super convenient to have the whole UI implemented by WorkOSAuthKit
.
/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 theauthorize
action and redirect it to the originalredirect_uri
stored in the requeststate
// ...#[debug_handler]asyncfncallback_handler(Query(params):Query<HashMap<String,String>>)->implIntoResponse{letcode=params.get("code").unwrap();letstate=params.get("state").unwrap();letoriginal_redirect_uri=String::from_utf8(BASE64_STANDARD.decode(state).unwrap()).unwrap();letresponse_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 flowsauthorization_code
andredresh_token
. Token handler checks the type of the request, and goes to the WorkOS to get the actual token.
asyncfntoken_handler(State(state):State<AppState>,Form(form):Form<HashMap<String,String>>)->implIntoResponse{letgrant_type=form.get("grant_type").unwrap();letclient=reqwest::Client::new();letrequest=matchgrant_type.as_str(){"authorization_code"=>{letcode=form.get("code").unwrap();letcode_verifier=form.get("code_verifier").unwrap();letclient_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"=>{letrefresh_token=form.get("refresh_token").unwrap();letclient_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);letres=client.post("https://api.workos.com/user_management/authenticate").body(serde_json::to_string(&request).unwrap()).header("Content-Type","application/json").send().await.unwrap();letauthkit_response:AuthkitAuthResult=serde_json::from_str(res.text().await.unwrap().as_str()).unwrap();println!("token response {:?}",&authkit_response);letexpires_at=chrono::Utc::now().timestamp()+3600;lettoken_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)]pubstructTokenResponse{access_token:String,refresh_token:String,token_type:String,expires_at:i64,}#[derive(Serialize,Deserialize,Debug)]pubstructAuthkitAuthResult{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 withsam local start-api
command.
Lambda authorizer is quite straightforward. I am gettingjwks
and decodeJWT
token.
// authorizer/src/token.rsusejsonwebtoken::{decode_header,DecodingKey,TokenData};useserde::{Deserialize,Serialize};#[derive(Debug,Serialize,Deserialize)]pubstructClaims{pubsub:String,pubexp:usize,}#[derive(Serialize,Deserialize,Clone,Debug)]pubstructJWK{pubkid:String,pubkty:String,pubalg:String,pubn:String,pube:String,}#[derive(Serialize,Deserialize,Clone,Debug)]pubstructJWKS{pubkeys:Vec<JWK>,}pubasyncfnget_jwks(jwks_url:String)->JWKS{letjwks_resp=reqwest::get(jwks_url).await.unwrap();letjwks:JWKS=jwks_resp.json().await.unwrap();jwks}pubasyncfncheck_token(token:&str,keys:&JWKS)->Result<TokenData<Claims>,String>{letheader=decode_header(token).unwrap();letkid=header.kid.ok_or("No kid found in token header")?;letjwk=keys.keys.iter().find(|k|k.kid==kid).ok_or("No matching kid found in jwks")?;letdecoding_key=DecodingKey::from_rsa_components(&jwk.n,&jwk.e).map_err(|op|format!("Error: {:?}",op))?;letmutvalidation=jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);validation.validate_exp;lettoken_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.rsusestd::collections::HashMap;uselambda_runtime::{run,service_fn,tracing,Error,LambdaEvent};useserde::{Deserialize,Serialize};useserde_json::{json,Value};usetoken::JWKS;modtoken;// https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html#[derive(Deserialize)]pubstructRequest{pubheaders:HashMap<String,String>,}#[derive(Serialize)]#[serde(rename_all="camelCase")]pubstructResponse{pubis_authorized:bool,pubcontext:HashMap<String,String>,}#[derive(Serialize)]#[serde(rename_all="camelCase")]pubstructUnauthorizedResponse{puberror_message:String,}#[derive(Serialize)]#[serde(untagged)]pubenumAuthorizerResponse{PermissionResponse(Response),Unauthorized(UnauthorizedResponse),}#[tokio::main]asyncfnmain()->Result<(),Error>{tracing::init_default_subscriber();letjwks_url=std::env::var("JWKS_URL").expect("JWKS_URL must be set");letjwks=token::get_jwks(jwks_url).await;println!("JWKS: {:?}",&jwks);run(service_fn(|ev|function_handler(&jwks,ev))).await}asyncfnfunction_handler(jwks:&JWKS,event:LambdaEvent<Request>,)->Result<AuthorizerResponse,Error>{lettoken_header=event.payload.headers.get("authorization");iftoken_header.is_none(){println!("No token header");returnOk(AuthorizerResponse::Unauthorized({UnauthorizedResponse{error_message:"Unauthorized".to_string(),}}));}lettoken=token_header.unwrap().replace("Bearer ","");letclaims=token::check_token(token.as_str(),jwks).await;// auth logicmatchclaims{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 returnisAuthorized
as false, it is translated by API Gateway to a403
status. It won't work with MCP flow, which requires a401
status to trigger authorization discovery.
I have moved hardcoded things toenv
variables and deployed the project to AWS.
MCP Server
The last missing part is the actual MCP Server
For simplicity's sake, I take theCounter
example from the examples for official Rust MCP SDK
Test
To test the whole solution, I add the new extension togoose
and start the program
I can see the login window
After logging in, I am properly redirected to the localhost used bygoose
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 wholecode is available in this repo
Summary
In this blog post, I implemented the authorization service for the MCP Server usingWorkOS
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 usingStreamable 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)

- EducationDidn't finish high school :(
- PronounsNev/Nevo
- WorkOSS Chief @ Gitroom
- Joined
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.
For further actions, you may consider blocking this person and/orreporting abuse