Authentication
Your backend holds a project credential and mints short-lived device tokens from it. A token's grant says what it may write, what it may read, and how narrowly.
Forest never asks a device to hold a project credential. Your own systems hold one, and mint each device a short-lived JWT from it. Forest verifies the signature, resolves the project from the credential the token names, and ignores anything the token says about which tenant it belongs to.
Two kinds of credential, same token shape:
| You hold | Signed with | Use when | |
|---|---|---|---|
| API key | fk_<id>_<secret> | HS256, the shared secret | Minting happens somewhere you are happy to keep a secret — a backend, a provisioning job |
| Signing key | Your private key | EdDSA, ES256 or RS256 | You would rather forest never hold anything that can sign |
Both are created per project in the console. Both produce a token with a kid
header naming the credential, and everything below applies equally to either.
Minting a token
import { mintToken } from "@xeonr/forest/server";
const token = await mintToken({
apiKey: process.env.FOREST_API_KEY!,
clientId: device.id,
ttlSeconds: 3600,
attrs: { plan: user.plan },
});From a registered signing key, pass the private half instead — it never leaves your systems:
const token = await mintToken({
signingKey: {
keyId: "01j9x…", // assigned by forest at registration
privateKey: process.env.FOREST_PRIVATE_KEY!, // PKCS#8 PEM, private JWK, or a CryptoKey
},
clientId: device.id,
read: { filters: { "attr:session_id": ["s-9"] } },
});There is nothing forest-specific in the result — it is an ordinary JWT, and any language with an HMAC can produce one:
// header
{ "alg": "HS256", "typ": "JWT", "kid": "01j9x…" }
// claims
{
"sub": "device-4711",
"iat": 1756512000,
"exp": 1756515600,
"forest": {
"write": { "attrs": { "plan": "pro" } }
}
}sub is your own identifier for the device and becomes client_id on every
record. exp is required, and must be within the maximum lifetime configured on
the credential — a token that outlives it is refused however well it is signed.
The API key secret must never reach a device or a browser bundle. Mint on your
own infrastructure and hand the device the token. @xeonr/forest/server is a
separate entry point so a bundler cannot pull it into a client build by
accident.
Grants: what a token may do
The forest claim is the token's authority. It has two independent halves, and
absence is denial:
- no
write→ the token cannot ingest; - no
read→ the token cannot query or open a live tail.
The default from mintToken is write-only, which is what an ordinary device
wants.
Writing
"write": { "attrs": { "session_id": "s-9", "node": "n7" } }attrs are assertions. They are stamped onto every record sent with the
token and the device cannot override them — so "this token may only log against
session s-9" is enforced rather than trusted. Attributes pinned to the
credential itself in the console still win over these.
Asserted attributes are validated against your declared fields like any other: being in the token makes a value trustworthy, not exempt.
Reading
const token = await mintToken({
apiKey: process.env.FOREST_API_KEY!,
clientId: "support-session-8821",
canWrite: false,
ttlSeconds: 900,
read: {
primitives: ["logs", "events"],
filters: { "attr:session_id": ["s-9"] },
},
});filters narrow every query the token makes. Keys are field names in the same
vocabulary the console filters use — a built-in column (client_id, level,
name) or a declared custom field (attr:session_id). Values within one key
are OR; keys AND together.
The narrowing is a ceiling, not a default: a request that sends its own filter can only narrow further. There is no filter a caller can send that widens what its token may see.
maxAgeSeconds floors the time range — a token with maxAgeSeconds: 86400
asking for a week returns the last day rather than an error. It has no meaning
on a live tail and is ignored there.
A read grant needs filters or all: true. One with neither is refused rather
than read as project-wide, so a bug that drops the filters cannot quietly widen
a token to your whole tenant.
Reading a whole project
read: { all: true }Permissive tokens are a legitimate thing to mint — a dashboard of your own, an export job, an internal tool. The credential has to be allowed to mint one; see below.
What a credential may delegate
Your token mints whatever you ask for, and forest refuses anything the credential is not permitted to delegate. Each credential carries a policy, set when you create it:
| Setting | Effect |
|---|---|
| Allow write | May mint write grants at all |
| Allow unrestricted write | May mint a write grant that asserts no attributes |
| Allow read | May mint read grants at all |
| Allow unrestricted read | May mint read: { all: true } |
| Required attributes | Every token must constrain these — asserted on the write side, filtered on the read side |
New credentials are write-only: reads and unrestricted minting are choices you make deliberately rather than defaults you inherit.
Required attributes are the lever for a credential that may only ever produce
narrow tokens. A key requiring session_id refuses a permissive mint even if
the permissive flags are set, so a support-tooling credential cannot be turned
into a tenant-wide one by whoever holds it.
A token that exceeds its credential's policy fails with 403 and a message
naming what it asked for. The fix is on the minting side, not the credential —
you asked for authority that key does not carry.
Registering a signing key
If you would rather forest never hold anything capable of signing, register a public key instead:
- Generate a key pair. Ed25519 is the smallest and the default recommendation; ECDSA P-256 and RSA (2048 bits or more) are also accepted.
- Paste the public half into the console — JWK or PEM, either is fine.
- Forest assigns a key id. Put it in the
kidheader of every token you sign.
openssl genpkey -algorithm ed25519 -out forest-signing.pem
openssl pkey -in forest-signing.pem -puboutThe algorithm is fixed when you register the key and is never read from a token header, so a token that claims a different algorithm does not verify — you cannot be tricked into accepting one signed with the public key material.
Everything else is identical: same claims, same grants, same policy, same TTL ceiling. Revoke a key in the console and its outstanding tokens stop working immediately, with a message saying the key was revoked rather than that it was unknown.
Sending us a private key is refused rather than quietly reduced to its public half. If you paste one by mistake, treat it as disclosed and rotate it.
Who can see a project
Console access is separate from tokens. A project names the identity-provider groups that may read it, in the console's Access dialog; anyone signed in with one of those groups can read the project and manage its keys and fields. Administrators — identified by a role-gated scope from the identity provider, not by a group — see every project and are the only ones who can create or delete a project, change its access, or revoke a credential.
A project with no groups is visible to administrators only.
Using a read token
Pass it as a bearer on the API:
Authorization: Bearer <token>Forest tells your token apart from a console session by its key id, so no extra header or parameter is needed. For the live tail, see Live tail — streams take a ticket rather than the token itself.
Errors
| Status | Meaning |
|---|---|
401 unknown key | The kid names no credential in any project |
401 invalid token | Signature failed, or the algorithm is not the one the key uses |
401 token expired | exp has passed |
401 token lifetime exceeds the key's maximum | exp − iat is longer than the credential allows |
403 grant exceeds what this key may mint | The credential's policy does not permit this grant |
403 grant does not constrain an attribute the key requires | A required attribute is missing from the grant |
403 read grant has no filters and is not marked all | The fail-closed rule above |
403 this token has no write grant | A read-only token tried to ingest |
Notes on scoping
A filter on an attribute the device sets partitions your data; it does not
authenticate it. A device that chooses its own session_id can choose someone
else's. Constrain on attributes that are pinned to the credential or asserted in
the token — those are the ones a device cannot vary — when the boundary needs to
hold against the device itself rather than merely tidy the view.