# Authentication

Every endpoint except [health](/reference/api#health-check) needs a credential. There are two, for two situations:

| | Use it for | Available on |
| --- | --- | --- |
| **API key** | Scripts, CI, servers — anything running with nobody watching | Paid plans |
| **Session token** | The [MCP server](/deploy-with-ai/mcp-server), the console, anything with a person and a browser present | Every plan, including free |

If a browser can open, you do not need a key. If one cannot, a key is the only way in.

## Using a key

```
Authorization: Bearer lc_...
```

No refreshing and no expiry to handle unless you set one. A key belongs to one organisation, so `targetOrganisationId` is optional on every call — it is inferred, and naming a different organisation is refused with `CA003`.

```codetabs
### curl
curl -X POST https://api.light-cloud.com/api/applications \
  -H "Authorization: Bearer $LIGHT_CLOUD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"limit": 100}'

### Node
const res = await fetch("https://api.light-cloud.com/api/applications", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LIGHT_CLOUD_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ limit: 100 }),
});

if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);
console.log(await res.json());

### Python
import os
import requests

res = requests.post(
    "https://api.light-cloud.com/api/applications",
    headers={"Authorization": f"Bearer {os.environ['LIGHT_CLOUD_API_KEY']}"},
    json={"limit": 100},
)
res.raise_for_status()
print(res.json())
```

## What a key cannot do

By design, not by configuration:

- **Billing and organisation settings.** No key, at any access level, can change a payment method, invite a member, or delete a workspace. Those need a signed-in person.
- **Manage keys.** The endpoints below are session-only. A leaked key cannot mint a replacement or widen its own access — not even its own listing.
- **Reach another workspace.** A key is bound to the organisation it was created in.

> [!WARNING]
> A key is a live credential for the whole workspace. Put it in your CI secret store, never in a repository, and never in a client-side bundle where a browser can read it.

## Plans

Keys require a paid plan; creating one on a free plan returns `402`. The entitlement is re-checked on **every** request, so a downgrade stops existing keys working rather than letting them outlive it.

This is a limit on unattended credentials, not on automation — the MCP server works on every account and can do everything a key can. It just needs a browser for the initial sign-in.

---

## Create an API key

```endpoint
POST /api/api-keys/create
> Issues a key and returns its secret. This is the only time the secret is ever readable — only a bcrypt hash is stored. Session tokens only.
permission: update:organisations

param targetOrganisationId | string | required | The organisation the key will belong to
param name | string | required | What will use it, e.g. "GitHub Actions"
param role | string | user | `user` for read-only, `admin` to deploy and manage. `owner` is not assignable
param expiresAt | string | optional | RFC 3339 timestamp in the future. Omit for a key that never expires

response 201 | The key, including its secret
{
  "id": "key_3Wq8",
  "name": "GitHub Actions",
  "prefix": "lc_RdiJfPH9X",
  "role": "admin",
  "expires_at": "2027-08-28T00:00:00.000Z",
  "created_at": "2026-08-28T11:16:35.617Z",
  "secret": "lc_RdiJfPH9XmS2cA7vK1pQ0tZbN4eL8yG6uW3rD5oX9jH"
}

code curl
curl -X POST https://api.light-cloud.com/api/api-keys/create \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "targetOrganisationId": "org_1Ab",
    "name": "GitHub Actions",
    "role": "admin",
    "expiresAt": "2027-08-28T00:00:00.000Z"
  }'

code Node
const res = await fetch("https://api.light-cloud.com/api/api-keys/create", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${sessionToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    targetOrganisationId: "org_1Ab",
    name: "GitHub Actions",
    role: "admin",
  }),
});

if (!res.ok) throw new Error(`${res.status}: ${await res.text()}`);

const key = await res.json();
// Store key.secret now — it cannot be read back
console.log(key.secret);

code Python
import os
import requests

res = requests.post(
    "https://api.light-cloud.com/api/api-keys/create",
    headers={"Authorization": f"Bearer {session_token}"},
    json={
        "targetOrganisationId": "org_1Ab",
        "name": "GitHub Actions",
        "role": "admin",
    },
)
res.raise_for_status()

key = res.json()
# Store key["secret"] now — it cannot be read back
print(key["secret"])
```

> [!IMPORTANT]
> `secret` appears in this response and nowhere else. If it is lost, revoke the key and create another.

## List API keys

```endpoint
POST /api/api-keys
> Every key in the organisation, newest first. Never returns the secret or its hash — `prefix` is the display form, enough to tell keys apart and useless on its own. Session tokens only.
permission: read:organisations

param targetOrganisationId | string | required | The organisation to read

response 200 | Keys, without secrets
[
  {
    "id": "key_3Wq8",
    "name": "GitHub Actions",
    "prefix": "lc_RdiJfPH9X",
    "role": "admin",
    "created_by": "usr_5Kd",
    "expires_at": null,
    "revoked_at": null,
    "last_used_at": "2026-08-28T09:41:02.000Z",
    "created_at": "2026-08-20T10:00:00.000Z"
  }
]

code curl
curl -X POST https://api.light-cloud.com/api/api-keys \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"targetOrganisationId": "org_1Ab"}'

code Node
const res = await fetch("https://api.light-cloud.com/api/api-keys", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${sessionToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ targetOrganisationId: "org_1Ab" }),
});

const keys = await res.json();

// Anything unused for 90 days is worth a look
const stale = keys.filter(
  (key) =>
    !key.revoked_at &&
    (!key.last_used_at ||
      Date.now() - Date.parse(key.last_used_at) > 90 * 864e5)
);

code Python
import os
import requests
from datetime import datetime, timedelta, timezone

res = requests.post(
    "https://api.light-cloud.com/api/api-keys",
    headers={"Authorization": f"Bearer {session_token}"},
    json={"targetOrganisationId": "org_1Ab"},
)
res.raise_for_status()

cutoff = datetime.now(timezone.utc) - timedelta(days=90)
stale = [
    key for key in res.json()
    if not key["revoked_at"]
    and (
        key["last_used_at"] is None
        or datetime.fromisoformat(key["last_used_at"].replace("Z", "+00:00")) < cutoff
    )
]
```

## Revoke an API key

```endpoint
POST /api/api-keys/revoke
> Stops a key working immediately — the next request with it is rejected. The row is kept rather than deleted, so the record of what existed survives. Session tokens only.
permission: update:organisations

param targetOrganisationId | string | required | The organisation the key belongs to
param keyId | string | required | The key to revoke

response 200 | Revoked
{ "revoked": true }

code curl
curl -X POST https://api.light-cloud.com/api/api-keys/revoke \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"targetOrganisationId": "org_1Ab", "keyId": "key_3Wq8"}'

code Node
const res = await fetch("https://api.light-cloud.com/api/api-keys/revoke", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${sessionToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    targetOrganisationId: "org_1Ab",
    keyId: "key_3Wq8",
  }),
});

code Python
import requests

requests.post(
    "https://api.light-cloud.com/api/api-keys/revoke",
    headers={"Authorization": f"Bearer {session_token}"},
    json={"targetOrganisationId": "org_1Ab", "keyId": "key_3Wq8"},
).raise_for_status()
```

Revoke a key when the person who created it leaves, when the job it served is retired, or the moment you suspect it has leaked. A key without an expiry does not stop working on its own.

> [!TIP]
> Set an expiry on anything temporary. A key that outlives its purpose is the one that turns up in an old pipeline two years later.

You can also do all of this in the console: **left sidebar → your workspace → API keys**.

---

## Session tokens

The console and the MCP server use short-lived tokens rather than keys: an access token valid for 15 minutes, plus a refresh token valid for 30 days.

Obtaining one happens in the browser, through the console. The MCP server does this for you; a person confirms the connection, and the tokens are written to `~/.lightcloud/credentials.json`. Implementing that flow yourself is not a supported integration path — use an API key.

> [!NOTE]
> `POST /api/auth/login` exists for the console. It expects the password encrypted with a platform secret that is not distributed, so it is not an authentication path for API clients.

## Refresh a session token

```endpoint
POST /api/auth/refresh
> Exchanges a refresh token for a new pair. The presented token is revoked in the same call, so the value changes every time — which is why a refresh token cannot live in a CI secret, and why API keys exist.

param refreshToken | string | required | The current refresh token. Browsers may omit it and send the httpOnly cookie instead

response 200 | A new pair
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "9c1f4e2a7b..."
}

code curl
curl -X POST https://api.light-cloud.com/api/auth/refresh \
  -H "Content-Type: application/json" \
  -H "X-Client-Type: cli" \
  -d "{\"refreshToken\": \"$REFRESH_TOKEN\"}"

code Node
const res = await fetch("https://api.light-cloud.com/api/auth/refresh", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    // Without this the new refresh token comes back only as a cookie
    "X-Client-Type": "cli",
  },
  body: JSON.stringify({ refreshToken }),
});

if (!res.ok) throw new Error("Session expired, sign in again");

// Both values are new — store them, the old refresh token is now dead
const { token, refreshToken: rotated } = await res.json();

code Python
import requests

res = requests.post(
    "https://api.light-cloud.com/api/auth/refresh",
    headers={"X-Client-Type": "cli"},
    json={"refreshToken": refresh_token},
)

if not res.ok:
    raise RuntimeError("Session expired, sign in again")

payload = res.json()
# Both values are new — the old refresh token is now dead
access_token, refresh_token = payload["token"], payload["refreshToken"]
```

> [!IMPORTANT]
> `X-Client-Type: cli` is not optional outside a browser. Without it the response carries only a new access token, and your refresh token stays the old, now-revoked one.

## Retrieve the current user

```endpoint
GET /api/auth/profile
> The signed-in user and every organisation they belong to, with their role in each. This is where `targetOrganisationId` comes from. Session tokens only — a key has no user behind it.

response 200 | The user and their organisations
{
  "id": "usr_5Kd",
  "email": "you@example.com",
  "first_name": "Ada",
  "organisations": [
    {
      "id": "org_1Ab",
      "name": "Acme Labs",
      "slug": "acme-labs",
      "role": "owner"
    }
  ]
}

code curl
curl https://api.light-cloud.com/api/auth/profile \
  -H "Authorization: Bearer $SESSION_TOKEN"

code Node
const res = await fetch("https://api.light-cloud.com/api/auth/profile", {
  headers: { Authorization: `Bearer ${sessionToken}` },
});

const { organisations } = await res.json();
const organisationId = organisations[0].id;

code Python
import requests

res = requests.get(
    "https://api.light-cloud.com/api/auth/profile",
    headers={"Authorization": f"Bearer {session_token}"},
)
res.raise_for_status()

organisation_id = res.json()["organisations"][0]["id"]
```

## Sign out

```endpoint
POST /api/auth/logout
> Revokes the refresh token and clears the cookie. Access tokens already issued stay valid until they expire, up to 15 minutes later.

response 200 | Signed out
{ "message": "Successfully logged out" }

code curl
curl -X POST https://api.light-cloud.com/api/auth/logout \
  -H "Authorization: Bearer $SESSION_TOKEN"
```

## Two-factor accounts

When an account has two-factor authentication enabled, signing in returns a short-lived pending token instead of a session, and a TOTP or recovery code exchanges it for the real one at `POST /api/auth/login/mfa`. The console and MCP server handle this.

API keys are unaffected — they are workspace credentials, not account credentials, and carry no second factor of their own. That is another reason they cannot touch billing or membership.

## Related

- [API overview](/reference/api): Base URL, conventions, and rate limits.
- [Errors](/reference/api/errors): What each status code means.
- [MCP server](/deploy-with-ai/mcp-server): Deploy from an AI agent, on any plan.
