WebAssembly Serverless IAM: Credential-Free Cloud Access from WASM Functions
The Credential Problem in WASM Serverless
Traditional cloud IAM relies on instance metadata. An EC2 instance running code can reach 169.254.169.254/latest/meta-data/iam/security-credentials/ to get short-lived credentials issued by STS for the instance’s attached role. EKS pods use IRSA — an OIDC token mounted into the pod that gets exchanged for STS credentials scoped to the pod’s service account. Neither mechanism exists in WASM serverless environments.
A Cloudflare Worker has no instance. It is an isolate running on Cloudflare’s infrastructure in whichever PoP is closest to the request origin. There is no EC2 metadata endpoint to call, no IMDS, no Kubernetes service account token mounted at a predictable path. The Worker cannot prove its identity to AWS IAM using the same mechanisms available to EC2 or EKS workloads.
This creates a dangerous temptation: put static credentials in the Worker. Developers working under deadline pressure reach for the obvious solution — an AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY stored as Worker secrets, injected as environment variables at runtime. This works. It is also a serious security problem.
Static long-lived credentials in a serverless function have several failure modes that compound each other:
Credential scope is too wide. IAM access keys are associated with an IAM user, not a role. Without careful policy design, the key has whatever permissions the IAM user has — frequently broader than the function actually needs.
Rotation is manual and error-prone. Instance metadata credentials rotate automatically via STS. IAM user access keys do not. They persist until explicitly rotated, and in practice, serverless function credentials are rarely rotated because doing so requires a coordinated update to the runtime secret store and a function redeployment.
Extraction surface is larger than you think. Even if the credential is never in the WASM binary itself — it is injected at runtime rather than compiled in — it still appears in function environment at runtime. A compromised WASM module that calls the WASI environment API reads everything in its environment, including credentials injected alongside legitimate configuration. In Cloudflare Workers running JavaScript, process.env or the binding environment object is accessible from any code running in the isolate, including third-party dependencies.
Audit attribution is weak. CloudTrail logs show API calls made with the access key, but attribution to a specific function invocation, request, or user requires additional context that static key authentication cannot provide automatically.
The correct architecture for WASM serverless IAM has two branches, depending on whether you are accessing resources within the same platform (Cloudflare R2, KV, D1) or resources in external clouds (AWS S3, DynamoDB, Secrets Manager). Within-platform access uses service bindings — a platform-native capability delegation model that requires no credentials at all. Cross-platform access uses OIDC token exchange: the WASM platform issues a signed identity token for the function, and the external cloud’s STS accepts it to issue short-lived credentials.
Cloudflare Workers: Service Bindings as Credential-Free IAM
Cloudflare’s service binding model is the cleanest serverless IAM pattern available on any platform. When a Worker needs access to R2 object storage, KV, D1, or Queues, the binding is declared in wrangler.toml and injected by the platform runtime. The Worker code calls the binding API directly — no credentials, no HTTP calls to a storage API, no authentication headers to manage.
# wrangler.toml
name = "inventory-api"
main = "src/index.ts"
compatibility_date = "2025-01-01"
[[r2_buckets]]
binding = "PRODUCT_IMAGES"
bucket_name = "acme-product-images"
preview_bucket_name = "acme-product-images-dev"
[[kv_namespaces]]
binding = "SESSION_CACHE"
id = "abc123def456"
[[d1_databases]]
binding = "ORDERS_DB"
database_name = "orders"
database_id = "xyz789"
[[queues.producers]]
binding = "EVENT_QUEUE"
queue = "order-events"
At runtime, env.PRODUCT_IMAGES is an R2 bucket handle, not a string containing credentials. The Worker calls env.PRODUCT_IMAGES.put(key, value) and Cloudflare’s runtime mediates the access — verifying that the Worker is authorized to use that binding, enforcing the binding’s configured permissions, and writing directly to the bucket without the Worker ever holding S3-compatible access keys.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { pathname } = new URL(request.url);
if (request.method === "PUT" && pathname.startsWith("/images/")) {
const key = pathname.slice("/images/".length);
const body = await request.arrayBuffer();
await env.PRODUCT_IMAGES.put(key, body, {
httpMetadata: { contentType: request.headers.get("Content-Type") ?? "application/octet-stream" },
customMetadata: {
uploadedBy: request.headers.get("CF-Worker-Identity") ?? "unknown",
requestId: request.headers.get("CF-Ray") ?? crypto.randomUUID(),
},
});
return new Response(null, { status: 204 });
}
return new Response("Not Found", { status: 404 });
},
};
The security properties of service bindings:
- No credentials in code or environment. There is no AWS_ACCESS_KEY_ID, no HMAC signing key, no bearer token. The binding authorization is enforced at the Cloudflare platform layer, not by code you write.
- Least privilege by binding type. R2 bindings can be scoped to read-only or read-write at the bucket level. KV bindings provide namespace isolation. D1 bindings scope access to the named database. A Worker cannot access R2 buckets it does not have an explicit binding for.
- Automatic key rotation is irrelevant. There are no keys to rotate. The authorization is structural — it follows from the binding configuration, which is version-controlled in
wrangler.toml. - Cross-worker service bindings. One Worker can bind to another Worker and call it directly without HTTP — the runtime enforces that only the bound Worker can invoke the target. This extends the credential-free model to service-to-service calls.
# Caller Worker: wrangler.toml
[[services]]
binding = "AUTH_SERVICE"
service = "auth-worker"
entrypoint = "AuthHandler"
// Caller Worker
const response = await env.AUTH_SERVICE.fetch(
new Request("https://internal/validate", {
method: "POST",
body: JSON.stringify({ token }),
})
);
The auth-worker receives requests only via the service binding — it is not publicly reachable. The binding is the authorization mechanism.
Cloudflare Workers + AWS: OIDC Token Exchange for STS Credentials
When a Cloudflare Worker needs to call AWS services directly — not via an intermediate API — the correct pattern is Workload Identity Federation. Cloudflare issues OIDC tokens for Workers; AWS STS accepts OIDC tokens from trusted providers and exchanges them for short-lived STS credentials via AssumeRoleWithWebIdentity.
Cloudflare’s Workers OIDC token is available via the cloudflare:workers built-in module. As of Workers runtime 2025.x, a Worker can request a signed JWT asserting its identity:
import { getToken } from "cloudflare:workers";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const oidcToken = await getToken({
audience: `sts.amazonaws.com`,
});
const stsResponse = await fetch("https://sts.amazonaws.com/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
Action: "AssumeRoleWithWebIdentity",
Version: "2011-06-15",
RoleArn: env.AWS_ROLE_ARN,
RoleSessionName: `cf-worker-${Date.now()}`,
WebIdentityToken: oidcToken,
DurationSeconds: "900",
}),
});
// parse STS XML response, extract credentials
const stsXml = await stsResponse.text();
const credentials = parseStsCredentials(stsXml);
// use short-lived credentials for single AWS API call
const s3Response = await callS3WithSigv4(credentials, env.S3_BUCKET, request);
return s3Response;
},
};
The AWS side requires an IAM OIDC provider configured for Cloudflare’s OIDC endpoint and a trust policy on the role permitting AssumeRoleWithWebIdentity from tokens with the correct sub claim:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.cloudflare.com/workers"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"oidc.cloudflare.com/workers:aud": "sts.amazonaws.com",
"oidc.cloudflare.com/workers:sub": "account:acme-corp:worker:inventory-api"
}
}
}
]
}
The sub claim in the Cloudflare OIDC token encodes the account and Worker name — this is the functional equivalent of the IRSA subject claim for EKS pods. The trust policy’s StringEquals on sub ensures that only the specific Worker named inventory-api in your Cloudflare account can assume the role. Other Workers in the same account cannot.
For full context on the OIDC exchange pattern with AWS, see AWS IRSA and Workload Identity and GCP Workload Identity Federation.
The STS credentials received have a 15-minute TTL by default. For Workers handling many requests per second, requesting fresh STS credentials on every invocation creates STS API rate limit pressure and adds 100–300ms of latency. Cache the credentials in Cloudflare KV with a TTL of 600 seconds — refreshed roughly every 10 minutes, well before the STS expiry. Include a 60-second buffer check so that credentials are refreshed before expiry, not after.
Fermyon Spin: Variable Injection from Secrets Backends
Spin’s IAM story differs from Cloudflare’s because Spin is a self-hosted or Fermyon Cloud-hosted runtime — you control the deployment infrastructure. The secrets management architecture depends on where Spin is running.
Spin 3.x supports a variables API that the host runtime resolves at request time. The WASM component never holds the raw secret value in source code or binary data sections — it calls the variables API and receives the value only if the runtime has granted the component access to that variable.
# spin.toml
[application]
name = "order-processor"
version = "0.1.0"
[[trigger.http]]
route = "/orders/..."
component = "order-processor"
[component.order-processor]
source = "target/wasm32-wasip2/release/order_processor.wasm"
allowed_outbound_hosts = ["https://s3.us-east-1.amazonaws.com"]
[component.order-processor.variables]
aws_access_key_id = { required = true }
aws_secret_access_key = { required = true, secret = true }
aws_region = { default = "us-east-1" }
s3_bucket = { required = true }
use spin_sdk::variables;
pub fn get_aws_credentials() -> anyhow::Result<AwsCredentials> {
Ok(AwsCredentials {
access_key_id: variables::get("aws_access_key_id")?,
secret_access_key: variables::get("aws_secret_access_key")?,
region: variables::get("aws_region")?,
})
}
The variable values themselves are provided by the Spin runtime’s configured variables provider. In self-hosted Spin deployments, you configure a provider at startup:
# Vault provider
spin up \
--runtime-config-file runtime-config.toml \
-- target/wasm32-wasip2/release/order_processor.wasm
# runtime-config.toml
[variables_provider.vault]
url = "https://vault.internal.acme.com:8200"
mount = "secret"
prefix = "spin/order-processor"
auth_login_path = "auth/approle/login"
role_id = "order-processor-role"
secret_id_file = "/run/secrets/vault-secret-id"
The Spin runtime authenticates to Vault using AppRole, fetches the variables for the component, and injects them at request time. The WASM component’s variables::get() call goes to the host runtime, not to Vault directly — the component has no Vault token, no Vault API access, and no knowledge of where its secrets come from.
For Fermyon Cloud deployments, variables are set via spin cloud variables set:
spin cloud variables set \
--app order-processor \
aws_access_key_id="ASIA..." \
aws_secret_access_key="<value>" \
s3_bucket="acme-orders-prod"
Fermyon Cloud encrypts variable values at rest. Variables marked secret = true in spin.toml are not returned by the cloud dashboard or CLI — they can be set but not read back after initial configuration.
The limitation of Spin’s current variable injection model for cloud IAM: it injects static credentials, not dynamically exchanged short-lived credentials. There is no equivalent of Cloudflare’s getToken() OIDC mechanism in Spin today. For production IAM on Spin, the practical options are either injecting short-lived STS credentials that your CI/CD pipeline refreshes on a schedule, or building a sidecar that refreshes credentials into a Vault secret that Spin reads at request time.
Fastly Compute: Config Store and Secret Store
Fastly Compute uses Wasmtime under the hood. Configuration and secrets reach the WASM module through two distinct host APIs:
Config Store for non-sensitive configuration that may be shared across multiple services and does not require encryption at rest:
use fastly::config_store::ConfigStore;
let config = ConfigStore::open("app-config");
let s3_bucket = config.get("s3_bucket").expect("s3_bucket not configured");
let aws_region = config.get("aws_region").unwrap_or_else(|| "us-east-1".to_string());
Config Stores are managed through the Fastly API and can be updated without redeploying the service binary.
Secret Store for credentials and other values requiring encryption at rest and access logging:
use fastly::secret_store::SecretStore;
let secrets = SecretStore::open("aws-credentials");
let access_key_id = secrets
.get("AWS_ACCESS_KEY_ID")
.expect("secret store open failed")
.plaintext()
.to_vec();
let secret_access_key = secrets
.get("AWS_SECRET_ACCESS_KEY")
.expect("secret store open failed")
.plaintext()
.to_vec();
Create and populate Secret Stores via the Fastly API:
fastly secret-store create --name aws-credentials
fastly secret-store entry create \
--store-id <store-id> \
--name AWS_ACCESS_KEY_ID \
--value "AKIAIOSFODNN7EXAMPLE"
fastly secret-store entry create \
--store-id <store-id> \
--name AWS_SECRET_ACCESS_KEY \
--value "<value>"
Link the Secret Store to the service in the service configuration, not in the WASM binary. The WASM module only calls SecretStore::open("aws-credentials") by name — the runtime resolves the binding.
Fastly does not currently offer an OIDC token mechanism for WASM functions analogous to Cloudflare’s getToken(). If your Fastly function needs to call AWS, the options are Secret Store for long-lived IAM user credentials (with a strict rotation policy), or an intermediate API Gateway endpoint that your Fastly function calls with a Fastly-signed request — the gateway uses its own AWS role credentials to call AWS on behalf of the function.
WASM Function Identity for Audit Logging
The question of function identity matters for audit attribution, not just access control. When a Cloudflare Worker calls AWS via STS AssumeRoleWithWebIdentity, the RoleSessionName appears in CloudTrail as the session name for every API call. Setting this to a value that includes the Worker name, the Cloudflare Ray ID, and a timestamp provides enough attribution to correlate CloudTrail events back to specific function invocations:
const rayId = request.headers.get("CF-Ray") ?? crypto.randomUUID();
const sessionName = `cf-worker-inventory-api-${rayId}`.slice(0, 64);
const stsParams = new URLSearchParams({
Action: "AssumeRoleWithWebIdentity",
RoleSessionName: sessionName,
// ...
});
CloudTrail logs the session name in userIdentity.sessionContext.sessionIssuer and as the ARN suffix: arn:aws:sts::123456789012:assumed-role/inventory-api-role/cf-worker-inventory-api-abc123. Querying CloudTrail for the Ray ID lets you trace every AWS API call made during a specific request.
For Fastly Compute, the Fastly request ID serves the same purpose:
let request_id = req.get_header_str("Fastly-Request-ID")
.unwrap_or("unknown");
let session_name = format!("fastly-compute-{}", &request_id[..32]);
For Spin, inject the function name and instance ID into AWS session names at the point where credentials are used. Spin exposes the component name via spin_sdk::info::component_name() in recent versions.
The audit identity pattern connects to the zero trust authorization model described in WASM Edge Zero Trust Auth, where function identity is asserted rather than assumed from network position.
Secret Injection: Deploy Time vs. Request Time
The timing of secret injection has different security and operational properties:
Deploy-time injection bakes secrets into the function’s environment at deployment. The Worker starts with credentials already available. This minimizes per-request latency but means the secret’s lifetime in the runtime environment equals the deployment lifetime — potentially days or weeks. Rotation requires a redeployment. If the runtime environment is compromised at any point during the deployment, the secrets are exposed. Cloudflare Wrangler’s [vars] (for non-secrets) and --secret flag represent deploy-time injection.
Request-time injection fetches or derives secrets on each request or on a short refresh cycle. STS credential exchange is the canonical request-time model — the token is valid for 15 minutes and is obtained fresh at the start of each cold start or cache miss. This minimizes the window of exposure for any given credential value but adds latency and dependency on an external service at request time.
For production WASM serverless IAM:
- Use service bindings (Cloudflare) or managed platform resources wherever possible — no injection, no expiry, no rotation burden.
- For cross-cloud access, prefer OIDC token exchange (request-time) over static credentials (deploy-time). The latency cost is manageable with credential caching; the security benefit is that a stolen credential from a runtime dump is valid for at most 15 minutes.
- For Spin and Fastly where OIDC exchange is not native, use the platform’s secret store for credentials and implement a rotation schedule enforced by CI/CD — create a new IAM access key, update the secret store entry, trigger a redeployment, then delete the old key. Keep the credential lifetime under 24 hours; rotate on a cron schedule from a trusted CI runner with its own short-lived OIDC credentials, never from a human’s local machine.
Detecting Credential Exfiltration from WASM Functions
A compromised WASM module that obtains cloud credentials will attempt to use them from outside the expected function context. Detection relies on distinguishing calls made through expected function pathways from calls originating elsewhere.
Source IP analysis. Cloud API calls from a Cloudflare Worker originate from Cloudflare’s IP ranges. Calls from a Fastly Compute function originate from Fastly’s IP ranges. Both publish their IP ranges via their respective APIs. A CloudTrail event showing an AWS API call with the assumed-role/inventory-api-role session but sourced from a residential ISP or a cloud provider other than the expected platform is a strong indicator of credential exfiltration. Route CloudTrail AssumeRoleWithWebIdentity events to a Lambda that verifies the source IP against the platform’s published CIDR list and alerts on mismatches.
Session name anomaly detection. If your functions consistently generate session names with a specific format (e.g., cf-worker-inventory-api-<ray-id>), CloudTrail calls with the same role but a different session name format indicate that someone is using the OIDC token or role outside your expected function context.
Rate and volume anomalies. A Worker handling 100 requests per second with STS credential caching (10-minute TTL) generates roughly 6 AssumeRoleWithWebIdentity calls per hour. A sudden spike in STS calls from the same role — especially concurrent calls that bypass the cache — indicates either a cache invalidation bug or exfiltrated tokens being used in parallel by an adversary.
Outbound network monitoring. Cloudflare Workers have a configurable allowlist for outbound fetch() calls via the allowed_outbound_hosts feature (when used with Cloudflare’s egress policy enforcement). Any fetch to a host not in the allowlist can be blocked and logged. Configure an egress policy that permits only your expected cloud API endpoints:
# wrangler.toml
[unsafe.metadata]
allowed_outbound_hosts = [
"https://sts.amazonaws.com",
"https://s3.us-east-1.amazonaws.com",
]
Cloudflare logs blocked egress attempts via Logpush. Route these to your SIEM. An exfiltrating module that attempts to POST credentials to an external URL will be blocked and generate a logged event before the credentials leave the function.
For Fastly Compute, outbound fetches are constrained by the allowed_backends configuration in the service definition. A WASM module cannot reach an arbitrary URL — only backends explicitly declared in the service config are reachable. The backend allowlist is enforced by the Fastly runtime, outside the WASM sandbox, so a compromised WASM module cannot bypass it by manipulating its own linear memory. This is the network-layer equivalent of Cloudflare’s service binding model: the runtime enforces access, not the code.
IAM Architecture Summary
| Platform | Within-platform access | Cross-cloud access | Secret storage |
|---|---|---|---|
| Cloudflare Workers | Service Bindings (R2, KV, D1, Queues) — no credentials | OIDC token → STS AssumeRoleWithWebIdentity | Wrangler Secrets (encrypted at rest) |
| Fermyon Spin | Spin key-value store (component-scoped) | Variable injection from Vault/Azure Key Vault | Spin variables with secret = true |
| Fastly Compute | None (no native storage) | Static credentials via Secret Store + rotation | Secret Store (encrypted, access-logged) |
The principle across all three platforms: the WASM binary itself should contain no credentials. Authentication to cloud resources flows through the runtime — via bindings, variable APIs, or secret stores — and the runtime enforces access control at a layer the WASM module cannot circumvent. Combined with network egress allowlisting and CloudTrail source-IP monitoring, this architecture makes credential exfiltration both harder to execute and faster to detect.