Guide · AWS STS

MCP Server STS Temporary Credentials — credential provider chains, OIDC, and refresh

Every AWS API call an MCP server makes must be signed with valid credentials — and those credentials must not expire mid-call. STS temporary credentials (AccessKeyId, SecretAccessKey, SessionToken with an Expiration timestamp) are the right model: they rotate automatically, leave no long-lived keys in config files, and are logged per-session in CloudTrail. The AWS SDK resolves credentials through a credential provider chain — an ordered list of sources tried in sequence — so the same MCP server binary can pick up credentials from environment variables in CI, from EC2 instance metadata in production, and from an assumed role when executing cross-account tool calls. Understanding the chain order and the provider-specific expiry semantics prevents the two most common STS bugs: expired credentials during long-running tool calls, and stale cached credentials after a role policy change.

TL;DR

The AWS SDK credential provider chain tries: env vars → shared credentials file → SSO → process credentials → container credentials (ECS/EKS) → EC2 instance metadata. For Lambda and ECS, credentials come from the execution role via IMDSv2 or the task metadata endpoint — do not override them. For EKS with IRSA, the OIDC token at AWS_WEB_IDENTITY_TOKEN_FILE is exchanged for temporary credentials via AssumeRoleWithWebIdentity automatically if AWS_ROLE_ARN is set. Cache temporary credentials and refresh at 80% of the session lifetime — never wait for ExpiredTokenException to trigger a refresh.

AWS SDK credential provider chain order

When you construct an AWS SDK client without explicit credentials, the SDK walks a defined chain of credential providers in order, using the first one that returns a valid credential. Understanding this order prevents the common scenario where a developer's local ~/.aws/credentials file shadows the production IAM role in a misconfigured deployment:

// AWS SDK v3 (Node.js) default credential provider chain
// Sources tried in this order:
//
// 1. fromEnv() — AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN
//    Highest priority; used in CI/CD pipelines and local overrides
//
// 2. fromIni() — ~/.aws/credentials and ~/.aws/config
//    Named profiles via AWS_PROFILE env var; falls back to [default]
//
// 3. fromSSO() — AWS IAM Identity Center (SSO) login
//    Requires 'aws sso login' to populate the SSO token cache
//
// 4. fromProcess() — credential_process in ~/.aws/config
//    Runs a subprocess that outputs JSON credentials; useful for SAML/IdP
//
// 5. fromTokenFile() — AWS_ROLE_ARN + AWS_WEB_IDENTITY_TOKEN_FILE
//    EKS IRSA: exchanges OIDC JWT for STS credentials automatically
//    This runs BEFORE container credentials — important for EKS workloads
//
// 6. fromContainerMetadata() — ECS/Fargate task credentials
//    HTTP call to 169.254.170.2 (ECS task metadata endpoint)
//
// 7. fromInstanceMetadata() — EC2 instance profile
//    HTTP call to 169.254.169.254/latest/meta-data/iam/security-credentials/
//    Uses IMDSv2 (PUT token required) by default in SDK v3
//
// To use the default chain explicitly:
import { defaultProvider } from "@aws-sdk/credential-provider-node";
import { S3Client } from "@aws-sdk/client-s3";

const s3 = new S3Client({
  region: "us-east-1",
  credentials: defaultProvider()  // explicit but equivalent to omitting credentials
});

For Lambda functions, the SDK automatically uses the execution role credentials provided by the Lambda service via the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_SESSION_TOKEN environment variables that Lambda sets at runtime. These rotate before expiry without any action needed — do not implement custom refresh logic for Lambda's own execution credentials.

AssumeRoleWithWebIdentity for OIDC (EKS IRSA, GitHub Actions)

AssumeRoleWithWebIdentity exchanges an OIDC JWT from an identity provider for temporary AWS credentials. This is the mechanism behind two common MCP server deployment patterns: EKS IRSA (IAM Roles for Service Accounts) and GitHub Actions OIDC for CI/CD deployments.

// EKS IRSA: Kubernetes service account → IAM role
// Setup: create OIDC provider for the EKS cluster, then create IAM role with
// this trust policy:
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::111122223333:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/EXAMPLEID" },
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {
        // Restrict to a specific service account in a specific namespace
        "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLEID:sub": "system:serviceaccount:mcp-ns:mcp-server-sa",
        "oidc.eks.us-east-1.amazonaws.com/id/EXAMPLEID:aud": "sts.amazonaws.com"
      }
    }
  }]
}

// Kubernetes service account annotation (triggers token projection):
// kubectl annotate serviceaccount mcp-server-sa \
//   eks.amazonaws.com/role-arn=arn:aws:iam::111122223333:role/mcp-server-role

// Pod spec — SDK picks up these env vars automatically:
// env:
//   - name: AWS_ROLE_ARN
//     value: arn:aws:iam::111122223333:role/mcp-server-role
//   - name: AWS_WEB_IDENTITY_TOKEN_FILE
//     value: /var/run/secrets/eks.amazonaws.com/serviceaccount/token

// The projected token is rotated by kubelet every 24h (or earlier on renewal)
// The SDK fromTokenFile() provider refreshes credentials before token expiry
// automatically — no custom logic needed

// GitHub Actions OIDC (no long-lived secrets):
// In workflow YAML:
// permissions:
//   id-token: write
//   contents: read
// steps:
//   - uses: aws-actions/configure-aws-credentials@v4
//     with:
//       role-to-assume: arn:aws:iam::111122223333:role/mcp-deploy-role
//       aws-region: us-east-1
// Trust policy sub condition: "token.actions.githubusercontent.com:sub":
//   "repo:your-org/mcp-server:ref:refs/heads/main"

