---
title: "Workload Identity"
description: "Give Limrun workloads permissions to access resources without copying your API key into them."
canonical_url: "https://docs.limrun.com/docs/platform/workload-identity"
markdown_url: "https://docs.limrun.com/docs/platform/workload-identity.md"
last_updated: "2026-11-14"
x_farming_labs_generated_preamble: true
---

# Workload Identity
URL: /docs/platform/workload-identity
LLM index: /llms.txt
Description: Give Limrun workloads permissions to access resources without copying your API key into them.

# Workload Identity

Workload Identity lets a Limrun instance authenticate to the Limrun API with its own credential. When you create the instance, you choose the permissions it receives. Code running inside the workload can then access those resources without your organization's API key.

The same model applies to iOS simulators, Android emulators, Xcode sandboxes, and Gradle sandboxes. Assets are the first supported resource type. For example, you can let a build upload to one named asset while denying access to other assets in your organization.

## Identity, permissions, and access tokens

A workload identity identifies the running instance and its organization. Its role contains the permissions currently delegated to it. An access token is the short-lived credential the workload uses to make authorized API requests.

The request flow is:

1. Your application creates an instance using its own credential and includes `spec.role.permissions`.
2. Limrun checks that your credential is allowed to delegate every requested permission.
3. The workload exchanges its identity for an access token carrying its assigned permissions.
4. Each API request using that token must satisfy the permission required for its resource and operation.

The creator's credential stays with your application. A successful instance creation does not grant the workload all of the creator's permissions.

