# Give servers and CI jobs their own access

> Canonical HTML: https://screenrig.ai/docs/service-clients/

A service client is a server, script or CI job that works on one project with
its own credentials. It signs in with a private key or a secret, receives a
15-minute access token, and never needs a person to approve each run.

## What a service client is

- **Owned by the project.** The project keeps the client when the person or
  agent who created it leaves. Its requests are billed to the project as agent
  requests, against the same [included requests](https://screenrig.ai/pricing.md).
- **Read only or Manage.** Set at creation, like a signed-in agent. Read only
  lists and reads; Manage also changes, publishes and spends. See
  [Read only and Manage](https://screenrig.ai/docs/authentication.md#read-only-and-manage).
- **Capabilities.** Any of `screens`, `content`, `playlists`, `advertising`,
  `reports` and `project`, within the creator's own.
- **Bounded.** A service client never creates or manages service clients,
  members, invitations or agent sign-ins.
- **Two ways to sign in.** A registered public key (`private_key_jwt`, the
  recommended way) or a secret (`client_secret_basic`). A client holds up to
  two of each, so you can rotate without downtime.

A project holds up to 50 live service clients. Client ids start with `scl_`
and secrets with `sr_cs_`.

## Create a key

Generate an Ed25519 key pair on the machine that runs the client. The private
key never leaves it; screenRIG keeps only the public half.

```sh
openssl genpkey -algorithm ed25519 -out screenrig-client.pem
chmod 600 screenrig-client.pem
```

EC P-256 keys and RSA keys of at least 2048 bits work too.

## Create the client

**In the dashboard.** Open [Access](https://screenrig.ai/dashboard/access) and
choose **New service client**. Name it, choose Read only or Manage, check its
capabilities, and paste the public key as a JWK, or choose **Generate a client
secret**. Print the public JWK of your key with Node:

```sh
node -e 'const c=require("node:crypto");const k=c.createPublicKey(require("node:fs").readFileSync("screenrig-client.pem"));console.log(JSON.stringify(k.export({format:"jwk"})))'
```

**With the CLI.** An agent with the `project` capability and Manage access
creates one directly. `--key-file` takes a JWK or PEM file and sends only its
public half:

```sh
screenrig service-client create --name "Nightly report" --access read \
  --capability reports --key-file screenrig-client.pem
```

To use a secret instead, add `--secret-file PATH`. The CLI writes the secret to
that new file with mode 0600; it is shown once and never again.

`screenrig service-client show scl_ID` lists the client's key ids (`kid`) and
secret ids. A key without its own `kid` is known by its RFC 7638 thumbprint.

## Get an access token

Ask the token endpoint for a token with the `client_credentials` grant. The
response carries `access_token`, `token_type: Bearer`, `expires_in` (900
seconds) and `scope`. There is no refresh token: ask again when it expires.

With a secret, authenticate with HTTP Basic. A secret in the request body is
refused:

```sh
curl -s https://api.screenrig.ai/oauth/token \
  -u "$SCREENRIG_CLIENT_ID:$SCREENRIG_CLIENT_SECRET" \
  -d grant_type=client_credentials
```

With a key, send a signed client assertion instead of a secret:

```sh
curl -s https://api.screenrig.ai/oauth/token \
  -d grant_type=client_credentials \
  -d client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
  -d client_assertion="$ASSERTION"
```

The assertion is a JWT signed with your private key:

| Field | Value |
| --- | --- |
| Header `alg` | `EdDSA`, `ES256` or `RS256`, matching the key |
| Header `kid` | The registered key's id |
| `iss` and `sub` | The client id |
| `aud` | `https://api.screenrig.ai` or `https://api.screenrig.ai/oauth/token`, as one string |
| `iat`, `exp` | At most 5 minutes apart |
| `jti` | A new random value for every assertion; a repeat is refused |

Headers `jku`, `x5u`, `jwk` and `x5c` are refused.

### Ask for less

Two optional parameters narrow a token:

- `scope`: `access:read` makes a Manage client's token Read only, and
  capability names keep only those capabilities, for example
  `scope=access:read reports`. A scope beyond the client's is `invalid_scope`.
- `resource`: `https://api.screenrig.ai/api`, the default, for the REST API, or
  `https://api.screenrig.ai/mcp` for the
  [remote server](https://screenrig.ai/docs/mcp.md#api-connectors).

## Call the API

Send the token as a bearer. It acts on the client's own project:

```sh
curl -s https://api.screenrig.ai/api/screens \
  -H "Authorization: Bearer $TOKEN"
```

### Node: a cached token with a key

This helper signs the assertion with Node's built-in crypto, keeps the token in
memory, and asks for a new one a minute before it expires. Set
`CLIENT_KEY_ID` to the key's `kid` from `service-client show`:

```js
import { createPrivateKey, randomUUID, sign } from "node:crypto";
import { readFileSync } from "node:fs";

const ISSUER = "https://api.screenrig.ai";
const { SCREENRIG_CLIENT_ID: clientId, SCREENRIG_CLIENT_KEY_FILE: keyFile, CLIENT_KEY_ID: kid } = process.env;
const key = createPrivateKey(readFileSync(keyFile));
const part = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
let cached;

function assertion() {
  const now = Math.floor(Date.now() / 1000);
  const input = `${part({ alg: "EdDSA", typ: "JWT", kid })}.${part({ iss: clientId, sub: clientId, aud: ISSUER, iat: now, exp: now + 60, jti: randomUUID() })}`;
  return `${input}.${sign(null, Buffer.from(input), key).toString("base64url")}`;
}

export async function accessToken() {
  if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;
  const response = await fetch(`${ISSUER}/oauth/token`, {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
      client_assertion: assertion(),
    }),
  });
  const body = await response.json();
  if (!response.ok) throw new Error(`screenRIG token request refused: ${body.error}: ${body.error_description ?? ""}`);
  cached = { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
  return cached.token;
}
```

### Python: a cached token with a secret

```python
import base64, json, os, time, urllib.parse, urllib.request

ISSUER = "https://api.screenrig.ai"
_cached = {"token": None, "expires_at": 0.0}


def access_token() -> str:
    if _cached["token"] and _cached["expires_at"] - 60 > time.time():
        return _cached["token"]
    pair = f'{os.environ["SCREENRIG_CLIENT_ID"]}:{os.environ["SCREENRIG_CLIENT_SECRET"]}'
    request = urllib.request.Request(
        f"{ISSUER}/oauth/token",
        data=urllib.parse.urlencode({"grant_type": "client_credentials"}).encode(),
        headers={"Authorization": "Basic " + base64.b64encode(pair.encode()).decode()},
    )
    with urllib.request.urlopen(request, timeout=10) as response:
        body = json.load(response)
    _cached.update(token=body["access_token"], expires_at=time.time() + body["expires_in"])
    return body["access_token"]
```

## Run the CLI as a service client

Set `SCREENRIG_CLIENT_ID` with `SCREENRIG_CLIENT_KEY_FILE` (the private key,
recommended) or `SCREENRIG_CLIENT_SECRET`, and every `screenrig` command runs
as that client with no sign-in step. The CLI keeps the token in memory and
never reads or writes the stored config:

```sh
export SCREENRIG_CLIENT_ID=scl_ID
export SCREENRIG_CLIENT_KEY_FILE=/run/secrets/screenrig-client.pem
screenrig screen list
```

Set exactly one of the two. Each command asks for its own token, so a job that
runs more than a few hundred commands an hour is better served by one token
reused through the API.

## Rotate and revoke

| To | Dashboard (Access, the client) | CLI |
| --- | --- | --- |
| Add a second key | **Add key** | `screenrig service-client add-key scl_ID --key-file new.pem` |
| Remove a key | **Remove key** | `screenrig service-client remove-key scl_ID --kid KID` |
| Add a second secret | **Generate secret** | `screenrig service-client add-secret scl_ID --secret-file new-secret` |
| Remove a secret | **Remove secret** | `screenrig service-client remove-secret scl_ID --secret-id css_ID` |
| Change access or capabilities | **Access**, then **Save access** | Use the dashboard |
| End the client | **Revoke** | `screenrig service-client revoke scl_ID --yes` |

To rotate without downtime, add the new key or secret, deploy it, then remove
the old one. Tokens issued with a removed key or secret stop working within
seconds, and a client keeps at least one key or secret.

Narrowing a client's access or capabilities ends its current tokens; its next
token carries the new access. Revoking is permanent: the keys and secrets are
deleted, the client's tokens stop working within seconds, and the record stays
on the Access page for audit. Deleting the project revokes its clients.

A client gets tokens only while its project is active. A cancelled or deleted
project issues none. A project at zero credit still issues tokens, and its
paid requests answer `402 payment_required`, as an agent's do.

## Errors and limits

| Response | Cause | Fix |
| --- | --- | --- |
| `401 invalid_client` | Wrong or removed secret, a bad or repeated assertion, an unknown `kid`, a revoked client, or a cancelled or deleted project | Check the credentials against `service-client show`; send a fresh `jti` each time |
| `400 invalid_scope` | A scope beyond the client's access or capabilities | Ask for less, or change the client |
| `400 invalid_target` | A `resource` other than the two listed | Use the default or the remote server's |
| `429 rate_limited` | More than 600 tokens per client, or 1,200 token requests per address, in an hour | Reuse each token until it expires |
| `503 temporarily_unavailable` | Try again shortly | Retry with backoff |
| `401` from `/api` | The token expired or was revoked | Ask for a new token once, then stop |
| `403 insufficient_access` from `/api` | A Read only token tried a change | Use a Manage client |
| `402 payment_required` from `/api` | The project has no credit for a paid request | Add credit to the project |

## Related

- [Authentication](https://screenrig.ai/docs/authentication.md): sessions, Read only and Manage, and the Access page.
- [API reference](https://screenrig.ai/docs/api.md): every REST route a token can call.
- [CLI reference](https://screenrig.ai/docs/cli.md#service-clients): the `service-client` commands.
