Bitskiff

Credentials

On this page

A credential is how a request proves it may act on your project. There are two: a key you hold, and a token your backend mints from a key for one of your own users. The prefix on the string says which one it is and where it belongs.

The command line

The bitskiff commands on this page come from @bitskiff/cli, and bitskiff init is the way in: you approve it once in the console, and it stores an organization key that every key command then runs under. The tarball is on the downloads page, and npx runs it with nothing installed:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init

The organization key it stores acts as you. It holds projects:read, projects:create and keys:manage for the setup, and every project word: the session words, tokens:mint, project:read, project:manage, usage:read, audit:read and webhooks:manage. So the secret key you make next with keys create is never wider than it. The people and billing words stay in the console.

It also mints a host key into bitskiff.json in the directory you run it in, as key, beside the project's slug and the api it talks to.

--join-url <url> sets the project's join URL, mints a client key for that page's origin, and records both there as joinUrl and clientKey. It writes a hello world page beside that file only when the directory is empty, or when you pass --scaffold; in a project you already have, bitskiff.json is the one file it adds.

Already made an organization key in the console's Keys panel, where its type is Organization? login stores that key instead:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz login

The CLI already knows the api it was published beside; the CLI page has the endpoint each environment answers on, for a CLI pointed at another one.

Prefixes

PrefixWhat it isWhere it belongs
bsk_pk_publishable key: a host key or a client keypage source, bounded by an origin allowlist
bsk_sk_secret keyyour server, or a device you control. Never a browser
bsk_ak_organization keyyour command line and your CI. Never a browser
bsk_at_tokenwherever you hand it: a signed-in page, an app, a client

Two other prefixes belong to a session rather than to your project: bsk_ht_ is the host token a host holds on the session it is already running, and bsk_rt_ is a client's resume token.

A third, bsk_cs_, is a console sign-in—it belongs to the person signed in to the console, and to no session. None of the three names a scope, a permission or a subject, and none of them opens anything new.

A key names a scope—one project, or the organization—a list of permissions and a list of limits. A token names one subject, permissions no wider than its key's, limits no higher, and an expiry. Those two are the whole set: a project holds keys and tokens and nothing else.

Publishable keys

A publishable key is public: it ships in page source. There are two, told apart by the one word each holds:

NameHoldsWhere it goes
host keysessions:opena host page: new BitskiffHost({ key })
client keysessions:joina client page or app: new BitskiffClient({ key })

A host key is what a browser host opens with:

import { BitskiffHost, type Session } from '@bitskiff/host';

const session: Session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();   // your host key
import { BitskiffHost } from '@bitskiff/host';

const session = await new BitskiffHost({ key: 'bsk_pk_...' }).open();   // your host key

Create one with init, in the directory of the code that uses it. It leaves the key in bitskiff.json for that code to read:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz init

init uses the project the directory's bitskiff.json names, else makes a new project named for the directory. keys create --type publishable makes a further key and prints its string once; the CLI page has it.

When a command is refused:

  • Refused invalid_key? The stored key no longer works. Run npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz logout, then init again.
  • Refused forbidden with details.reason of missing_permission? The stored key lacks the permission details.permission names. Run logout, then init to sign in again.
  • Refused forbidden without that reason? The key may not use that project. Pass --project of a project it may use.
  • Key from BITSKIFF_ORG_KEY? logout does not remove it. Unset the variable instead.

A publishable key holds its one word and no other, and the list cannot be edited. A call outside its class is refused forbidden (403) with details.reason of not_for_class. A host key presented on a join is refused forbidden with missing_permission: a client page takes a client key.

A publishable key is stored in clear and readable in full in the console, because the page it ships in prints it anyway; the CLI names it by its keyId and prints its string only when it creates it.

The origin allowlist is what bounds it instead. A key created with no origin allows http://localhost and http://127.0.0.1 on any port, which is how init makes a host key, so a page served locally works with nothing set up. A client key made for a page allows that page's origin only.

keys update takes --origin <your page's origin> (repeatable) for a deployed page. Read the list before you change it, because --origin replaces all of it.

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys list
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys update <keyId> --origin https://app.example.com --origin 'http://localhost:*'