An instance token such as `status.token` has a different purpose: it lets a client control that instance. Workload Identity gives the workload access to other resources. See [credentials](/docs/concepts#credentials) for client and device credentials.

See [Permissions and access tokens](/docs/reference/permissions) for the complete resource, action, and organization-role tables.

## Delegated permissions

All four instance create APIs accept the same inline role:

```json
{
  "spec": {
    "role": {
      "permissions": [
        "asset:name/input.zip:read"
      ]
    }
  }
}
```

This fragment grants permission to read and download `input.zip`. A permission has the form `resource:target:verb`. Each resource type defines its targets and verbs. The [asset permission reference](/docs/platform/asset-storage#delegated-asset-permissions) lists the supported asset operations, IDs, names, and wildcard patterns.

The role is a limit on the workload's own credential. It does not change the capabilities needed to operate the instance itself. It also cannot restrict a separate API key, access token, or signed URL that you supply to a process or build.

You can replace a running instance's permissions with a conditional PATCH. Requesting an access token inherits the current role and cannot expand it. Existing tokens retain their grants until expiry.

### Delegation limits

You can delegate only permissions covered by your own credential. A valid permission outside your grants causes creation or a role update to return `403 Forbidden`. Malformed permissions return `400 Bad Request`.

Asset name patterns have an additional rule. Delegating any pattern containing `*` or `?` requires the general `asset:*` target for that verb, or an equivalent broader permission. Holding a name pattern lets you delegate matching concrete names, but not another pattern, even an identical one. This avoids relying on pattern-overlap comparisons. See [asset name patterns](/docs/platform/asset-storage#delegated-asset-permissions) for examples.

For example, this role permits uploads to assets named `release-1.0.zip`,
`release-2.0.zip`, and other names matching `release-*.zip`:

```ts
spec: {
  role: {
    permissions: ['asset:name/release-*.zip:upload'],
  },
}
```

Here, `name/release-*.zip` is the permission target. Delegating it requires
`asset:*:upload`, `asset:*:all`, or an equivalent broader grant. Use a concrete
name such as `name/release-1.0.zip` as the upload destination. Add matching
`create` permission if the workload must create an asset that does not exist yet.

### Explicit and omitted roles

`role: { permissions: [] }` grants no access to other resources through the workload credential. It leaves the instance's normal operating capabilities available.

Omitting `role` uses the organization's compatibility default. For organizations with legacy upload access enabled, that default permits uploads to every asset in the organization. An explicit list makes the intended access independent of this default.

A role must contain a non-null `permissions` array with at most 100 entries. `role: {}` is invalid. Named role references are not supported. See [instance role validation and reuse](/docs/reference/instances#delegated-permissions) for API behavior.

## Updating a running workload

GET returns the current role and an `ETag` for live instances. Send that ETag in
`If-Match` when replacing `spec.role.permissions`. The same operation is available
on `/v1/ios_instances/{id}`, `/v1/android_instances/{id}`,
`/v1/xcode_instances/{id}`, and `/v1/gradle_instances/{id}`.

<CodeGroup labels={["TypeScript", "cURL"]}>
```ts
const { data, response } = await lim.xcodeInstances.get(id).withResponse();
const ifMatch = response.headers.get('etag');
if (!ifMatch || !data.spec.role) throw new Error('Instance is no longer live');
await lim.xcodeInstances.update(id, {
  ifMatch,
  spec: {
    role: {
      permissions: [...new Set([
        ...data.spec.role.permissions,
        'asset:name/releases/app.ipa:create',
        'asset:name/releases/app.ipa:upload',
        'asset:name/releases/app.ipa:update',
      ])],
    },
  },
});
```

```bash
# Read the instance and copy the quoted ETag response header.
curl -i "https://api.limrun.com/v1/xcode_instances/$INSTANCE_ID" \
  -H "Authorization: Bearer $LIM_API_KEY"

# Replace the entire permission list using that ETag.
curl -X PATCH "https://api.limrun.com/v1/xcode_instances/$INSTANCE_ID" \
  -H "Authorization: Bearer $LIM_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'If-Match: "ETAG_FROM_GET"' \
  -d '{"spec":{"role":{"permissions":["asset:name/releases/app.ipa:all"]}}}'
```
</CodeGroup>

A successful update returns `204 No Content`. The array replaces the previous
list, while other instance fields stay unchanged. Use `[]` to remove all delegated
grants. A concurrent change returns `412 Precondition Failed`; read the instance
again and recompute the list before retrying. Malformed bodies and missing
`If-Match` return `400`. Terminating instances reject updates with `409`.
Historical records have no editable ETag. List results may lag behind live GETs.

Removing grants does not revoke previously issued tokens or signed URLs.
Workload tokens can remain usable for up to ten minutes; signed URLs retain their
own expiry. Build daemons exchange identity again for each permission-based upload.

The same PATCH endpoint can replace customer labels through `metadata.labels`,
independently of permissions. Omit `spec` for a labels-only change, or supply both
fields to update them atomically. Use an empty labels object to clear customer labels.
See [updating labels](/docs/reference/instances#updating-labels) for examples.

Updating requires an API key with control of the instance, such as
`xcode:<instance-id>:all` or `xcode:*:all`. Use the corresponding `ios`, `android`,
or `gradle` resource for other workloads. If you supply permissions, your key must
cover every entry in the replacement list, including entries you preserve.
A labels-only update does not reauthorize the unchanged role. Signed access tokens,
instance tokens, and workload identities cannot call this management endpoint.

To update labels and permissions together, include `metadata` alongside `spec`
in the same request. Both updates succeed together or neither is applied:

```ts
const { data: current, response: snapshot } = await lim.xcodeInstances.get(id).withResponse();
const etag = snapshot.headers.get('etag');
if (!etag || !current.spec.role) throw new Error('Instance is no longer live');
await lim.xcodeInstances.update(id, {
  ifMatch: etag,
  metadata: { labels: { ...current.metadata.labels, branch: 'release' } },
  spec: { role: { permissions: current.spec.role.permissions } },
});
```

## Authentication inside the workload

Limrun provides an identity token file and the environment variables `LIMRUN_WORKLOAD_IDENTITY_TOKEN_FILE` and `DIRECTOR_URL` to the workload environment. The TypeScript SDK discovers them when no API key is configured:

```ts
import Limrun from '@limrun/api';

// Run inside the workload environment, with LIM_API_KEY unset.
const lim = new Limrun();
```

On the first API call, the SDK exchanges the identity for an access token. It caches the token and refreshes it before expiry by rereading the identity file. Your code does not need to manage these exchanges.

An API key supplied to the constructor or through `LIM_API_KEY` takes precedence. Set `workloadTokenFile: null` to disable identity discovery. These environment variables belong to the workload environment; they are not credentials you need to embed in a mobile app running on a simulator or emulator.

Other clients can use the same [REST token exchange](/docs/reference/sdk#workload-token-exchange). An exchange with no requested permission list inherits the assigned role. A request for a smaller list must stay within that role and obey the delegation rules above. A workload with an empty permission list cannot obtain an access token through this exchange.

### Credential lifetime

Workload access tokens expire after ten minutes by default. REST callers can request a shorter lifetime. Refresh requires the current workload identity; an access token cannot refresh itself.

Terminating the instance prevents new exchanges. An issued access token remains valid until it expires or is revoked. A signed upload or download URL also carries its own access until its expiry. Terminating an instance does not invalidate URLs it has already received.

## Example: upload a build artifact

This TypeScript example runs on your machine or backend, where `LIM_API_KEY` authenticates instance creation. It uses the [TypeScript SDK](/docs/reference/sdk#install) and a local Xcode project at `./MyProject` with scheme `MyApp`. The build runs inside the new workload and uploads using that workload's identity.

```ts
import Limrun from '@limrun/api';

const lim = new Limrun();
const existingPermissions = ['asset:name/input.zip:read'];
const instance = await lim.xcodeInstances.create({
  wait: true,
  spec: {
    role: {
      permissions: [
        ...existingPermissions,
        'asset:name/preview/app.ipa:create',
        'asset:name/preview/app.ipa:upload',
        'asset:name/preview/app.ipa:update',
      ],
    },
  },
});

try {
  const xcode = await lim.xcodeInstances.createClient({ instance });
  await xcode.sync('./MyProject', { watch: false });
  await xcode.xcodebuild(
    { scheme: 'MyApp' },
    { upload: { target: 'name/preview/app.ipa', ttl: '24h' } },
  );
} finally {
  await lim.xcodeInstances.delete(instance.metadata.id);
}
```

The permissions authorize the upload. The separate `upload.target` tells the build where to send its artifact. Assigning a permission alone does not trigger an upload. The SDK sends your permission array unchanged; it does not add grants from `upload.target`.

For a name target, `create` lets the workload create the asset if it is absent. `upload` allows writing the file, and `update` allows recording app metadata and expiry. Build uploads default to a 14-day expiry, so they need `update` even when you omit `ttl`. An existing asset ID needs no `create` grant. Downloading the result requires a separate `read` grant.

Gradle builds and `uploadLatestRbeBuild` use the same target shape. The build REST APIs call the field `assetUpload`. Targets identify one concrete asset; a permission pattern does not choose a destination. See [build with Xcode](/docs/ios/build-with-xcode) and [build with Gradle](/docs/android/build-with-gradle) for build options.

An explicit `upload: { target, token }` uses that supplied access token. If the token is rejected or expired, the build does not fall back to workload credentials. `upload: { signedUploadUrl }` remains supported and needs no workload permission grants.

## CLI permissions

When the CLI creates an instance for a named upload, it adds the exact-name `create`, `upload`, and `update` permissions automatically. You do not need `--permission` for the upload itself:

```sh
lim xcode build ./MyProject --scheme MyApp --upload preview/app.ipa --upload-ttl 24h
lim gradle build ./my-app --upload result.apk --permission asset:name/input.zip:read
```

Repeatable `--permission` values add other grants to the creation request. `--upload` takes a plain asset name; the CLI adds the `name/` prefix. Automatic upload names cannot contain `*` or `?`. RBE `--auto-upload <name>` follows the same rules.

All four create commands, `run`, Xcode build/test/sync/RBE, and Gradle build/sync accept `--permission`. Every CLI creation sends an explicit list, empty when no grants are needed. Auxiliary simulators receive an empty list; `ios create --xcode` gives both newly created workloads the requested grants.

A named upload keeps the selected instance. The CLI reads its live role and adds create, upload, and update permissions for the exact asset name. It preserves existing grants and retries concurrent changes. Your credential must control the instance and cover the resulting permission list. RBE auto-upload adds the grants at tunnel startup; manual RBE uploads add them before uploading. The daemon obtains fresh workload credentials for each permission-based upload. `--reuse-if-exists` still matches labels and leaves the stored role unchanged; `--permission` applies only when creating a new instance.

`--signed-upload-url` and `LIM_SIGNED_UPLOAD_URL` keep their existing behavior and add no workload grants.

## Sitemap

See the full [sitemap](/sitemap.md) for all pages.
Docs-scoped sitemap: [/docs/sitemap.md](/docs/sitemap.md).
Well-known sitemap: [/.well-known/sitemap.md](/.well-known/sitemap.md).
