Canary tokens
Issuing, the secret you see once, rotating, revoking, and what each field means.
# 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.
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.
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
}'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:
printf '%s' "$FOUND_TOKEN" | shasum -a 256An 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:
{
"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.
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.