> For the complete documentation index, see [llms.txt](https://docs.molecule.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.molecule.xyz/api-reference/labs-api/service-tokens.md).

# Service Tokens

A service token is the credential that proves *which wallet* a write request acts as. It is **self-issued** — you mint your own by proving control of the wallet, and nobody has to provision one for you.

> **Wallet-bound, not lab-bound.** A service token carries a wallet identity, not a list of labs. What it may do on a given lab is resolved per request from that wallet's live onchain role, so one token works across every lab the wallet has a role on, and a role granted after issuance takes effect without re-issuing. See [What a Service Token actually authorizes](/api-reference/authentication.md#what-a-service-token-actually-authorizes).

## Obtaining a Token

Two calls, no human in the loop — the path for autonomous agents, bots and CI/CD pipelines that have no browser-based Privy session. Fetch a fresh sign-in message, sign it with the service wallet, then exchange the signature for a token. For the runnable version, see [Step 1 of Create a lab and upload a public file](/api-reference/getting-started/create-lab-and-upload-file.md#step-1-get-a-service-token).

Issuance is **not** gated on holding a role on any lab: any wallet can mint a token for itself. The role is what makes the token useful.

**Step 1 — Get the sign-in message (`getServiceSignInMessage`):**

```graphql
query GetServiceSignInMessage($walletAddress: String!, $serviceName: String!) {
  getServiceSignInMessage(
    walletAddress: $walletAddress
    serviceName: $serviceName
  ) {
    message
    expiresAt
  }
}
```

Returns the deterministic message that a service must sign to authenticate via wallet signature. Public query, no auth required. Use cases: autonomous agents, bots, CI/CD pipelines, or any service that needs a token without a browser-based Privy session.

Returns [`ServiceSignInMessageResult!`](/api-reference/types.md#servicesigninmessageresult).

| Name            | Type      | Description                                          |
| --------------- | --------- | ---------------------------------------------------- |
| `walletAddress` | `String!` | Wallet address of the service (e.g. an agent's EOA). |
| `serviceName`   | `String!` | Name of the service requesting a token.              |

Public query — no authentication required.

{% hint style="warning" %}
**Single-use, and valid for 10 minutes.** The sign-in message is not deterministic — do not cache it, do not cache a signature over it, and never reconstruct the string client-side. Concretely:

* The nonce is **consumed** by the first successful `generateServiceToken`. Issuing a second token means fetching a new message and signing again.
* There is **one outstanding nonce per `(walletAddress, serviceName)`**, last-write-wins: calling this query again invalidates the message you have not yet redeemed.
* The window is **10 minutes** from issuance (`expiresAt`). Sign and redeem promptly rather than fetching a message ahead of time.
* Signatures over the older, nonce-free message format no longer verify.
  {% endhint %}

**Step 2 — Exchange the signature for a token (`generateServiceToken`):**

Sign the returned `message` **verbatim** with the service wallet, as a plain personal message (EIP-191 `personal_sign` — **not** typed data). The backend recomposes the same string from the stored nonce record and verifies it server-side, so re-wording or re-formatting it fails with `UNAUTHENTICATED` / `reason: INVALID_SIGNATURE`. Then submit the signature:

```graphql
mutation GenerateServiceToken(
  $serviceName: String!
  $walletAddress: String!
  $messageSignature: String!
  $expiresIn: String
) {
  generateServiceToken(
    serviceName: $serviceName
    walletAddress: $walletAddress
    messageSignature: $messageSignature
    expiresIn: $expiresIn
  ) {
    token
    tokenId
    serviceName
    expiresAt
    createdAt
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

Issues a service token (JWT) for non-browser automation such as agents, bots and pipelines. Supply both `walletAddress` and `messageSignature` to authenticate by wallet signature over the message from `getServiceSignInMessage`, whose nonce is single-use and valid for 10 minutes, or supply neither to authenticate with the caller's session. Fails with `UNAUTHENTICATED` when the nonce is missing, expired or the signature does not match, and `VALIDATION_FAILED` when only one of the two is supplied.

Returns [`ServiceTokenResult!`](/api-reference/types.md#servicetokenresult).

| Name               | Type      | Description                                                                                                                                                     |
| ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `serviceName`      | `String!` | Name of the service the token is for; recorded with the token and echoed in `ServiceTokenResult.serviceName`.                                                   |
| `expiresIn`        | `String`  | Lifetime of the token as `<number><unit>` with unit s, m, h, d, w, M (30 days) or y (365 days), e.g. "30d", "6M", "1y". Default "180d"; minimum 1h, maximum 2y. |
| `walletAddress`    | `String`  | Wallet address for signature-based auth (both walletAddress and messageSignature required together). Use cases: agents, bots, services.                         |
| `messageSignature` | `String`  | Hex-encoded signature of the service sign-in message.                                                                                                           |

\* `walletAddress` and `messageSignature` must be provided together for signature-based issuance. The returned `token` is the JWT to pass as `X-Service-Token` on subsequent requests.

**`expiresIn`:**

|                      |                                                                                                     |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| Default when omitted | `180d`                                                                                              |
| Format               | `<integer><unit>`, unit one of `s` `m` `h` `d` `w` `M` `y` — e.g. `"30d"`, `"720h"`, `"6M"`, `"1y"` |
| Minimum              | 1 hour                                                                                              |
| Maximum              | 2 years                                                                                             |

`M` is a 30-day month and `y` is a 365-day year. **Validate this client-side.** `expiresIn` is not checked before use: anything outside the bounds, or in another format, fails deep in token generation and surfaces as `INTERNAL_ERROR` with `details.reason: TOKEN_GENERATION_FAILED` and a masked message — not as `VALIDATION_FAILED`. `INTERNAL_ERROR` carries `retryable: true`, but this particular one is permanent: retrying the same `expiresIn` will never succeed. Prefer a short lifetime matched to the caller's purpose — for an agent, match the expiry of its role grant — over the 180-day default.

Success ⇔ `error == null`. On failure `error` carries the catalogue `code` (e.g. `UNAUTHENTICATED` when the signature does not verify), `message` mirrors `error.message`, and `token`, `tokenId`, `expiresAt` and `createdAt` are `null` (`serviceName` may echo the name you sent) — guard for `null`, not for empty strings, and branch on `error`, never on the token fields.

**Failure modes on the signature path** — all `UNAUTHENTICATED`, distinguished by `details.reason`. Read it through the tolerant [`parseDetails`](/api-reference/errors.md#reading-details), not a bare `JSON.parse` — the in-band string is currently doubly encoded:

| `reason`            | What happened                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Fix                                                                                                                                |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `NONCE_NOT_FOUND`   | No nonce record at all — you never called `getServiceSignInMessage` for this wallet + service, or the nonce was already consumed by an earlier token                                                                                                                                                                                                                                                                                                                                              | Fetch a fresh message and sign it again. Do not retry the same signature                                                           |
| `NONCE_EXPIRED`     | The message is older than its 10-minute window                                                                                                                                                                                                                                                                                                                                                                                                                                                    | Fetch a fresh message and sign it again                                                                                            |
| `INVALID_SIGNATURE` | The signed bytes are not the string the backend recomposes. Either the message was altered (re-formatted, rebuilt client-side, typed-data signing, the retired nonce-free format), **it was superseded** — a later `getServiceSignInMessage` call replaced the stored nonce, so an earlier message no longer matches — **or the `walletAddress` you sent is not the address that produced the signature**: verification runs against the address you claim, so a wrong one simply fails to verify | Sign the `message` from the most recent call, byte-for-byte, with `personal_sign`, and send the signing address as `walletAddress` |

None of these are retryable as-is: every one of them means "get a new message and sign that". Note there is no distinct wallet-mismatch reason on this path — the nonce is looked up under the `walletAddress` you send and the signature is verified against it, so a wrong address returns `NONCE_NOT_FOUND` (no message was ever issued for that address) or `INVALID_SIGNATURE` (one was, but a different key signed). A `VALIDATION_FAILED` here refers to `walletAddress` format, or to sending only one of `walletAddress` / `messageSignature` — both must be present together.

## Extending Token Expiration

You can extend your service token's expiration using the `extendServiceToken` mutation. **Scoped to your own tokens:** the token you present must own the `tokenId` you name. A `tokenId` belonging to another wallet returns the same `NOT_FOUND` as one that does not exist, so token existence cannot be probed.

```graphql
mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) {
  extendServiceToken(tokenId: $tokenId, expiresIn: $expiresIn) {
    token
    tokenId
    expiresAt
    message
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

Extends the lifetime of one of the caller's own service tokens, counted from now. Authenticated by the `x-service-token` header (fails with `UNAUTHENTICATED` when missing or invalid). A token the caller does not own is reported as not found; a revoked token fails with `FAILED_PRECONDITION`. Success when `error == null`.

Returns [`ServiceTokenResult!`](/api-reference/types.md#servicetokenresult).

| Name        | Type      | Description                                                                                          |
| ----------- | --------- | ---------------------------------------------------------------------------------------------------- |
| `tokenId`   | `String!` | Value of `ServiceTokenResult.tokenId`, not the token itself.                                         |
| `expiresIn` | `String!` | New lifetime from now, in the `expiresIn` format of `generateServiceToken` (minimum 1h, maximum 2y). |

**Example:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_CURRENT_TOKEN' \
  -d '{
    "query": "mutation ExtendServiceToken($tokenId: String!, $expiresIn: String!) { extendServiceToken(tokenId: $tokenId, expiresIn: $expiresIn) { token tokenId expiresAt message error { code message requestId retryable details } } }",
    "variables": {
      "tokenId": "your-token-id",
      "expiresIn": "90d"
    }
  }'
```

**Important:** Extension returns a **new JWT token** - update your stored token accordingly. On failure `error` is set and `token` and `expiresAt` are `null`; `tokenId` may echo the id you sent, so branch on `error`, not on the token fields.

## Revoking Tokens

Revoke a service token immediately (e.g., if compromised). Like `extendServiceToken`, this is **scoped to your own tokens** — the presented token must own the `tokenId`, and a foreign one returns `NOT_FOUND`.

```graphql
mutation RevokeServiceToken($tokenId: String!) {
  revokeServiceToken(tokenId: $tokenId) {
    tokenId
    message
    revokedAt
    error {
      code
      message
      requestId
      retryable
      details
    }
  }
}
```

Success ⇔ `error == null`. On failure `message` mirrors `error.message`; do not infer the outcome from `tokenId` or `revokedAt` — `tokenId` may echo the id you sent, and when the token was already revoked (`CONFLICT`, reason `ALREADY_REVOKED`) `revokedAt` carries the original revocation time.

**Example:**

```bash
curl -X POST https://production.graphql.api.molecule.xyz/graphql \
  -H 'Content-Type: application/json' \
  -H 'Authorization: YOUR_CONSUMER_CREDENTIAL' \
  -H 'X-Service-Token: YOUR_CURRENT_TOKEN' \
  -d '{
    "query": "mutation RevokeServiceToken($tokenId: String!) { revokeServiceToken(tokenId: $tokenId) { tokenId message revokedAt error { code message requestId retryable details } } }",
    "variables": {
      "tokenId": "your-token-id"
    }
  }'
```

***

## Related

* [Authentication](/api-reference/authentication.md) — what a token authorizes and which headers it goes in
* [Agent access](/api-reference/getting-started/agent-as-a-lab-contributor.md) — an agent issuing its own token
* [x402 Gateway](/api-reference/x402-gateway.md) — pay per call without a token

{% hint style="info" %}
**Need help?**

Ask the team on Discord. Reporting an API error? Include the `requestId` from the failing response so we can trace it.

<a href="https://t.co/L0VEiy4Bjk" class="button primary" data-icon="discord">Ask on Discord</a>

*Page wrong, unclear or missing something? Let us know with **Was this helpful?** on this page.*
{% endhint %}