The sub claim in the OIDC token uniquely identifies the caller. For GitHub Actions, the sub format is repo:{org}/{repo}:ref:refs/heads/{branch} — always specify both org/repo AND the branch or environment to prevent other repos in your org from assuming the role. For EKS IRSA, the sub is system:serviceaccount:{namespace}:{service-account-name}.

Credential caching and proactive refresh

STS credentials expire. The critical rule for MCP servers is: never let credentials expire while a tool call is in flight. A 3-minute database export tool call started 59 minutes into a 1-hour session will fail at the 60-minute mark with ExpiredTokenException. The fix is proactive refresh:

// Proactive credential refresh: refresh at 80% of lifetime
class CredentialCache {
  private credentials: AWSCredentials | null = null;
  private refreshTimer: NodeJS.Timeout | null = null;

  async get(): Promise {
    if (!this.credentials || this.isExpiredOrExpiringSoon()) {
      this.credentials = await this.fetchFresh();
      this.scheduleRefresh();
    }
    return this.credentials;
  }

  private isExpiredOrExpiringSoon(): boolean {
    const expiry = new Date(this.credentials!.Expiration!).getTime();
    // Refresh if within 5 minutes of expiry (safety buffer)
    return Date.now() > expiry - 5 * 60 * 1000;
  }

  private scheduleRefresh(): void {
    if (this.refreshTimer) clearTimeout(this.refreshTimer);
    const expiry = new Date(this.credentials!.Expiration!).getTime();
    const lifetime = expiry - Date.now();
    const refreshDelay = lifetime * 0.8;  // refresh at 80% elapsed

    this.refreshTimer = setTimeout(async () => {
      this.credentials = await this.fetchFresh();
      this.scheduleRefresh();  // schedule the next refresh
    }, refreshDelay);
  }

  private async fetchFresh(): Promise {
    const result = await sts.send(new AssumeRoleCommand({
      RoleArn: this.roleArn,
      RoleSessionName: `mcp-cache-${Date.now()}`,
      DurationSeconds: 3600,
    }));
    return result.Credentials!;
  }
}

// Simpler alternative: AWS SDK credential providers handle refresh automatically
// fromTemporaryCredentials refreshes transparently when the SDK detects expiry
import { fromTemporaryCredentials } from "@aws-sdk/credential-providers";

const creds = fromTemporaryCredentials({
  masterCredentials: defaultProvider(),  // base credentials
  params: { RoleArn: roleArn, RoleSessionName: "mcp-session" }
  // SDK refreshes automatically when Expiration approaches
});

IMDSv2 on EC2: the metadata service token requirement

EC2 instances support IMDSv2 (Instance Metadata Service v2) which requires a PUT request to obtain a session token before reading credentials. AWS SDK v3 uses IMDSv2 by default. If the MCP server runs on EC2 instances that have HttpTokens: required set (enforced IMDSv2), any direct curl http://169.254.169.254/latest/meta-data/iam/security-credentials/ call will return 401 — this is not an IAM permission error, it is the metadata service rejecting a v1 request.

// IMDSv2 credential fetch (for diagnostics — SDK handles this automatically)
const TOKEN = await fetch("http://169.254.169.254/latest/api/token", {
  method: "PUT",
  headers: { "X-aws-ec2-metadata-token-ttl-seconds": "21600" }
}).then(r => r.text());

const roleName = await fetch(
  "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
  { headers: { "X-aws-ec2-metadata-token": TOKEN } }
).then(r => r.text());

const creds = await fetch(
  `http://169.254.169.254/latest/meta-data/iam/security-credentials/${roleName}`,
  { headers: { "X-aws-ec2-metadata-token": TOKEN } }
).then(r => r.json());

// creds.Expiration is ISO 8601 — credentials rotate ~5 min before expiry
// EC2 role credentials are valid for ~6 hours and auto-rotate

// For containerized MCP servers on EC2 (Docker/containerd):
// The container must have access to the metadata endpoint (default: yes)
// If the container sets --network=host, uses 169.254.169.254 directly
// If the container uses bridge networking, hop count may block IMDS —
// increase EC2MetadataServiceNumAttempts in SDK config if credentials fail

Failure modes reference

FailureSymptomFix
ExpiredTokenException mid-callLong-running tool call (S3 multipart, DynamoDB batch) fails with expired token after 60 minutesUse fromTemporaryCredentials provider; or implement proactive refresh at 80% of lifetime; never start a call with <10 minutes left on the credential
Local ~/.aws/credentials overrides production roleMCP server in production uses developer's personal credentials from a mounted config volumeUnset AWS_SHARED_CREDENTIALS_FILE in production; or use explicit fromInstanceMetadata() provider instead of the default chain
IRSA token file not mountedAWS_WEB_IDENTITY_TOKEN_FILE set but file does not exist; SDK falls through to instance metadata and picks up node role instead of pod roleCheck projected volume mount in pod spec; verify service account annotation; confirm OIDC provider ARN matches cluster's OIDC issuer URL exactly
AssumeRoleWithWebIdentity token expiredError: "Provided OIDC token is expired" despite recent pod creationKubelet rotates projected tokens every 24h; check pod age and token expiry with kubectl exec — token at AWS_WEB_IDENTITY_TOKEN_FILE has its own exp claim independent of the pod
EC2 IMDSv2 returns 401Direct curl to metadata endpoint returns 401; SDK credential resolution failsAdd X-aws-ec2-metadata-token header from a PUT /latest/api/token call first; or update SDK version to v3 which handles IMDSv2 automatically
Credentials not propagated to child processMCP tool spawns a subprocess (e.g., aws CLI) that cannot access AWS APIsExplicitly pass AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN to the subprocess environment; the child process does not inherit SDK credential provider state