An entry is scheme://host[:port], http or https, with no path. The entry https://*.example.com matches any host below the apex and never the apex itself, so app.example.com and a.b.example.com both match and example.com does not. An entry with no port matches the scheme's default port and no other, so a page served on :8443 needs that port written out, or :*.

An open or a join from an origin the list does not carry is refused origin_not_allowed (403) with details.reason of mismatch. An open with no browser origin at all—a page opened from disk, an Electron main process, Node—is refused the same code with details.reason of absent; that host holds a secret key or a token instead.

The allowlist makes a key copied into another site's page fail loudly. It does not stop a scraped key driven from a script: the key's own limits bound that one.

Client keys

A client key is what your own client page joins with, and it is made for you. Set the project's join URL to your page and a client key for that page's origin comes with it:

  • From the command line: init --join-url <url> mints the key and saves it in bitskiff.json as clientKey.
  • In the console: set the join URL in the project's settings. A project that holds no client key gets one.

To make one by hand, create it in the console's Keys panel by picking A client page (joins sessions), or from the command line. The console's form asks for no origin, so add your page's afterwards with keys update <keyId> --origin, where <keyId> is the new client key's id. The command line takes the origin at creation, and <hostKeyId> is the host key's id, which keys list prints:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type publishable --preset client-page --origin <your page's origin> --host-key <hostKeyId>

A client key joins its own project's sessions and no other project's. Its second condition, hostKeys, narrows that to the sessions opened by the host keys it names. --host-key sets it and repeats; leave it off and the key joins sessions opened by any host key of the project.

A project with one app never needs it. A project with two, such as a game and a quiz, gives each client page a key that serves only its own host key.

A join the key is not for—another project's session, or one opened by a host key its hostKeys does not name—is refused wrong_client before the client takes a place.

A native app that sends no Origin header declares one with origin beside its client key, and that is checked against the same allowlist. An app shell that does send a real Origin header, such as Capacitor on iOS with capacitor://localhost, is matched on that header.

A client key opens no sessions. Revoking one stops new joins with it. Under manual the clients already in keep their places; under compromise they lose them within 5 seconds.

The sample client page is the one client that holds no key. See Client pages.

Secret keys

A secret key is for a server, a CI job, or a device you control. It carries any permission your project's routes name, and you pick the list when you create it:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys create --type secret --label backend \
  --permission sessions:open --permission tokens:mint

--preset host-only is sessions:open alone, --preset monitor is sessions:read and usage:read, and --preset full-access is every project word. A preset only fills the list in once. It is not a tier, and a key created under one does not pick up a word added later.

The string is shown once, when the key is created, and every listing after that shows a masked form. Put it where your other secrets live.

Permissions are editable after the key exists, and --permission replaces the whole list the way --origin does, so read the key first:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys update <keyId> --permission sessions:open --permission sessions:read

The console's Keys panel lists every key with its permissions, creates one and revokes one. Editing a key and rotating one are command-line commands; the console does neither.

A request whose key lacks the word a route names is refused forbidden (403), with details.reason of missing_permission and details.permission naming the word it wanted. A secret key presented from a browser is refused secret_key_in_browser (403) whatever its permissions: the browser sends an Origin, and a secret key is not a browser's credential.

Rotation replaces a key with no outage in the middle:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys rotate <keyId> --overlap 24h

It makes a successor, prints the successor's string once, and leaves the old key verifying until the overlap runs out, so both work while you roll the new string out.

Revoking ends a key at once and takes a reason, which is asked for and never assumed, because it decides what happens to the sessions the key opened: manual leaves them running until they end on their own, compromise ends them now.

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz keys revoke <keyId> --reason manual

Permissions

A credential carries the words its work needs, and a route names the one word it wants. This is the whole vocabulary the service has, and the last column says who may hold each word:

