Skip to content

Tokens for scripts and machines

A bypass token lets something that can’t sign in call one app: a CI job, a cron script or another service. Its requests still go through the edge. The edge checks the token on every request and tells your app which token called.

Terminal window
forculus token create acme/hello ci
Created bt_fxcm00j6joovjjxj (viewer), valid until 2026-10-29. Save the secret now. You won't see it again.
Call the app with it:
curl -H 'Authorization: Bearer fcb_aqnthzukntlkegy6mcln5vhgr6kabxoqdr34i3bdfiihrez4pwradqrjuxl' https://forculuslabs-apps.tech/acme/hello/

The name, ci here, is what you see in lists. The audit log shows the token’s id instead, bt_fxcm00j6joovjjxj here. forculus token list shows both. Make one token per script or service, so revoking one stops only that one.

Your workspace’s publish policy may keep making tokens to the people its owners name.

The secret starts with fcb_ and is shown once. We keep only a hash of it, so we can’t show it again. Store it in your CI’s secret settings or a secret manager, not in code.

Flag What it does
--role viewer The default. The app is told the caller is a viewer.
--role editor The app is told the caller is an editor. A token can’t be an owner.
--days <n> How many days it works for: 1 to 30, 30 by default.

A token made from the CLI ends no later than the CLI session that made it would. A CLI session lasts 30 days from forculus login, so the token lasts 30 days at most, and --days over 30 is refused. A token is a credential of its own, though, like an API key: signing out, or revoking the session, doesn’t end it. Revoke it with forculus token revoke, or every token and agent credential you made at once with forculus logout --everywhere --tokens, or Sign out everywhere on the dashboard’s Sessions page with its box ticked. The command prints when the token stops working, and --json shows it as expires_at:

Terminal window
forculus --json token create acme/hello nightly-sync --role editor --days 30
{
"token": {
"id": "bt_c12w2c7832i15yme",
"name": "nightly-sync",
"role": "editor",
"hint": "fcb_…axzx",
"created_by": "ana@example.com",
"created_at": "2026-09-29T00:35:10.201762Z",
"expires_at": "2026-10-29T00:34:02.264064Z",
"last_used_at": null,
"revoked_at": null,
"effective_role": "editor"
},
"secret": "fcb_luyxpzscvsdebzo7dwrk47unvpu4d3l7qyj2kwbpfllfcjlfdmbqc6t6tpr",
"url": "https://forculuslabs-apps.tech/acme/hello"
}

For a token that lasts longer, make it in the dashboard. Open the app, go to Bypass tokens, and select New token. Pick a name, Viewer or Editor, and how long it lasts: 7, 30, 90 or 365 days. If you last signed in more than 15 minutes ago, the dashboard asks you to sign in again. The API, POST /v1/workspaces/<workspace>/apps/<app>/tokens, takes expires_in_days from 1 to 365, and 365 when it’s left out. No token lasts longer than a year, however it’s made. Pick the shortest that works: a token that never needs replacing is one nobody remembers to revoke.

App editors and owners, and the workspace’s owners and admins, can create, list and revoke an app’s tokens. To create one, you also need to have accepted the current terms of service in the dashboard.

Send the secret as a bearer token to the apps domain, with the workspace and the app as the first two parts of the path:

Terminal window
curl -H "Authorization: Bearer $FORCULUS_TOKEN" https://forculuslabs-apps.tech/acme/hello/

The app gets the request without /acme/hello, so https://forculuslabs-apps.tech/acme/hello/api/items reaches your app as /api/items.

forculus curl does the same. Set FORCULUS_TOKEN from your secret store, then:

Terminal window
forculus curl acme/hello
Hello, there.

This is the app from your first deploy. It greets whoever X-Forculus-Email names, and a token has no email. An app that checks the caller with an SDK gets the token’s id instead. See What your app sees.

forculus curl prints the status, 200 OK, on stderr. For a redirect it also prints the Location it sends you to, or, when there is none, says the app sent none or the gateway dropped it. It sends the token as the request’s Authorization header, so it refuses a -H Authorization of your own. We take that header off before your app sees the request anyway. You can pass the token with --token instead, but then it ends up in your shell history. Like curl, it exits with code 0 whatever the app answers, a 404 or a 502 included. Add --fail to exit with code 22 on a 4xx or 5xx, as curl does.

