tsiam
GitHub

Getting a Token

Any machine on your Tailnet can request a JWT token from tsiam by making a POST request to the /token endpoint.

Request#

POST https://tsiam/token?resource=<audience>
X-Tsiam: 1

The X-Tsiam: 1 header is required and helps preventing cross-site request forgery by ensuring requests are made intentionally.

The /token endpoint is only reachable from within your Tailnet. It is never exposed via Tailscale Funnel.

Query parameters#

ParameterRequiredDescription
resourceYesThe audience for the token (alias: audience), which specifies the intended recipient of the token. Must be in the allowed audiences list .

Common audience values#

When exchanging tokens with cloud providers, use these audience values:

Target serviceAudience value
AWS IAMsts.amazonaws.com (for OIDC federation with AWS IAM)
Microsoft Entra ID (Azure AD)api://AzureADTokenExchange (for Workload Identity Federation)
Google Cloud IAMYour workload identity pool audience (e.g., //iam.googleapis.com/projects/PROJECT_ID/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID)
Custom APIUse your API’s identifier (e.g., https://api.example.com)

Response#

{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": "300",
  "expires_on": "1735706000",
  "not_before": "1735700400"
}

Present the access_token as a Bearer token to the target service.

JWT claims#

The JWT token contains your Tailscale node identity in the tsiam claim and the requested audience in the aud claim:

{
  // Subject: the stable Tailscale node identifier (see tokens.subjectClaim)
  "sub": "nABC123CNTRL",

  // Issuer: the tsiam service that issued this token
  "iss": "https://your-tsiam",

  // Audience: the intended recipient of this token
  "aud": [ "https://api.example.com" ],

  // Issued At: when the token was created (Unix timestamp)
  "iat": 1735700600,

  // Expiration: when the token expires (Unix timestamp)
  "exp": 1735704000,

  // Not Before: earliest time the token can be used (Unix timestamp)
  "nbf": 1735700600,

  // Custom claim with detailed Tailscale node information
  "tsiam": {
    "nodeId": "abc123",
    "name": "my-server.tailnet-name.ts.net",
    "hostname": "my-server",
    "ip4": "100.64.0.1",
    "ip6": "fd7a:115c:a1e0::1",
    "userLoginName": "user@example.com",
    "tags": []
  },

  // Unique token identifier: cryptographically random 24-byte value encoded as base64url
  // Used for audit and correlation purposes across systems, not for replay prevention
  "jti": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef"
}

By default the sub claim is the stable Tailscale node identifier. Set tokens.subjectClaim to "name" to use the (recyclable) node name instead (note the security warning in the config docs) or to "capability" to pull the subject from the subject field of the matching Tailscale ACL grant, letting multiple nodes share one workload identity.

Tagged nodes: for nodes that carry one or more Tailscale tags (tag:*), tsiam.userLoginName is always emitted as an empty string. The upstream value is the email of whoever authorized the tag, not the workload’s owner, so it must not be used as a user-identity signal in relying-party trust policies. Bind tagged-workload trust policies to tsiam.tags and tsiam.nodeId instead.

Code Examples#

cURL
# Get a token for a specific audience
TOKEN=$(curl -s -X POST "https://tsiam/token?resource=https://api.example.com" -H "X-Tsiam: 1" | jq -r '.access_token')

# Use the token
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/resource
Node.js
async function getToken(audience) {
  const url = new URL('https://tsiam/token');
  url.searchParams.set('resource', audience);

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'X-Tsiam': '1',
    },
  });

  if (!response.ok) {
    throw new Error(`Failed to get token: ${response.status}`);
  }

  const data = await response.json();
  return data.access_token;
}

// Usage
const token = await getToken('https://api.example.com');
const response = await fetch('https://api.example.com/resource', {
  headers: {
    'Authorization': `Bearer ${token}`,
  },
});
Go
package main

import (
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
)

