Skip to content

Federated identity

A Fly Service often needs a resource on another cloud: an S3 bucket, a Pub/Sub topic. Its Machines can prove which App they belong to, so AWS and GCP can trust them directly. There is no access key or service account key to create, store or rotate.

You bind the resource the same way you would on a Lambda or Cloud Run. Federation is what Alchemy picks for a Fly Service.

Every Machine serves part of the Machines API on a Unix socket, /.fly/api, already authenticated as that Machine. A process on it can ask for an OIDC token without holding any secret:

Terminal window
curl --unix-socket /.fly/api -X POST http://localhost/v1/tokens/oidc \
-d '{"aud": "sts.amazonaws.com"}'

The token is signed by your organization’s issuer, https://oidc.fly.io/<org>, and names the Machine in its subject:

{
"iss": "https://oidc.fly.io/my-org",
"aud": "sts.amazonaws.com",
"sub": "my-org:api-app:3d8d9e1a2b3c4d",
"app_name": "api-app",
"exp": 1762300800
}

A token lasts 10 minutes. The socket answers only root, the default user in the images Alchemy builds. A Service whose image switches to another user fails its first cross-cloud call with OidcTokenError.

Bind an S3 bucket to a Service:

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 your organization’s issuer as an IAM OIDC provider, and creates a role that holds s3:PutObject on the bucket. The role trusts tokens whose subject starts with the Service’s App, so every Machine of the App can assume it and no other App can:

{
"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:*" }
}
}

At runtime the Machine mints a token and trades it for the role’s credentials:

ApiFly Machine/.fly/apiFlyAWS STSUploadsS3 bucketOIDC token for sts.amazonaws.comsigned token · 10 minAssumeRoleWithWebIdentityrole credentials · 1 hourPutObject
The Machine asks Fly who it is, and AWS checks Fly's signature and the App in the subject. Shortly before the credentials expire, it mints a fresh token and assumes the role again.

IAM allows one OIDC provider per issuer URL in an account. Fly’s issuer is per organization, so every stack that binds a Fly Service in the same account and organization shares one provider. The provider only says that Fly’s signatures are trusted; each role’s condition decides which App may use it.

Destroying a stack leaves the provider in place, because other stacks may still use it. Delete it yourself once no stack needs it:

Terminal window
aws iam delete-open-id-connect-provider \
--open-id-connect-provider-arn arn:aws:iam::123456789012:oidc-provider/oidc.fly.io/my-org

Bind a Pub/Sub topic the same way:

src/api.ts
import * as Fly from "@alchemy.run/fly";
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 Api extends Fly.Service<Api>()(
"Api",
{ main: import.meta.url, port: 3000 },
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 on the topic. The stack gets a workload identity pool whose provider trusts your organization’s issuer and maps each token to its App. The service account lets that one App impersonate it:

principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/<pool>/attribute.fly_app/api-app

At runtime the Machine mints a token addressed to the pool’s provider, trades it at Google STS for a federated token, and trades that for the service account’s access token, which lasts an hour.

A pool isn’t tied to an issuer, so each stack has its own and destroys it with the stack.

A Service calling another Fly Service needs no token either. Fly’s proxy signs every private request with a Fly-Src header naming the calling App, and a Service answers only the Apps that bind it. See Connect Services.

A Sprite is not a Machine and has no /.fly/api socket, so it can’t mint tokens. A Sprite that binds an AWS or GCP resource gets a key for an identity that may do only what that Sprite needs, deployed as a secret. The binding code is the same.

AWS usually accepts a new role’s trust within seconds. A GCP grant to a federated principal can take a few minutes to land, and until it does calls fail with a permission error. Retry the first call after a deploy.