Skip to content

Federated identity

A runtime on one cloud often needs a resource on the other: a Cloud Run service sending to an SQS queue, or a Lambda publishing to a Pub/Sub topic. When the runtime already runs as an identity of its own cloud, Alchemy lets the other cloud trust that identity. There is no access key or service account key to create, store, or rotate.

You write the binding the same way as on a native runtime. Federation is what Alchemy picks for you.

Identity Federation covers how federation works across every cloud.

A Cloud Run service runs as a Google service account. Bind an SQS queue to it like you would on a Lambda:

src/api.ts
import * as AWS from "@alchemy.run/aws";
import * as GCP from "@alchemy.run/gcp";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
export const Jobs = AWS.SQS.Queue("Jobs");
export default class Api extends GCP.Run.Service<Api>()(
"Api",
{ main: import.meta.url },
Effect.gen(function* () {
const send = yield* AWS.SQS.SendMessage(Jobs);
return {
fetch: send({ MessageBody: "hello" }).pipe(
Effect.as(HttpServerResponse.text("sent")),
Effect.orDie,
),
};
}).pipe(Effect.provide(AWS.SQS.SendMessageHttp)),
) {}

The binding creates an IAM role that trusts Google’s ID tokens for that one service account, and puts sqs:SendMessage on the role. The trust policy matches the account’s unique ID, which the service exposes as serviceAccountUniqueId:

{
"Effect": "Allow",
"Principal": { "Federated": "accounts.google.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"accounts.google.com:sub": "104729384756102938475",
"accounts.google.com:aud": "104729384756102938475",
"accounts.google.com:oaud": "sts.amazonaws.com"
}
}
}

At runtime the service asks its metadata server for an ID token and trades it for the role’s credentials with AssumeRoleWithWebIdentity. The credentials last an hour and are assumed again, with a fresh token, shortly before they expire.

A Fly Service runs on Fly Machines, and every Machine can mint an OIDC token signed by https://oidc.fly.io/<org>. Bind a bucket to it the same way:

src/api.ts
import * as AWS from "@alchemy.run/aws";
import * as Fly from "@alchemy.run/fly";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
export const Uploads = AWS.S3.Bucket("Uploads");
export default class Api extends Fly.Service<Api>()(
"Api",
{ main: import.meta.url, port: 3000 },
Effect.gen(function* () {
const putObject = yield* AWS.S3.PutObject(yield* Uploads);
return {
fetch: putObject({ Key: "hello.txt", Body: "hello" }).pipe(
Effect.as(HttpServerResponse.text("stored")),
Effect.orDie,
),
};
}).pipe(Effect.provide(AWS.S3.PutObjectHttp)),
) {}

The binding registers Fly’s issuer for your organization as an IAM OIDC provider, and creates a role that trusts tokens from that one App. A token names its Machine as <org>:<app>:<machine>, so the trust policy matches every Machine of the App and nothing else:

{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/oidc.fly.io/my-org"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "oidc.fly.io/my-org:aud": "sts.amazonaws.com" },
"StringLike": { "oidc.fly.io/my-org:sub": "my-org:api-app:*" }
}
}

IAM keeps one provider per issuer in an account, so every stack in the account shares it. Destroying a stack leaves the provider in place.

At runtime the Machine asks its /.fly/api socket for a token and trades it for the role’s credentials. Tokens last 10 minutes; a fresh one is minted each time the credentials are assumed again. The socket answers only root, the default user in the images Alchemy builds. See Fly federated identity for the tokens themselves and the shared provider.

A Lambda runs as an IAM role. Bind a Pub/Sub topic to it:

src/worker.ts
import * as AWS from "@alchemy.run/aws";
import * as GCP from "@alchemy.run/gcp";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/http/HttpServerResponse";
export const Events = GCP.PubSub.Topic("Events", {});
export default class Worker extends AWS.Lambda.Function<Worker>()(
"Worker",
{ main: import.meta.url, functionUrl: true },
Effect.gen(function* () {
const publish = yield* GCP.PubSub.Publish(yield* Events);
return {
fetch: publish({ body: { messages: [{ data: btoa("hello") }] } }).pipe(
Effect.as(HttpServerResponse.text("published")),
Effect.orDie,
),
};
}).pipe(Effect.provide(GCP.PubSub.PublishHttp)),
) {}

The binding creates a service account that holds the publisher role, and lets the Lambda’s role impersonate it. The trust goes through a workload identity pool with an AWS provider for your account. The stack shares one pool, so every AWS runtime in the stack uses it.

At runtime the Lambda signs an STS GetCallerIdentity request with its own role credentials and sends it to Google STS. Google replays the request against AWS to learn which role is calling, and returns a token for the service account. The token lasts an hour and is exchanged again before it expires.

Lambda functions, ECS tasks and services, EC2 instances and launch templates, App Runner services, Batch jobs, EKS pods, and Fly Services reach GCP this way. Cloud Run services, jobs, and worker pools, Cloud Functions, and Fly Services reach AWS this way.

Runtimes without an identity the other cloud can trust still get a key: a Cloudflare Worker, a Railway service, a Fly Sprite, and a GKE pod calling AWS. The key belongs to an identity that may do only what that one host needs. The binding code is the same either way.

A freshly created role or federated grant is not usable right away. AWS usually accepts a new role’s trust within seconds. A GCP grant to a federated principal can take a few minutes. Until it lands, calls fail with a permission error, so retry the first call after a deploy.

See IAM & bindings for the roles each GCP binding grants.