# Tailscale Workload Identity Federation


Put a VM on your Tailscale network, or let it call the Tailscale API,
without copying a Tailscale auth key or OAuth secret onto it. Instead, the VM
asks exe.dev for a short-lived token that proves which VM it is, and
Tailscale accepts that token because you told it to trust exe.dev.

You need two browser tabs: the exe.dev [Integrations page](/integrations)
and Tailscale's
[Trust credentials](https://console.tailscale.com/admin/settings/trust-credentials)
page. You must be allowed to add trust credentials in your Tailscale
network.

The integration only hands out the token. Once a VM has joined, it stays on
your Tailscale network until it logs out or you remove it in Tailscale, even
if you detach or delete the integration. The steps below register VMs as
ephemeral, so Tailscale removes them on its own when they go away.

## 1. Start the integration in exe.dev

On the [Integrations page](/integrations), add an **Identity Federation**
integration and choose **Tailscale**. Give it a name, such as `tailscale-wif`,
and attach it to the VM or tag that should use it.

The dialog shows an **Issuer** and a **Subject**. You will paste both into
Tailscale in the next step, and Tailscale will give you a **Client ID** to
paste back. You can click **Run** now and add the Client ID later by editing
the integration.

## 2. Tell Tailscale to trust exe.dev

In Tailscale, open
[Trust credentials → Add](https://console.tailscale.com/admin/settings/trust-credentials/add),
select **OpenID Connect**, and fill in:

| Tailscale field | What to enter |
| --- | --- |
| Issuer | Choose **Custom issuer** and paste the exe.dev **Issuer** |
| Subject | Paste the exe.dev **Subject** exactly as shown. Don't use `*`. |
| Audience | Leave it; Tailscale generates one from the Client ID |
| Scopes | Turn on **Auth Keys** write. Only add more if the VM needs to call the Tailscale API. |
| Tags | Appears once you turn on a scope that can add devices. Pick the tags the VM should get, such as `tag:exe`. |

If you'd rather use the Tailscale API:

```
curl -fsS -X POST https://api.tailscale.com/api/v2/tailnet/-/keys \
  -H "Authorization: Bearer $TS_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d "{
    \"keyType\": \"federated\",
    \"description\": \"exe.dev\",
    \"issuer\": \"$EXE_WIF_ISSUER\",
    \"subject\": \"$EXE_WIF_SUBJECT\",
    \"scopes\": [\"auth_keys\"],
    \"tags\": [\"tag:exe\"]
  }"
```

Copy the **Client ID** Tailscale shows. It isn't a secret.

## 3. Enter the Client ID and save

Back in exe.dev, open the integration if you already saved it, and paste
the **Client ID**. The **Audience** fills in as `api.tailscale.com/<Client
ID>`, matching what Tailscale generated. Optionally enter the **Tags** you
picked in Tailscale so the VM can read them later. Click **Run** or
**Save**.

Or from the CLI. The audience is `api.tailscale.com/<Client ID>`, but the
command prints the Issuer and Subject only after it creates the integration,
so you don't have the Client ID yet. Pass a stand-in, then replace it once
Tailscale has given you one:

```
ssh exe.dev integrations add wif --name tailscale-wif \
  --audience api.tailscale.com/pending --consumer tailscale --attach vm:example-vm

ssh exe.dev integrations edit tailscale-wif \
  --audience api.tailscale.com/CLIENT_ID \
  --metadata=client_id=CLIENT_ID --metadata=tags=tag:exe
```

Add `--team` to `add` for a team integration. `edit` replaces every
`--metadata` value, so pass all of them each time.

## Use it from the VM

On an attached VM, the integration is reachable at
`https://<name>.int.exe.xyz` (`https://<name>.team.exe.xyz` for a team
integration). `/metadata` returns the Client ID and tags you entered.
`/token` returns a fresh token.

### Join your Tailscale network

Install Tailscale on the VM, then run this. The token is only needed once;
the VM stays joined across reboots.

```
EXE_WIF_URL=https://tailscale-wif.int.exe.xyz
META="$(curl -fsS "$EXE_WIF_URL/metadata")"
CLIENT_ID="$(echo "$META" | jq -er .client_id)"
TAGS="$(echo "$META" | jq -r '.tags // "tag:exe"')"
ID_TOKEN="$(curl -fsS "$EXE_WIF_URL/token" | jq -er .token)"

sudo tailscale up \
  --client-id="${CLIENT_ID}?ephemeral=true&preauthorized=true" \
  --id-token="$ID_TOKEN" \
  --advertise-tags="$TAGS"

unset ID_TOKEN
```

`ephemeral=true` tells Tailscale to remove the VM from your network once it
goes offline, so a deleted VM doesn't leave a stale entry behind. Use
`ephemeral=false` if the VM should stay listed while it's off. The tags you
advertise must be ones you picked in step 2.

To leave the network, run `sudo tailscale logout` on the VM.

### Call the Tailscale API

If you gave the credential API scopes in step 2, trade the exe.dev token for
a Tailscale API token:

```
EXE_WIF_URL=https://tailscale-wif.int.exe.xyz
CLIENT_ID="$(curl -fsS "$EXE_WIF_URL/metadata" | jq -er .client_id)"
JWT="$(curl -fsS "$EXE_WIF_URL/token" | jq -er .token)"

TS_API_TOKEN="$(
  curl -fsS -X POST https://api.tailscale.com/api/v2/oauth/token-exchange \
    -d "client_id=$CLIENT_ID" -d "jwt=$JWT" | jq -er .access_token
)"

curl -fsS https://api.tailscale.com/api/v2/tailnet/-/devices \
  -H "Authorization: Bearer $TS_API_TOKEN"

unset JWT TS_API_TOKEN
```

The Tailscale Terraform provider can do this for you: set
`TAILSCALE_OAUTH_CLIENT_ID` to the Client ID and `TAILSCALE_IDENTITY_TOKEN`
to `file:` followed by the path of a file holding a fresh exe.dev token.

### About the tokens

- Get a new token from `/token` each time you join or call the API. They
  last 5 minutes by default.
- Tailscale API tokens are short-lived too. Get a new one before the old one
  runs out.
- Treat both like passwords. Don't print, log, or commit them.

## Troubleshooting

- **`/token` returns 403.** The integration isn't attached to this VM.
- **Tailscale rejects the token.** Check that the Issuer and Subject in
  Tailscale match the integration exactly, that the integration's Client ID
  is the one Tailscale shows for this credential, and that
  `--advertise-tags` only lists tags you picked in Tailscale. Tailscale's
  [list of token exchange errors](https://tailscale.com/docs/features/workload-identity-federation#debugging-token-exchange-errors)
  explains each one.
- **`tailscale up` asks for `--audience`.** That option only works on cloud
  platforms Tailscale can fetch tokens from itself. On exe.dev, pass
  `--id-token` as shown above.

## Keep it narrow

- Make one exe.dev integration per Tailscale trust credential.
- Attach it only to the VMs or tag that need it.
- Use the exact Subject in Tailscale. A `*` would let any integration under
  the same Issuer in.
- Only turn on **Auth Keys** unless the VM must call the API, and never
  **OAuth Keys**, **Federated Keys**, or **All**.

Tailscale references:

- [Workload identity federation](https://tailscale.com/docs/features/workload-identity-federation)
- [Trust credentials](https://tailscale.com/docs/reference/trust-credentials)
