AWS STS authentication for the AWS MCP Server
This tutorial shows you how to use ToolHive as an authentication proxy for the
AWS MCP Server. Developers sign in
through their company identity provider, and ToolHive exchanges their OIDC token
for temporary AWS credentials via
AssumeRoleWithWebIdentity.
Before starting this tutorial, ensure you have:
- A Kubernetes cluster with the ToolHive Operator installed (see the Kubernetes quickstart guide)
kubectlconfigured to access your cluster- The AWS CLI installed and configured with an AWS account that has permissions to create IAM roles, policies, and OIDC identity providers
- An OIDC identity provider (such as Okta, Auth0, Microsoft Entra ID, or Keycloak) that your users authenticate with
- Basic familiarity with AWS IAM concepts and OIDC
Overview
The following diagram shows how ToolHive processes each request:
- The client sends a request with an OIDC token from your identity provider.
- ToolHive validates the token against your OIDC provider's JWKS endpoint.
- The role mapper inspects JWT claims (such as
groups) and selects an IAM role based on your configured mappings. - ToolHive calls AWS STS
AssumeRoleWithWebIdentityto exchange the OIDC token for temporary AWS credentials. - The SigV4 signer signs the outgoing request with the temporary credentials.
- The signed request is forwarded to the AWS MCP Server.
The AWS MCP Server acts as the access point that allows AI assistants to connect to different AWS services. Compared to configuring AWS credentials directly, ToolHive adds:
- Company IdP integration: Developers authenticate using company SSO. ToolHive acquires short-lived, properly scoped credentials on their behalf. No AWS CLI configuration is required, and AWS credentials are never stored on developers' machines.
- Fine-grained authorization: Control which MCP tools a user can invoke. Cedar policies are evaluated on every request, using claims from the incoming token to make access decisions.
- Observability, metrics, and auditing: ToolHive provides OpenTelemetry tracing, Prometheus metrics, and audit logs that correlate user identity across the request flow.
Step 1: Register your identity provider with AWS IAM
Create an OIDC identity provider in AWS IAM so that AWS STS trusts tokens from your identity provider.
aws iam create-open-id-connect-provider \
--url https://<YOUR_OIDC_ISSUER> \
--client-id-list <YOUR_OIDC_AUDIENCE>
Replace the placeholders:
<YOUR_OIDC_ISSUER>- your identity provider's issuer identifier without thehttps://scheme. Include any path components (for example,dev-123456.okta.com/oauth2/default)<YOUR_OIDC_AUDIENCE>- the audience claim in your OIDC tokens that identifies this proxy (for example,toolhive-aws-proxy)
This step establishes a trust relationship between AWS and your identity
provider. When ToolHive later calls AssumeRoleWithWebIdentity, AWS STS
verifies the OIDC token signature against keys published by your identity
provider. Without this trust relationship, AWS rejects the token exchange.
Step 2: Create IAM roles and policies
Create IAM roles that ToolHive assumes on behalf of your users. Each role defines what AWS permissions a user gets when their JWT claims match a role mapping.
Understanding the AWS MCP Server permission model
AWS MCP Server authorization works at the AWS service level using standard IAM
policies. There are no separate aws-mcp:* actions — you grant the AWS service
permissions you want users to have, and the AWS MCP Server enforces them.
Two IAM condition keys let you scope policies specifically to MCP traffic:
aws:ViaAWSMCPService(Bool) —truefor any request routed through an AWS-managed MCP server. Use this when you want to allow access via any AWS MCP server, or to deny direct API access outside of MCP.aws:CalledViaAWSMCP(String) — identifies the specific MCP server that originated the request (for example,aws-mcp.amazonaws.com). Use this when you want to restrict access to a particular MCP server endpoint.
Default role
Create a role with minimal permissions. This is the fallback when no specific role mapping matches. It needs two policy documents.
The trust policy allows AWS STS to accept tokens from your OIDC provider. The
Federated principal identifies your registered provider, and the aud
condition rejects tokens meant for other services:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:oidc-provider/<YOUR_OIDC_ISSUER>"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"<YOUR_OIDC_ISSUER>:aud": "<YOUR_OIDC_AUDIENCE>"
}
}
}
]
}
The permission policy grants minimal access. It allows sts:GetCallerIdentity
as a smoke test (so you can verify the role assumption works), and includes a
deny guardrail that prevents the credentials from being used outside of MCP:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowMinimalAccessViaMCP",
"Effect": "Allow",
"Action": "sts:GetCallerIdentity",
"Resource": "*",
"Condition": {
"Bool": {
"aws:ViaAWSMCPService": "true"
}
}
},
{
"Sid": "DenyDirectAPIAccess",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:ViaAWSMCPService": "false"
}
}
}
]
}
Create the role with the trust policy and attach the permission policy:
aws iam create-role \
--role-name DefaultMCPRole \
--assume-role-policy-document file://default-mcp-trust-policy.json
aws iam put-role-policy \
--role-name DefaultMCPRole \
--policy-name DefaultMCPPolicy \
--policy-document file://default-mcp-permissions.json
For production deployments, attach
permission boundaries
to your IAM roles to limit the maximum permissions a role can grant. A good
boundary policy restricts allowed AWS regions, denies IAM and Organizations API
access, and denies sts:AssumeRole to prevent role chaining. This limits the
blast radius even if a role's identity policy is overly permissive.
Optional: additional roles for specific teams
You can create additional roles with different permissions and map them to specific groups using ToolHive's role mappings. This example creates a role that grants S3 read-only access when using the AWS MCP Server:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowS3ReadAccessViaMCP",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": "*",
"Condition": {
"StringEquals": {
"aws:CalledViaAWSMCP": "aws-mcp.amazonaws.com"
}
}
},
{
"Sid": "DenyDirectAPIAccess",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:ViaAWSMCPService": "false"
}
}
}
]
}
No aws-mcp:* actions are required — you grant only the AWS service permissions
you need. The aws:CalledViaAWSMCP condition scopes access to requests
originating from the AWS MCP Server, and the deny guardrail prevents the
temporary credentials from being used outside of MCP.
aws iam create-role \
--role-name S3ReadOnlyMCPRole \
--assume-role-policy-document file://default-mcp-trust-policy.json
aws iam put-role-policy \
--role-name S3ReadOnlyMCPRole \
--policy-name S3ReadOnlyMCPPolicy \
--policy-document file://s3-readonly-permissions.json
Step 3: Create the MCPExternalAuthConfig
Create an MCPExternalAuthConfig resource that defines how ToolHive exchanges
OIDC tokens for AWS credentials.
apiVersion: toolhive.stacklok.dev/v1alpha1
kind: MCPExternalAuthConfig
metadata:
name: aws-mcp-sts-auth
namespace: toolhive-system
spec:
type: awsSts
awsSts:
region: <YOUR_AWS_REGION>
# SigV4 service name for the AWS MCP Server
service: aws-mcp
# Default role when no role mapping matches
fallbackRoleArn: >-
arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:role/DefaultMCPRole
# Map JWT claims to IAM roles (lower priority = evaluated first)
roleMappings:
- claim: s3-readers
roleArn: >-
arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:role/S3ReadOnlyMCPRole
priority: 10
Replace the placeholders:
<YOUR_AWS_REGION>- the AWS region (for example,us-east-1)<YOUR_AWS_ACCOUNT_ID>- your 12-digit AWS account ID
When a request arrives, ToolHive evaluates your role mappings in priority order (lower number = higher priority). The first matching rule determines which IAM role to assume. If no mapping matches, the fallback role is used.
For example, if a user belongs to both s3-readers and developers groups, and
s3-readers has a lower priority number, ToolHive selects the S3 read-only
role.
Apply the configuration:
kubectl apply -f aws-sts-auth-config.yaml
ToolHive checks the groups claim in the JWT by default (controlled by the
roleClaim field, which defaults to "groups" when omitted). Each mapping's
claim field is the value to match against that claim. In this example, if
the user's JWT contains "s3-readers" in their groups array, ToolHive assumes
the S3 read-only role.
If your identity provider uses a different claim name (e.g., roles or
memberOf), add the roleClaim field:
awsSts:
roleClaim: roles # look at the "roles" claim instead of "groups"
For more complex matching logic, use CEL expressions in the matcher field
instead of claim:
roleMappings:
- matcher: '"admins" in claims["groups"] && claims["org"] == "engineering"'
roleArn: arn:aws:iam::123456789012:role/AdminMCPRole
priority: 1
Step 4: Deploy the MCPRemoteProxy
Create an MCPRemoteProxy resource that points to the AWS MCP Server endpoint
and references the authentication configuration from the previous step.
apiVersion: toolhive.stacklok.dev/v1alpha1
kind: MCPRemoteProxy
metadata:
name: aws-mcp-proxy
namespace: toolhive-system
spec:
remoteURL: https://aws-mcp.us-east-1.api.aws/mcp
# Reference the AWS STS auth config from Step 3
externalAuthConfigRef:
name: aws-mcp-sts-auth
# OIDC configuration for validating incoming client tokens
oidcConfig:
type: inline
# Public URL of this proxy's MCP endpoint, advertised to clients
# via WWW-Authenticate so they can discover your OIDC provider.
# Must match the hostname you configure in Step 5.
resourceUrl: https://<YOUR_DOMAIN>/mcp
inline:
issuer: https://<YOUR_OIDC_ISSUER>
audience: <YOUR_OIDC_AUDIENCE>
proxyPort: 8080
transport: streamable-http
audit:
enabled: true
resources:
limits:
cpu: '500m'
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
Replace the placeholders with your OIDC provider's configuration.
Apply the proxy:
kubectl apply -f aws-mcp-remote-proxy.yaml
When you apply this resource, the ToolHive Operator:
- Creates a Deployment running the ToolHive proxy
- Creates a Service to expose the proxy within the cluster
- Configures the proxy to validate incoming OIDC tokens, exchange them for AWS credentials via STS, and forward SigV4-signed requests to the AWS MCP Server
Step 5: Expose the proxy
To make the proxy accessible to clients outside the cluster, create Gateway and HTTPRoute resources. This example uses Kubernetes Gateway API; if your cluster uses a traditional Ingress controller, see Connect clients to MCP servers for alternatives.
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: aws-mcp-gateway
namespace: toolhive-system
spec:
gatewayClassName: <YOUR_GATEWAY_CLASS>
listeners:
- name: https
protocol: HTTPS
port: 443
hostname: <YOUR_DOMAIN>
allowedRoutes:
namespaces:
from: All
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: aws-mcp-route
namespace: toolhive-system
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: aws-mcp-gateway
namespace: toolhive-system
hostnames:
- <YOUR_DOMAIN>
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: mcp-aws-mcp-proxy-remote-proxy
port: 8080
Replace <YOUR_GATEWAY_CLASS> and <YOUR_DOMAIN> with your gateway
configuration.
kubectl apply -f aws-mcp-gateway.yaml
For more on exposing MCP servers, see Connect clients to MCP servers. For a worked example using ngrok for development, see Configure secure ingress for MCP servers on Kubernetes.
Step 6: Verify the integration
Check that all resources are running:
# Verify the MCPExternalAuthConfig
kubectl get mcpexternalauthconfig -n toolhive-system
# Verify the MCPRemoteProxy
kubectl get mcpremoteproxy -n toolhive-system
# Check the proxy pods are running
kubectl get pods -n toolhive-system -l app.kubernetes.io/instance=aws-mcp-proxy
Test that unauthenticated requests are rejected:
# Port-forward for local testing
kubectl port-forward -n toolhive-system \
svc/mcp-aws-mcp-proxy-remote-proxy 8080:8080 &
# This should return 401 Unauthorized
curl -s -o /dev/null -w "%{http_code}" \
-X POST http://localhost:8080/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
Now test with a valid OIDC token. This example uses oauth2c and jq to obtain and extract a token, but any method that produces a valid access token from your identity provider will work:
TOKEN=$(oauth2c https://<YOUR_OIDC_ISSUER> \
--client-id <YOUR_OIDC_CLIENT_ID> \
--client-secret $OIDC_CLIENT_SECRET \
--scopes openid \
--grant-type authorization_code \
--auth-method client_secret_basic \
--response-mode form_post \
--response-types code \
--pkce | jq -r '.access_token')
# Step 1: initialize the MCP session and capture the session ID
SESSION=$(curl -si -X POST http://localhost:8080/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}' \
| grep -i "^mcp-session-id:" | awk '{print $2}' | tr -d '\r')
# Step 2: list available tools using the session ID
curl -X POST http://localhost:8080/mcp \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Mcp-Session-Id: $SESSION" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
Check the proxy logs to confirm role selection is working:
kubectl logs -n toolhive-system \
-l app.kubernetes.io/instance=aws-mcp-proxy --tail=50
Look for log entries showing role selection and STS exchange results.
Security best practices
The deny guardrail pattern
Both policy examples above include a DenyDirectAPIAccess statement that uses
BoolIfExists:
{
"Sid": "DenyDirectAPIAccess",
"Effect": "Deny",
"Action": "*",
"Resource": "*",
"Condition": {
"BoolIfExists": {
"aws:ViaAWSMCPService": "false"
}
}
}
BoolIfExists is important here. Without the IfExists suffix, the deny would
apply even when the condition key is absent — which would block legitimate STS
operations like AssumeRoleWithWebIdentity itself. With IfExists, the
condition only evaluates when the key is present:
aws:ViaAWSMCPService present? | Value | Deny applies? |
|---|---|---|
| No (key absent) | — | No |
| Yes | true | No |
| Yes | false | Yes |
This means credentials can only be used when the request flows through an AWS MCP server. If the temporary credentials are ever extracted and used directly against the AWS API, the deny fires and access is blocked.
Choosing between condition keys
Use aws:ViaAWSMCPService when you want to:
- Allow or deny access based on whether the request came through any AWS-managed MCP server
- Write deny guardrails that apply regardless of which MCP server is used
Use aws:CalledViaAWSMCP when you want to:
- Scope access to a specific MCP server endpoint (for example,
aws-mcp.amazonaws.com) - Prevent credentials from being used with other MCP servers
You can combine both in a single policy for the tightest scoping.
Observability and audit
ToolHive sets the STS session name to the user's sub claim from their JWT.
This means you can correlate ToolHive proxy logs with AWS CloudTrail entries for
the same user - look for CloudTrail events with
eventName: AssumeRoleWithWebIdentity and check
requestParameters.roleSessionName to identify who triggered each action.
On the ToolHive side, the proxy logs role selection and STS exchange events (user identity, matched claim, selected role, success or failure). You can also enable Prometheus metrics and OpenTelemetry tracing on the MCPRemoteProxy - see Telemetry and metrics and the OpenTelemetry tutorial for setup instructions.
Clean up
Remove the Kubernetes resources:
kubectl delete mcpremoteproxy aws-mcp-proxy -n toolhive-system
kubectl delete mcpexternalauthconfig aws-mcp-sts-auth -n toolhive-system
kubectl delete gateway aws-mcp-gateway -n toolhive-system
kubectl delete httproute aws-mcp-route -n toolhive-system
Remove the AWS IAM resources:
aws iam delete-role-policy \
--role-name DefaultMCPRole --policy-name DefaultMCPPolicy
aws iam delete-role --role-name DefaultMCPRole
# If you created the optional S3 read-only role
aws iam delete-role-policy \
--role-name S3ReadOnlyMCPRole --policy-name S3ReadOnlyMCPPolicy
aws iam delete-role --role-name S3ReadOnlyMCPRole
aws iam delete-open-id-connect-provider \
--open-id-connect-provider-arn \
arn:aws:iam::<YOUR_AWS_ACCOUNT_ID>:oidc-provider/<YOUR_OIDC_ISSUER>
Next steps
- Learn about the concepts behind backend authentication and token exchange.
- Explore the authentication and authorization framework for securing client-to-MCP-server connections.
- Read the AWS documentation on AssumeRoleWithWebIdentity and IAM OIDC identity providers.
Troubleshooting
STS access denied: "Not authorized to perform sts:AssumeRoleWithWebIdentity"
This error means the IAM role's trust policy doesn't allow your OIDC provider to assume it. Verify:
- The OIDC provider ARN in the trust policy's
Federatedfield matches the provider you registered in Step 1. - The
audcondition matches the audience in your OIDC tokens. - The OIDC provider's issuer URL in AWS IAM exactly matches the
issclaim in your tokens (including any path like/oauth2/default).
Check the trust policy:
aws iam get-role --role-name DefaultMCPRole \
--query 'Role.AssumeRolePolicyDocument'
Service access denied: "User is not authorized to perform s3:ListBucket" (or similar)
This error means the assumed role doesn't have the required AWS service
permissions. Authorization is now at the service level — there are no
aws-mcp:* actions. Verify the permission policy attached to the role includes
the actions the MCP tool needs:
aws iam get-role-policy \
--role-name S3ReadOnlyMCPRole \
--policy-name S3ReadOnlyMCPPolicy
Ensure the policy includes the relevant service actions (for example,
s3:GetObject, s3:ListBucket) and that the aws:CalledViaAWSMCP or
aws:ViaAWSMCPService conditions are correctly set.
Debugging token claims and role selection
Obtain a token and inspect the output to verify your claims:
oauth2c https://<YOUR_OIDC_ISSUER> \
--client-id <YOUR_OIDC_CLIENT_ID> \
--client-secret $OIDC_CLIENT_SECRET \
--scopes openid \
--grant-type authorization_code \
--auth-method client_secret_basic \
--response-mode form_post \
--response-types code \
--pkce
Check that the claim specified by roleClaim (default: groups) contains the
expected values. For example, if your role mapping uses claim: s3-readers, the
decoded token should include:
{
"groups": ["s3-readers", "developers"]
}
If role mappings aren't matching as expected, check the proxy logs for role selection details:
kubectl logs -n toolhive-system \
-l app.kubernetes.io/instance=aws-mcp-proxy | grep -i "role"
Proxy returns 401 Unauthorized
If clients receive 401 errors, the OIDC token validation is failing. Verify:
- The
issuerinoidcConfigmatches theissclaim in your token. - The
audiencematches theaudclaim. - The token hasn't expired (check the
expclaim). - The proxy can reach your OIDC provider's JWKS endpoint from within the cluster.
Check proxy logs for authentication errors:
kubectl logs -n toolhive-system \
-l app.kubernetes.io/instance=aws-mcp-proxy | grep -i "auth"