Skip to main content

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.

Prerequisites

Before starting this tutorial, ensure you have:

  • A Kubernetes cluster with the ToolHive Operator installed (see the Kubernetes quickstart guide)
  • kubectl configured 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:

  1. The client sends a request with an OIDC token from your identity provider.
  2. ToolHive validates the token against your OIDC provider's JWKS endpoint.
  3. The role mapper inspects JWT claims (such as groups) and selects an IAM role based on your configured mappings.
  4. ToolHive calls AWS STS AssumeRoleWithWebIdentity to exchange the OIDC token for temporary AWS credentials.
  5. The SigV4 signer signs the outgoing request with the temporary credentials.
  6. 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 the https:// 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)
What's happening?

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) — true for 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:

default-mcp-trust-policy.json
{
"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:

default-mcp-permissions.json
{
"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
Production: use permission boundaries

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:

s3-readonly-permissions.json
{
"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.

aws-sts-auth-config.yaml
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
How role selection works

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
What's happening?

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.

aws-mcp-remote-proxy.yaml
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
What's happening?

When you apply this resource, the ToolHive Operator:

  1. Creates a Deployment running the ToolHive proxy
  2. Creates a Service to expose the proxy within the cluster
  3. 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.

aws-mcp-gateway.yaml
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?ValueDeny applies?
No (key absent)No
YestrueNo
YesfalseYes

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

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 Federated field matches the provider you registered in Step 1.
  • The aud condition matches the audience in your OIDC tokens.
  • The OIDC provider's issuer URL in AWS IAM exactly matches the iss claim 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 issuer in oidcConfig matches the iss claim in your token.
  • The audience matches the aud claim.
  • The token hasn't expired (check the exp claim).
  • 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"