type TokenResponse struct {
	AccessToken string `json:"access_token"`
	TokenType   string `json:"token_type"`
	ExpiresIn   string `json:"expires_in"`
}

func getToken(audience string) (string, error) {
	u, err := url.Parse("https://tsiam/token")
	if err != nil {
		return "", err
	}
	q := u.Query()
	q.Set("resource", audience)
	u.RawQuery = q.Encode()

	req, err := http.NewRequest("POST", u.String(), nil)
	if err != nil {
		return "", err
	}
	req.Header.Set("X-Tsiam", "1")

	client := http.DefaultClient()
	resp, err := client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	var tokenResp TokenResponse
    err = json.NewDecoder(resp.Body).Decode(&tokenResp)
	if err != nil {
		return "", err
	}

	return tokenResp.AccessToken, nil
}

func main() {
	token, err := getToken("https://api.example.com")
	if err != nil {
		panic(err)
	}

	// Use the token
	req, _ := http.NewRequest("GET", "https://api.example.com/resource", nil)
	req.Header.Set("Authorization", "Bearer "+token)

	client := http.DefaultClient()
	resp, err := client.Do(req)
	if err != nil {
		panic(err)
	}
	defer resp.Body.Close()

	fmt.Println("Response status:", resp.Status)
}
Python
import requests

def get_token(audience):
    response = requests.post(
        "https://tsiam/token",
        params={"resource": audience},
        headers={"X-Tsiam": "1"}
    )
    response.raise_for_status()
    return response.json()["access_token"]

# Usage
token = get_token("https://api.example.com")
response = requests.get(
    "https://api.example.com/resource",
    headers={"Authorization": f"Bearer {token}"}
)
C# (.NET)
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;

public class TokenResponse
{
    public string AccessToken { get; set; }
    public string TokenType { get; set; }
    public string ExpiresIn { get; set; }
}

public class TsiamClient
{
    private readonly HttpClient _client = new();

    public async Task<string> GetTokenAsync(string audience)
    {
        var uriBuilder = new UriBuilder("https://tsiam/token");
        uriBuilder.Query = $"resource={Uri.EscapeDataString(audience)}";

        var request = new HttpRequestMessage(HttpMethod.Post, uriBuilder.Uri);
        request.Headers.Add("X-Tsiam", "1");

        var response = await _client.SendAsync(request);
        response.EnsureSuccessStatusCode();

        var json = await response.Content.ReadAsStringAsync();
        var tokenResponse = JsonSerializer.Deserialize<TokenResponse>(json,
            new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });

        return tokenResponse.AccessToken;
    }

    public async Task<string> CallApiAsync(string url, string audience)
    {
        var token = await GetTokenAsync(audience);

        var request = new HttpRequestMessage(HttpMethod.Get, url);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);

        var response = await _client.SendAsync(request);
        return await response.Content.ReadAsStringAsync();
    }
}
Java
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import com.google.gson.Gson;

public class TsiamClient {
    private final HttpClient client = HttpClient.newHttpClient();
    private final Gson gson = new Gson();

    public record TokenResponse(
        String access_token,
        String token_type,
        String expires_in
    ) {}

    public String getToken(String audience) throws Exception {
        String encodedAudience = URLEncoder.encode(audience, StandardCharsets.UTF_8);
        String url = "https://tsiam/token?resource=" + encodedAudience;

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .header("X-Tsiam", "1")
            .POST(HttpRequest.BodyPublishers.noBody())
            .build();

        HttpResponse<String> response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        TokenResponse tokenResponse = gson.fromJson(response.body(), TokenResponse.class);
        return tokenResponse.access_token();
    }

    public String callApi(String url, String audience) throws Exception {
        String token = getToken(audience);

        HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .header("Authorization", "Bearer " + token)
            .GET()
            .build();

        HttpResponse<String> response = client.send(request,
            HttpResponse.BodyHandlers.ofString());

        return response.body();
    }
}
Edit this page on GitHub