# Canary tokens

Issuing, the secret you see once, rotating, revoking, and what each field means.

A canary token is a real credential that grants next to nothing, minted so that its first use is a signal. Two kinds exist today. A GitLab project access token is a Guest on a project Feint controls, with the `create_runner` scope, which GitLab refuses on every API call while still recording that the call was made. An AWS IAM access key belongs to a user with no permissions in an AWS account Feint owns: every call but `sts:GetCallerIdentity` is denied, and every call is written to CloudTrail.

## Issuing

`POST /v1/tokens` with a JSON body. `placement` is the only required field.

| Field | Type | Meaning |
|---|---|---|
| `provider` | string | Platform to mint on: `gitlab` (the default) or `aws_iam`. Where AWS decoys are not yet enabled, `aws_iam` is refused with `400`. |
| `placement` | string | Where the secret will live: `prod .env on web-01`, `CI variable GITLAB_DEPLOY_TOKEN in group/infra`. One line, at most 200 characters. The alert names it, so write it for whoever will be reading the alert. |
| `metadata` | object | Your own notes, kept with the canary and shown on alerts. At most 20 entries; keys up to 64 characters, string values up to 500. Small non-string values are kept as JSON. |
| `ttl_days` | integer | Lifetime, 1–3650 days. Default 365. GitLab enforces the expiry itself; Feint revokes an expired AWS key within minutes. Either way a `canary.expired` alert says the decoy has gone dark. |

Whitespace in `placement` and in metadata is collapsed to single spaces. A placement of nothing but whitespace is refused with `400`.

```bash title="curl"
curl -s -X POST https://api.feintsecurity.com/v1/tokens \
  -H "Authorization: Bearer $FEINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "placement": "CI variable GITLAB_DEPLOY_TOKEN in group/infra",
    "metadata": {"env": "prod", "owner": "platform"},
    "ttl_days": 90
  }'
```

```python title="Python"
r = requests.post(
    f"{API}/tokens",
    headers=HEADERS,
    json={
        "placement": "CI variable GITLAB_DEPLOY_TOKEN in group/infra",
        "metadata": {"env": "prod", "owner": "platform"},
        "ttl_days": 90,
    },
)
```

## The secret

The `201` response is the only place `secret` appears. Feint does not store it. It keeps `secret_prefix`, the first 12 characters, and `secret_fingerprint`, a SHA-256 of the whole secret. Both are on every read, so a credential found in the wild can be matched to its canary without the secret ever being held again:

```bash
printf '%s' "$FOUND_TOKEN" | shasum -a 256
```

An AWS key is a pair. `external_id` is the access key ID (`AKIA…`) and `secret` the secret access key; place both, as a `[default]` profile in `~/.aws/credentials`, as `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`, or as the JSON `aws iam create-access-key` prints. The access key ID is not secret (it is in every CloudTrail record, and it is what GuardDuty shows), so `secret_prefix` carries the whole of it and `secret_fingerprint` is the SHA-256 of the secret access key alone. Abridged:

```json
{
  "id": "5c1f8e2d-9a7b-4c3e-b0d1-6f7a8b9c0d1e",
  "provider": "aws_iam",
  "name": "deploy-ci-3f9a1c",
  "placement": "~/.aws/credentials on bastion-1",
  "status": "active",
  "secret_prefix": "AKIAEXAMPLEKEY000001",
  "external_id": "AKIAEXAMPLEKEY000001",
  "scopes": [],
  "access_level": null,
  "secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "secret_notice": "This is the only time the secret is returned. Feint does not store it."
}
```

`name` is the credential's name at the provider: `ci-` and ten characters for a GitLab token, an IAM user name like `deploy-ci-3f9a1c` for an AWS key, on purpose. Whoever holds the credential can read its name, and a name that mentioned Feint or the placement would give the game away.

## Status

| `status` | Meaning |
|---|---|
| `active` | Armed: the credential exists at the provider and Feint is watching it. |
| `expired` | Past `expires_at`. Derived, so it is reported the moment it becomes true; a `canary.expired` alert follows within minutes. |
| `rotated` | Replaced by a newer canary, which names this one in `rotated_from_id`. |
| `revoked` | Retired: by you through the API or the console, or found revoked at the provider by someone else, which also raises an alert. |
| `missing` | The provider has no record of the credential and Feint did not remove it. An alert was raised when this was noticed. |

## Once it is used

| Field | Meaning |
|---|---|
| `last_used_at` | When the credential was last seen used: GitLab's throttled last-used time, or the latest CloudTrail record for an AWS key. Non-null means the canary has fired. |
| `last_used_ips` | Every source address reported for it, accumulated over time. |
| `last_checked_at` | When Feint's monitor last looked. |
| `needs_attention` | Used, and nobody has acknowledged since. A fresh use after an acknowledgement raises it again. |
| `acknowledged_at`, `acknowledged_by` | The last acknowledgement, made in the console. |

The events behind these fields are listed by [`GET /v1/alerts`](https://feintsecurity.com/docs/alerts).

> [!NOTE]
> Acknowledging a used canary, and editing a canary's placement or metadata, are done in the console for now. The API reads the result.

## Rotating

`POST /v1/tokens/{id}/rotate` mints a replacement with the same placement, metadata and original lifetime, returns it with its new `secret`, and revokes the old credential at the provider. The old record becomes `rotated`; the new one points back to it in `rotated_from_id`. Rotation does not count against your plan's canary cap.

The replacement is minted before the old token is revoked, so a failure leaves you with a working canary rather than none. If the new one exists but the old could not be revoked, the response is `400` and says so; revoke the old credential by hand at the provider.

Rotate when the secret is lost, or when the placement has been refreshed and the old secret would look stale.

## Revoking

`DELETE /v1/tokens/{id}` revokes the credential at the provider and marks the record `revoked` with `revoked_at`. Revoking an already revoked canary returns the record unchanged. Retired canaries stay in your list and keep their history.

## Limits

An organization on the Free plan can have 5 active canaries — `active` and not yet expired — at a time; Pro has no cap. Issuing past the cap returns `400` with a message saying so. Revoked, rotated and expired canaries do not count, and rotation never does.