The body is written out as it arrives, so an event stream shows each event as the app sends it. When access ends or your session ends, the edge cuts the stream off within 15 seconds. If the answer stops before it’s finished, or a stream stops once your session or your access to the app has ended, forculus curl prints an error that says so in place of the status, and exits with code 1:

error: acme/hello's answer stopped after 1,204 bytes: your session has ended. It was signed out, revoked or expired. Run `forculus login`.
error: acme/hello's answer stopped after 1,204 bytes: your access to the app ended while it was answering, so it was cut off.

With a bypass token, forculus curl can only tell a stream was cut off when the connection says so.

On a machine that has never run forculus login, forculus curl sends the request to https://forculuslabs-apps.tech. Use --apps-url or FORCULUS_APPS_URL to send it somewhere else. If FORCULUS_API points at another environment, set FORCULUS_APPS_URL to that environment’s apps domain too. The CLI won’t send the token anywhere else.

  • It removes your Authorization header, so the token never reaches your app.
  • It removes cookies from the request, and Set-Cookie and Service-Worker-Allowed from the response.
  • It adds Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff to the response.
  • It keeps your app’s redirects on your app, as below.

Every app shares the one host, forculuslabs-apps.tech, and a client that follows a redirect there sends your token along. So a redirect your app sends only goes out when it stays inside the app’s own path, /acme/hello:

Your app sends The caller gets
Location: /login Location: /acme/hello/login
Location: ../login, relative to the page The path it names, under /acme/hello. A .. stops at the app’s root.
Location: https://hello--acme.forculuslabs-apps.tech/login, the app’s own address over https Location: /acme/hello/login
Location: https://forculuslabs-apps.tech/acme/hello/next Location: /acme/hello/next
Anything else: another app, another site, http://, a port, a user name, or a path with a backslash or an encoded slash No Location at all. The status stays as your app sent it.

Dropping a redirect to another site matters because some clients put the token back on after it: older Go sends it on to a second page on that site, and curl sends it again if that site sends it back to https://forculuslabs-apps.tech.

Each request arrives with a fresh identity token in X-Forculus-Identity, as it does for a person. For a bypass token, kind is service, the subject is service: followed by the token’s id, and there’s no email. The role is the token’s role, or viewer while the person who made it is only a viewer of the app. See When its maker loses access.

To treat machines differently from people, check the kind:

import { requireUser } from "@forculus/sdk";
const user = await requireUser(request, env);
if (user.kind === "service") {
// user.sub is "service:bt_…" and user.email is null
}

The SDK pages cover setup for each language.

Terminal window
forculus token list acme/hello
bt_c12w2c7832i15yme editor active nightly-sync ends 2026-11-03
bt_fxcm00j6joovjjxj viewer active ci ends 2027-10-04

Each says the day it ends, in UTC. A token past its expiry is listed as expired, and a revoked one as revoked. Neither works any more.

The list doesn’t show secrets. forculus --json token list acme/hello adds created_by, expires_at, revoked_at and last_used_at, and a hint with the secret’s last four characters, like fcb_…axzx. last_used_at is updated at most once a minute.

Terminal window
forculus token revoke acme/hello bt_fxcm00j6joovjjxj
Revoked bt_fxcm00j6joovjjxj. It stops working on the next request.

The edge checks the token with our control plane on every request and doesn’t cache the answer, so the next call is refused. A WebSocket, stream or download already open with the token is checked every 10 seconds, and closes within 15 seconds of the revoke.

The next call:

Terminal window
forculus curl acme/hello
That token isn't good here: it was revoked, it has expired, or it isn't one of ours.

On stderr:

401 Unauthorized
This 401 came from Forculus, not the app. The token wasn't accepted. It may be revoked or expired, or made for an app that no longer exists.

A revoked token stays in forculus token list, marked revoked. Revoking it again gets:

error: Not found: acme/hello has no active token bt_fxcm00j6joovjjxj, or you can't see the app.

Deleting the app deletes its tokens too.

A token can’t do more than the person who made it can do today. The edge checks this on every request, without caching, so a change applies from the token’s next request. An open WebSocket, stream or download closes within 15 seconds, as it does after a revoke.