PermissionWhat a credential holding it may doWho may hold it
sessions:openOpen a session. The one word a host key carriesA key of either scope, or a token
sessions:readList sessions, read one, and read one session's metersA key of either scope, or a token
sessions:manageChange a running session, and boot a client or change its grantsA key of either scope, or a token
sessions:joinJoin a session. A client key's one word, and a join token'sA token, or a client key
sessions:claimTake a fixed code's claim at open from a host key. A secret key or a token needs only sessions:openA host key only, when the developer chose it
tokens:mintMint a token, list the tokens still outstanding, and end one earlyA project key or an organization key
project:readRead the project this request names: what it is, its settings and its limitsA project key or an organization key
project:manageChange the project's settings and its limits, and ask for a raiseA project key or an organization key
usage:readRead what the project has used, and the events it raisedA project key or an organization key
audit:readRead the audit log: every key, limit and webhook change, with who made itA project key or an organization key
webhooks:manageRead and set where the project's events are deliveredA project key or an organization key
projects:readList an organization's projectsAn organization key only
projects:createCreate a project in an organizationAn organization key only
keys:manageCreate, change, rotate and revoke the organization's and the project's keysAn organization key only
members:inviteInvite someone into an organization, read what is open, and revoke oneAn organization key only
members:manageRead an organization's members and set what each one may doAn organization key only
billing:readRead the billing accounts an organization can seeAn organization key only

The prefixes project: and projects: are one letter apart and not the same permission. The words project:read and project:manage are about the one project a request names. The words projects:read and projects:create are about the list of projects an organization holds, and a key that manages a project carries neither.

A token may name only the four session words, because a word the subject check cannot narrow would hand a page the whole project.

Organization keys

An organization key (bsk_ak_) is scoped to the organization rather than to one project, and it is what tooling holds: bitskiff login stores one. It reaches one project's routes by naming that project in an X-Bitskiff-Project header.

Six words in the table above are an organization key's alone, and a project key asking for one is refused: projects:read and projects:create for the list of projects, keys:manage for the keys, members:invite and members:manage for the people, and billing:read for the billing accounts. Every other word an organization key may hold too.

Give a job the words it needs and no more: a nightly hosting check holds sessions:open, a support tool holds sessions:read and audit:read.

A member creates one in the console, and only there: a leaked key that could create organization keys would leave one behind that outlives its own revocation, so bitskiff keys create --scope org refuses and says so. Organization keys expire, 90 days by default and one year at most.

Tokens

Mint a token when the holder is not you and will not stay you: a page behind your own sign-in, a device you want to end one at a time, a partner service.

Keep a key when the holder is you: a server you run, a fleet you own, a job you schedule. The common name for minting per user is token vending, and it is one call from a backend holding a secret key with tokens:mint:

// Your backend, behind your own sign-in. This code never runs in a browser.
const endpoint = process.env.BITSKIFF_ENDPOINT;   // your endpoint, from `bitskiff whoami`
const key = process.env.BITSKIFF_SECRET_KEY;      // bsk_sk_..., holding tokens:mint

interface Minted {
  tokenId: string;
  token: string;
  expiresAt: string;                              // Unix ms; a 64-bit number arrives as a string
}

export async function mintToken(subject: string): Promise<string> {
  const response = await fetch(`${endpoint}/v1/tokens`, {
    method: 'POST',
    headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
    body: JSON.stringify({
      subject,                                    // your own id for them, 1 to 128 characters
      permissions: ['sessions:open'],             // the default
      limits: { concurrentSessions: 1 },          // also the default
      ttl: '10m',                                 // also the default
    }),
  });
  if (!response.ok) throw new Error(`mint refused: ${String(response.status)}`);
  const minted = (await response.json()) as Minted;
  return minted.token;                            // bsk_at_..., yours to hand out once
}
// Your backend, behind your own sign-in. This code never runs in a browser.
const endpoint = process.env.BITSKIFF_ENDPOINT;   // your endpoint, from `bitskiff whoami`
const key = process.env.BITSKIFF_SECRET_KEY;      // bsk_sk_..., holding tokens:mint

export async function mintToken(subject) {        // `subject` is your own id for the holder
  const response = await fetch(`${endpoint}/v1/tokens`, {
    method: 'POST',
    headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
    body: JSON.stringify({
      subject,                                    // your own id for them, 1 to 128 characters
      permissions: ['sessions:open'],             // the default
      limits: { concurrentSessions: 1 },          // also the default
      ttl: '10m',                                 // also the default
    }),
  });
  if (!response.ok) throw new Error(`mint refused: ${String(response.status)}`);
  // The answer is { tokenId, token, expiresAt }: `expiresAt` is Unix ms, and a 64-bit number
  // arrives as a JSON string.
  const minted = await response.json();
  return minted.token;                            // bsk_at_..., yours to hand out once
}

