Every Sandbox Gets an Identity
Somewhere in almost every app there is a long-lived cloud credential. A service account key so it can write to a bucket. A token for the thing that sends the email. They start life pasted into an environment variable or a secret store, and from there they spread: into a teammate’s .env, into a CI variable, into a screenshot in a support thread. They don’t expire on their own. Rotating them is a tedious project that nobody wants to start. And the whole time they sit there, each one is a key that works from anywhere, for anyone who has it.
Underneath, a credential is really just a way to establish trust. I hold this secret, therefore I’m a legitimate caller. But a secret is a clumsy way to prove who you are. It’s detached from who you actually are, which is the whole reason a leaked copy works for anyone who finds it. There’s a better way to prove the same thing. If your app is deployed on Miren, the cluster already knows what it is, because it’s the one running it — which app, which org, which cluster — and it can make signed assertions about all of it. That’s an identity, and a far stronger thing to show the outside world than a secret you pasted into an env var.
That’s the idea behind workload identity, which we shipped in Miren 0.10. Every sandbox gets a signed, short-lived token that proves what it is. You don’t configure it or switch it on — it’s just there when the container starts, and it refreshes itself in the background so it’s always valid. Your app can hand that token to anything that speaks OIDC, whether that’s AWS, GCP, Vault, or another one of your own services, and prove who it is without holding a long-lived secret.
The rest of this post is about what you do with that token. We’ll work through two examples: federating into a cloud provider like AWS, and letting two of your own services authenticate to each other across clusters. If Route Protection was about who gets into your app, this is the other direction: who your app is when it reaches out.
It’s already in the container
When a sandbox starts, Miren writes a token to a well-known path and sets a handful of environment variables:
$ env | grep MIREN_
MIREN_IDENTITY_TOKEN_PATH=/var/run/miren/identity-token
MIREN_OIDC_ISSUER_URL=https://cluster-abcxyz.miren.systems
MIREN_IDENTITY_TOKEN_URL=http://10.0.0.1:7123/v1/token
MIREN_IDENTITY_TOKEN_SECRET=9f86d081884c7d659a2feaa0c55ad015...
$ cat /var/run/miren/identity-token
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL...
That file is a JWT signed by your cluster, and reading your own identity is just a cat away. The variables sort into two jobs. The first two are about holding that identity: MIREN_IDENTITY_TOKEN_PATH is where the token file lives, and MIREN_OIDC_ISSUER_URL is the address other systems verify it against. The other two are about minting fresh tokens scoped to whoever you’re about to call — the more useful trick, which we’ll dig into shortly.
What’s in the token
That eyJ... blob looks opaque, but a JWT is just signed JSON. Decode this one and you can read exactly what the cluster is claiming about the workload:
{
"iss": "https://cluster-abcxyz.miren.systems",
"sub": "org:acme:app:myapp:sandbox:sbx_8f3a2c",
"aud": "miren",
"organization_id": "acme",
"cluster_id": "prod-us-east",
"app": "myapp",
"sandbox_id": "sbx_8f3a2c",
"iat": 1748793600,
"nbf": 1748793600,
"exp": 1748797200,
"jti": "..."
}
Most of these are the usual JWT plumbing: who issued it (iss), the window it’s valid for (iat, nbf, exp), and a unique ID for the token itself (jti). The ones that matter here describe the workload — its organization_id, its cluster_id, the app name, and the sandbox_id. The subject (sub) stitches those into a single string, org:<org>:app:<app>:sandbox:<sandbox-id>, so a relying party can match at exactly the granularity it wants: one specific sandbox, every sandbox of myapp, or anything in the acme org. The token describes the workload, and the other side decides how much of that description to require.
The one claim you’ll set yourself is the audience. The token in the file always reads "aud": "miren" — fine for proving who you are, but most relying parties want to see their own name in aud, as proof the token was minted for them and not lifted from somewhere else.
Anyone can verify it
A token nobody can check is just a string. Workload identity tokens are verifiable by standard OIDC discovery, served straight off your cluster:
$ curl https://cluster-abcxyz.miren.systems/.well-known/openid-configuration
{
"issuer": "https://cluster-abcxyz.miren.systems",
"jwks_uri": "https://cluster-abcxyz.miren.systems/.well-known/miren/jwks",
"response_types_supported": ["id_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
$ curl https://cluster-abcxyz.miren.systems/.well-known/miren/jwks
{ "keys": [ { "kty": "RSA", "alg": "RS256", "use": "sig", "kid": "...", "n": "0vx7...", "e": "AQAB" } ] }
This is the same /.well-known/ discovery flow that GitHub Actions, Google, and every other OIDC provider uses. Any system that already knows how to federate against an OIDC issuer can federate against yours — there’s no Miren-specific client or special SDK. The cluster holds the signing key; the public key is published at the JWKS endpoint; verifiers fetch it and check the signature themselves.
Minting a token for who you’re calling
The token in the file is generic: it says “I am this workload,” to an audience of miren. That’s enough to prove identity, but the stronger move is to mint a token addressed to the specific thing you’re about to call. A token whose aud is sts.amazonaws.com is only useful to AWS; one whose aud is your billing service is only useful there. An audience-scoped token that leaks can’t be replayed against a different relying party, because the aud won’t match.
That’s where the other two variables come in. MIREN_IDENTITY_TOKEN_URL is a local endpoint — reachable only from inside the sandbox — that mints a fresh token on demand, and you authenticate to it with MIREN_IDENTITY_TOKEN_SECRET. That secret is a local key to this one endpoint, not a credential to the outside world: it’s scoped to this sandbox and gone when the sandbox stops. Ask for an audience and a lifetime, and you get back a signed JWT:
$ curl -s -H "Authorization: Bearer $MIREN_IDENTITY_TOKEN_SECRET" \
"$MIREN_IDENTITY_TOKEN_URL?audience=sts.amazonaws.com&ttl=900"
{"value":"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."}
audience can be repeated to mint a token good for more than one recipient, and ttl is the lifetime in seconds (60 seconds to 24 hours; it defaults to an hour). Everything else about the token — the subject, the org and app claims, the signature — is identical to the one in the file.
In real code you’ll want a small wrapper around that endpoint rather than a raw curl. This one mints a token per audience and caches it until the token’s close to expiring — and if you’ve used GitHub Actions OIDC, it’s the same shape as ACTIONS_ID_TOKEN_REQUEST_URL plus getIDToken(audience): a request-scoped token for a named audience, fetched right when you need it:
import { decodeJwt } from "jose";
const TOKEN_URL = process.env.MIREN_IDENTITY_TOKEN_URL!;
const TOKEN_SECRET = process.env.MIREN_IDENTITY_TOKEN_SECRET!;
const cache = new Map<string, { token: string; exp: number }>();
// A helper for your own app: hand it an audience, get back a token minted for that caller.
async function tokenFor(audience: string, ttlSeconds = 300): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const hit = cache.get(audience);
if (hit && hit.exp - 60 > now) return hit.token;
const url = new URL(TOKEN_URL);
url.searchParams.set("audience", audience);
url.searchParams.set("ttl", String(ttlSeconds));
const res = await fetch(url, { headers: { authorization: `Bearer ${TOKEN_SECRET}` } });
if (!res.ok) throw new Error(`mint failed: ${res.status}`);
const { value } = await res.json();
cache.set(audience, { token: value, exp: decodeJwt(value).exp! });
return value;
}
With that helper in hand, the only thing that changes from one integration to the next is what goes in audience. Let’s look at a couple of concrete examples.
A worked example: AWS with no stored keys
Say your app needs to read from an S3 bucket. Normally you’d create an IAM user, generate an access key, and paste it into your app’s secrets — a long-lived credential you now have to guard and eventually rotate. Workload identity lets you skip the key altogether: you teach AWS to trust tokens from your cluster once, and from then on your app mints a token and trades it for short-lived AWS credentials whenever it needs them, holding no permanent key of its own.
First, register your Miren issuer as an OIDC identity provider in IAM (once per cluster). The client ID is the audience AWS will require your tokens to carry — sts.amazonaws.com is the conventional choice:
aws iam create-open-id-connect-provider \
--url https://cluster-abcxyz.miren.systems \
--client-id-list sts.amazonaws.com
Then create a role whose trust policy accepts tokens from that issuer — scoped, in this case, to a single app in a single org:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/cluster-abcxyz.miren.systems" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"cluster-abcxyz.miren.systems:aud": "sts.amazonaws.com"
},
"StringLike": {
"cluster-abcxyz.miren.systems:sub": "org:acme:app:myapp:sandbox:*"
}
}
}]
}
That aud condition is why we mint instead of reading the file: the file’s token says miren, but AWS wants to see sts.amazonaws.com. The sub condition is where the rest of the claims earn their keep — the wildcard here trusts every sandbox of myapp in the acme org, so any instance of the app can assume the role but nothing else can. Drop the wildcard and pin the full subject to lock it down to a single sandbox, or widen it to org:acme:app:* to cover every app in the org — on this cluster. That qualifier matters: the trust is anchored to one cluster (the Federated provider above is cluster-abcxyz), because each Miren cluster is its own OIDC issuer with its own signing key. An org that spans clusters registers each cluster as its own provider.
Finally, the app mints a token for AWS and trades it for real credentials at runtime:
TOKEN=$(curl -s -H "Authorization: Bearer $MIREN_IDENTITY_TOKEN_SECRET" \
"$MIREN_IDENTITY_TOKEN_URL?audience=sts.amazonaws.com&ttl=900" | jq -r .value)
aws sts assume-role-with-web-identity \
--role-arn arn:aws:iam::123456789012:role/myapp-s3-reader \
--role-session-name myapp \
--web-identity-token "$TOKEN"
You get back a set of temporary AWS credentials, good for the length of the session and no longer.
In real code you don’t run that exchange by hand. The AWS SDK takes a credential provider, so you hand it one that mints a token and lets the SDK do the AssumeRoleWithWebIdentity call, refreshing as the credentials expire:
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { fromWebToken } from "@aws-sdk/credential-providers";
// Mints a fresh Miren token and trades it for AWS credentials, refreshed
// automatically as they expire. Reuses tokenFor from earlier.
const s3 = new S3Client({
credentials: async () =>
fromWebToken({
roleArn: "arn:aws:iam::123456789012:role/myapp-s3-reader",
webIdentityToken: await tokenFor("sts.amazonaws.com"),
})(),
});
// From here it's ordinary S3. No access key anywhere in your app.
const obj = await s3.send(new GetObjectCommand({ Bucket: "reports", Key: "q2.csv" }));
Your application code calls S3 normally and never sees a key. Nothing static was ever stored. Nothing needs rotating. The same shape works for GCP Workload Identity Federation, HashiCorp Vault’s JWT auth, and any other OIDC-aware system.
If you want to run exactly this, bucketorama is a complete version in our sample-apps repo — the mint helper, the SDK wiring, and a local-dev fallback so you can develop against a bucket before you deploy.
Services that recognize each other
The relying party doesn’t have to be a cloud provider. It can be another one of your services. Say service A runs in cluster X and service B runs in cluster Y, and A needs to call B over the public internet. The classic answer is a shared API key that both sides hold, with all the rotation and leakage problems that brings. With workload identity, neither side holds a secret: A presents its token, B verifies it, and the verification is the authentication.
Each cluster signs with its own key, so this is genuinely cross-cluster. B doesn’t need cluster X’s key ahead of time — it just needs to know cluster X is an issuer it federates with. The token names its issuer, and B fetches that cluster’s public keys from the standard JWKS endpoint, exactly the way it would verify a token from Google or any other OIDC provider.
The caller mints a token addressed to B — the audience is simply B’s URL — and sends it along, reusing the tokenFor helper from earlier:
const SERVICE_B = "https://service-b.cluster-wxy789.miren.systems";
const res = await fetch(`${SERVICE_B}/work`, {
headers: { authorization: `Bearer ${await tokenFor(SERVICE_B)}` },
});
Because the token is minted for B specifically, a copy captured in B’s logs can’t be turned around and spent against a third service — the aud names B and nothing else.
The receiver verifies it with stock JWKS code — jose here, but the same handful of steps in any language. It reads its own org from its own identity token once at startup, then checks every incoming token against it:
import { createRemoteJWKSet, jwtVerify, decodeJwt } from "jose";
// Who we are: the audience callers must mint their tokens for (our own URL),
// and our org, read once from our own identity token.
const MY_AUDIENCE = "https://service-b.cluster-wxy789.miren.systems";
const myToken = (await Bun.file(process.env.MIREN_IDENTITY_TOKEN_PATH!).text()).trim();
const MY_ORG = decodeJwt(myToken).organization_id as string;
// The cluster issuers we federate with — anchor trust here, on issuers you
// chose, not on a hostname pattern. Issuer URLs are per cluster, and may be
// Miren-assigned names or your own domains. Each gets a cached JWKS fetcher.
const TRUSTED_ISSUERS = new Map(
[
"https://cluster-abcxyz.miren.systems", // cluster X, where service A runs
"https://cluster-wxy789.miren.systems", // our own cluster
].map((iss) => [iss, createRemoteJWKSet(new URL(`${iss}/.well-known/miren/jwks`))]),
);
async function verifyPeer(bearer: string) {
const { iss } = decodeJwt(bearer);
const jwks = TRUSTED_ISSUERS.get(iss as string);
if (!jwks) throw new Error(`untrusted issuer: ${iss}`);
// jwtVerify checks the signature, expiry, and — crucially — that the token
// was minted for us: audience must equal MY_AUDIENCE.
const { payload } = await jwtVerify(bearer, jwks, {
issuer: iss as string,
audience: MY_AUDIENCE,
});
// The clinching check: the peer is in our org.
if (payload.organization_id !== MY_ORG) {
throw new Error("peer is in a different org");
}
return payload; // { app, sandbox_id, organization_id, ... } — now trusted
}
(If A and B share a cluster, nothing here changes — TRUSTED_ISSUERS just has the one entry, B’s own cluster.)
Wire it into the request handler and you have authenticated service-to-service calls:
Bun.serve({
port: 8080,
async fetch(req) {
const bearer = req.headers.get("authorization")?.replace(/^Bearer /, "");
if (!bearer) return new Response("missing token", { status: 401 });
try {
const peer = await verifyPeer(bearer);
return Response.json({ ok: true, caller: peer.app });
} catch (err) {
return new Response(`unauthorized: ${(err as Error).message}`, { status: 403 });
}
},
});
None of that rests on a secret the workload holds. The signature has to come from a cluster on B’s federation list, the organization_id is stamped by Miren rather than chosen by the caller, and the aud was fixed to B when the token was minted — none of which the app gets to pick. So a token from an issuer B doesn’t federate with never gets past the lookup, a token from someone else’s org carries the wrong organization_id, and one minted for another service carries the wrong aud. The two services never exchanged a secret or set up a rotation. They both belong to the same org, and they can prove it.
Short-lived by design
Everything you’ve seen is short-lived by design: minted tokens last minutes, the file token refreshes itself, and nothing sticks around long enough to be worth stealing. It’s the same GitHub Actions OIDC model we already run the other direction. For secretless deploys from CI, GitHub issues the token and your Miren cluster verifies it, so no credential sits on the runner. Workload identity reverses the roles: now your cluster is the issuer, and the workloads it runs can prove who they are to AWS, a peer service, or anything else that speaks OIDC.
A couple of honest notes
Workload identity needs a public issuer URL to function, which means it activates once your cluster has a public name for the discovery endpoints to live on. Sandboxes without one simply don’t get a token, rather than getting a broken one.
And to be clear about the division of labor: Miren issues identity, not access. The token proves what the workload is; the relying party — AWS, GCP, your own service — decides what that identity is allowed to do. That’s the right split. You write the authorization policy where the resource lives, and Miren’s job is just to tell the truth about who’s asking.
Try it
If you’re running Miren 0.10 or later, the token is in your sandboxes right now. Read the file straight out of a sandbox, or mint one for whoever you’re about to call:
miren app run -a myapp -- cat /var/run/miren/identity-token
miren app run -a myapp -- sh -c \
'curl -s -H "Authorization: Bearer $MIREN_IDENTITY_TOKEN_SECRET" \
"$MIREN_IDENTITY_TOKEN_URL?audience=sts.amazonaws.com&ttl=900"'
Decode either one, point a relying party at your /.well-known/openid-configuration, and wire up a role. The full reference — claim formats, federation setup for each cloud, and the discovery endpoints — lives in the workload identity docs.
If you haven’t tried Miren yet, this is a good excuse to get started. Every app you run gets an identity, for free, with nothing to store.