What its maker has now What the token does
Editor or owner of the app, or owner or admin of the workspace Works with the role it was made with.
Viewer of the app, by a share or because the app is open to the whole workspace Works, but your app is told viewer, even for an editor token. It works as an editor token again once its maker is an editor again.
No access Refused with 401 and That token isn't good here: it was revoked, it has expired, or it isn't one of ours., as if it were revoked.

The token is also revoked, and shows as revoked in forculus token list and the dashboard, when its maker:

  • is removed from the workspace, or leaves it
  • is taken off the app, and the app isn’t open to the whole workspace
  • had the app through a group or an email domain, and loses that: they’re taken out of the group, the group is deleted, or the app stops being shared with the group or the domain
  • is changed from owner or admin to member, and has no share of the app
  • deletes their account

It’s also revoked when the app is closed to the workspace, or the allowed email domains change, and that leaves its maker with no access. Any other token the edge refuses for this reason is revoked within the hour. We log that revoke ourselves, with the reason creator_lost_access.

A revoked token stays revoked. Giving its maker access again doesn’t bring it back, so make a new one.

forculus token list and the dashboard tell you when a token can do less than it was made for, and why: limited to viewer for now: ada@example.com can only view the app. In --json and the API, that’s effective_role and capped_because. effective_role is null when the token can do nothing. capped_because is one of viewer_grant, workspace_member, no_grant, not_member, outside_domains or no_account.

You can only make a token you could use yourself. If your own address is outside the workspace’s allowed email domains, you’re refused with 409 outside_domains, even as an owner or admin, because the edge would refuse the token from the start.

For a job that should keep running after someone leaves, have the token made by someone who will stay, such as a workspace owner.

Status Body Why
401 unauthorized No token, or a credential that isn’t one of ours, on a path the app hasn’t made public.
401 That token isn't good here: it was revoked, it has expired, or it isn't one of ours. A token that was revoked or has expired, one whose app was deleted, one whose maker no longer has access to the app, or one cut short, too long or made up.
401 A token goes in the Authorization header, alone and after Bearer: `Authorization: Bearer <token>`. Two credentials in one Authorization header, a token without Bearer in front, or a token anywhere but the Authorization header.
401 An agent's credential, a bypass token or a CLI session goes to the gateway, https://forculuslabs-apps.tech/acme/hello/, not to the app's own host. The token went to the app’s own address instead of the apps domain.
403 This bypass token is for another app, or there's no app here. A token that still works was sent to another app, or to an address where no app exists or nothing is deployed.
403 This workspace has its people sign in through its own single sign-on, and this token was made by someone who hadn't. Whoever made it can make a new one once they've signed in that way. The workspace wants single sign-on, and whoever made the token didn’t sign in that way.
429 too many requests Too many tokens that weren’t good, from one address. Wait a minute.
402 or 403 A page The workspace can’t serve requests right now. See Limits.

Every refusal is in the audit log, with the reason.

Each app has a limit on active tokens: 5 on Free, 50 on Team and 100 on Business. Revoked and expired tokens don’t count. At the limit:

error: This app has 5 active tokens. The Free plan allows 5 per app. Revoke one or upgrade. Team allows 50 tokens per app. Billing: https://app.forculuslabs.tech/w/acme/billing

Requests made with a token count toward the workspace’s monthly requests, like a person’s. See Plans and billing.

Creating and revoking a token are logged as token.create and token.revoke, with the token’s id.

When a token is revoked because its maker lost access, its token.revoke is logged as done by whoever made the change, or by the maker if they left or deleted their account. The entry has created_by and a reason: member_removed, member_left, grant_removed, group_member_removed, group_grant_removed, group_deleted, domain_grant_removed, role_changed, visibility_changed, domains_changed or account_deleted.

A token revoked with forculus logout --everywhere --tokens, or with Sign out everywhere and its box ticked, is logged as done by its maker, with the reason revoked_everywhere and no created_by.

Every request made with a token shows its id as the caller. That includes requests refused because the token was revoked or expired, or its maker lost access, so you can see a leaked token being tried. A token tried at another workspace’s app is refused there without its id, so that workspace’s log doesn’t record it.

In forculus audit:

2026-09-29T00:34:05Z access allow bt_fxcm00j6joovjjxj GET /acme/hello/ on hello

forculus watch shows the same request live:

00:34:05 ALLOW token:bt_fxcm00j6joovjjxj hello GET /acme/hello/ -