Identity

A sandbox can prove which sandbox it is to anything that accepts OpenID Connect (OIDC) tokens (AWS STS, Google Cloud workload identity federation, Vault, a service of your own) without holding any credential. loam-api is the issuer: a process in the sandbox asks for a token for an audience, and whoever receives it checks loam's signature against loam's public keys.

Method and path
GET /.well-known/openid-configuration OIDC discovery; no key needed
GET /.well-known/jwks.json the public keys; no key needed
GET /identity/token?audience=<aud>&ttl_s=600 a token; from inside the sandbox only, on its API socket

Getting a token

Inside the sandbox, on /run/loam/api.sock:

$ curl -sS --unix-socket /run/loam/api.sock 'http://loam/identity/token?audience=sts.amazonaws.com&ttl_s=900'
{"token":"eyJhbGciOiJSUzI1NiIsImtpZCI6ImpXY3ZFWHlK...","expires_at":"2026-10-12T09:36:26Z"}

audience is required: the aud the receiver expects (sts.amazonaws.com for AWS, your service's URL for your own). ttl_s is whole seconds from 1 to 3600, default 600.

$ curl -sS --unix-socket /run/loam/api.sock 'http://loam/identity/token'
{"error":{"code":"invalid","message":"audience is required"}}
$ curl -sS --unix-socket /run/loam/api.sock 'http://loam/identity/token?audience=x&ttl_s=5000'
{"error":{"code":"invalid","message":"ttl_s must be a number of seconds from 1 to 3600"}}

From TypeScript inside the sandbox: await LoamClient.inside().identityToken('sts.amazonaws.com').

A project key can't get a token: there is no such route under /v1. Only code running in the sandbox can, which is the point.

What's in it

An RS256 JWT. Its header and claims, decoded:

{"alg":"RS256","kid":"jWcvEXyJW-uNKXi-otD4oD3pvV8s_3TpoXs9tpKzq1g","typ":"JWT"}
{
  "iss": "https://api.loam.example.com",
  "sub": "project:docs:sandbox:sb-2kjiso7vff5b",
  "aud": "sts.amazonaws.com",
  "iat": 1791796886, "nbf": 1791796886, "exp": 1791797786,
  "jti": "a6cd035797068b260bd2e70e113d5ad5",
  "project": "docs",
  "sandbox": "sb-2kjiso7vff5b",
  "sandbox_name": "scratch",
  "template": "base",
  "labels": {}
}
Claim
iss the API's base URL, https://api.loam.example.com
sub project:<project>:sandbox:<id>: stable for the sandbox's life, and never reused
aud the audience asked for
iat, nbf, exp issued, valid from, expires (Unix seconds)
jti a unique ID per token
project, sandbox, sandbox_name, template, labels the sandbox as it is when the token is made

kid in the header is the RFC 7638 thumbprint of the signing key. Trust sub and project for authorization; a name or a label is something anyone with the project's key can change.

Discovery and keys

$ curl -sS https://api.loam.example.com/.well-known/openid-configuration
{"claims_supported":["iss","sub","aud","iat","nbf","exp","jti","project","sandbox","sandbox_name","template","labels"],
 "id_token_signing_alg_values_supported":["RS256"],"issuer":"https://api.loam.example.com",
 "jwks_uri":"https://api.loam.example.com/.well-known/jwks.json","response_types_supported":["id_token"],
 "subject_types_supported":["public"]}
$ curl -sS https://api.loam.example.com/.well-known/jwks.json
{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"jWcvEXyJW-uNKXi-otD4oD3pvV8s_3TpoXs9tpKzq1g","n":"ppJYzNCcvQhcN6zU...","e":"AQAB"}]}

When loam rotates its key, the old one stays in the JWKS while tokens it signed can still be live, so a verifier that fetches the JWKS by kid (and refetches on an unknown kid) keeps working.

A deployment without a signing key answers 501 unsupported on these routes and on /identity/token.

Verifying a token

Any OIDC or JWT library does this given the issuer URL. By hand, in Node 20 or later with no packages:

// verify.mjs: check a loam identity token against loam's JWKS.
import { createPublicKey, verify } from 'node:crypto'

const issuer = 'https://api.loam.example.com'
const audience = 'sts.amazonaws.com'
const token = process.argv[2]

const [h, p, sig] = token.split('.')
const header = JSON.parse(Buffer.from(h, 'base64url'))
const claims = JSON.parse(Buffer.from(p, 'base64url'))

const conf = await (await fetch(`${issuer}/.well-known/openid-configuration`)).json()
const { keys } = await (await fetch(conf.jwks_uri)).json()
const jwk = keys.find((k) => k.kid === header.kid)
if (header.alg !== 'RS256' || !jwk) throw new Error('unknown key or algorithm')

const ok = verify('RSA-SHA256', Buffer.from(`${h}.${p}`), createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(sig, 'base64url'))
const now = Date.now() / 1000
if (!ok) throw new Error('bad signature')
if (claims.iss !== conf.issuer || claims.aud !== audience) throw new Error('wrong issuer or audience')
if (now < claims.nbf || now >= claims.exp) throw new Error('expired or not yet valid')
console.log('valid:', claims.sub, claims.sandbox_name, claims.labels)
$ TOKEN=$(loam run scratch -- curl -sS --unix-socket /run/loam/api.sock \
    'http://loam/identity/token?audience=sts.amazonaws.com' </dev/null | jq -r .token)
$ node verify.mjs "$TOKEN"
valid: project:docs:sandbox:sb-2kjiso7vff5b scratch { team: 'docs' }

AWS: assume a role with the token

A sketch; it needs an AWS account, so it wasn't run for these docs. Once, in AWS, register loam as an identity provider and make a role that trusts it for your project:

aws iam create-open-id-connect-provider \
  --url https://api.loam.example.com \
  --client-id-list sts.amazonaws.com

The role's trust policy (ACCOUNT is your account ID; the sub pattern admits every sandbox in project docs; name one sandbox's ID to admit only it):

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": {"Federated": "arn:aws:iam::ACCOUNT:oidc-provider/api.loam.example.com"},
    "Action": "sts:AssumeRoleWithWebIdentity",
    "Condition": {
      "StringEquals": {"api.loam.example.com:aud": "sts.amazonaws.com"},
      "StringLike": {"api.loam.example.com:sub": "project:docs:sandbox:*"}
    }
  }]
}

Then, in the sandbox, the AWS SDKs and CLI do the exchange themselves when given a file with the token and the role:

curl -sS --unix-socket /run/loam/api.sock 'http://loam/identity/token?audience=sts.amazonaws.com' \
  | jq -r .token > /tmp/aws-token
export AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/aws-token
export AWS_ROLE_ARN=arn:aws:iam::ACCOUNT:role/loam-docs
export AWS_ROLE_SESSION_NAME=$(hostname)
aws sts get-caller-identity

or explicitly, aws sts assume-role-with-web-identity --role-arn ... --role-session-name ... --web-identity-token "$(cat /tmp/aws-token)". The token lives 10 minutes by default; the SDKs read the file again when they need new credentials, so refresh it (a service that rewrites it every few minutes) for long-running work. The sandbox needs to reach sts.amazonaws.com (and the AWS services it uses) if its egress is restricted.