Hand the string to the SDK where a key goes: new BitskiffHost({ key: token }) on a host. The SDK never reads it.

The ttl field is a duration string and defaults to 10m. A value above your project's tokensMaxTtl, 1h by default, is refused validation (400) with details.field of ttl rather than lowered to fit.

tokensMaxTtl is raisable, to 720h (thirty days), which is how a fleet holds a token long enough to ride out a backend outage: see Raises.

The permissions field defaults to ["sessions:open"] and may name only the session words—sessions:open, sessions:read, sessions:manage, sessions:join—because a word the subject check cannot narrow would hand a page the whole project.

A limit above the minting key's own is refused quota_above_ceiling (400). Naming a permission the key does not hold is not refused, and it grants nothing: a token is only ever its key narrowed, so the extra word has no effect. Narrow the key later and every token it has already minted narrows with it.

The subject is yours. It is a string we store, count against and attribute to, and never parse, resolve or show to anyone but you: your own id for a person, a device id, or a compound such as team:acme/user:8123. The service carries it on the session, on the client's limits and on the usage rows, which is what makes usage and limits read by subject.

Join tokens

A client page can present a token too. Mint it with ["sessions:join"] and, when you know the session, its sessionId:

  • sessionId pins the token to one session, so a token captured on its way cannot be redeemed anywhere else. sessionId on any other permission list is refused validation.
  • Expiry is 1h at most, whatever the project allows. The ceiling is the lower of tokensMaxTtl and 1h, so raising tokensMaxTtl lengthens a host's token and never this one. A ttl above the ceiling is refused validation.
  • Subject names who is joining. The service meters, limits and attributes the client under it. On the host, client.state.subject is the subject of the token that client presented, so the host can greet your user by your own id. A client that joined with no token has no subject.

The client still joins with the join code, and the token rides along on join. A token never stands in for the client key:

import { BitskiffClient } from '@bitskiff/client';

const code = location.hash.slice(1);                          // your page read its own link

const { token } = (await fetch('/api/join-token').then((r) => r.json())) as { token: string };

const client = new BitskiffClient({ key: 'bsk_pk_...' });     // your client key
await client.join(code, { token });
import { BitskiffClient } from '@bitskiff/client';

const code = location.hash.slice(1);                          // your page read its own link

const { token } = await fetch('/api/join-token').then((r) => r.json()); // bsk_at_...

const client = new BitskiffClient({ key: 'bsk_pk_...' });     // your client key
await client.join(code, { token });

The SDK does not hold the join code across a redirect through your own sign-in: inspect and join take the code as your page passes it, so your page is what carries it across. A join code is good for 30 to 60 seconds from the moment it is shown—see Join codes for the window—so mint the token before the page joins, as above.

One token, minted from the command line, is the fastest way to see this work before you write the backend:

Shell
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz tokens mint --subject user_8123 --permission sessions:join --session <sessionId> --ttl 10m
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz tokens list --subject user_8123
npx --yes https://docs.dev.bitskiff.com/downloads/0.3.0/bitskiff-cli.tgz tokens revoke <tokenId>

These commands read BITSKIFF_SECRET_KEY from the environment rather than the key bitskiff login stored. Ending a token stops it opening anything new and ends no session already running. A token the service refuses is invalid_token (401) and never invalid_key, with details.reason of expired, revoked or unknown: the first calls for another token, the last two call for a signed-in person.

Limits

A permission says whether you may; a limit says how much, how fast and how long. Both a key and a token carry limits, and the more restrictive value along the chain always wins, so a key can only narrow what the project allows and a token can only narrow what its key allows.

Set a limit with --limit <name>=<value> on bitskiff keys create, on bitskiff keys update, and on bitskiff tokens mint. These are the names each one takes:

CredentialLimits it takes
a keyconcurrentSessions, concurrentClients, sessionOpensPerMinute, clientMinutesPerDay, ticketMintsPerMinute, tokensMaxTtl, requireToken
a tokenconcurrentSessions, concurrentClients, sessionOpensPerMinute, clientMinutesPerDay, joinsPerMinute

A token's limits are counted per subject rather than per project, so one subject's runaway page does not spend the project's share. concurrentSessions on a token defaults to 1. Limits and capacity has the numbers and what each refusal means.

Next steps