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.
Create a token
Section titled “Create a token”forculus token create acme/hello ciCreated 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.
Options
Section titled “Options”| 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:
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.
Who can make one
Section titled “Who can make one”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.
Call the app
Section titled “Call the app”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:
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:
forculus curl acme/helloHello, 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.
What the edge changes on the way
Section titled “What the edge changes on the way”- It removes your
Authorizationheader, so the token never reaches your app. - It removes cookies from the request, and
Set-CookieandService-Worker-Allowedfrom the response. - It adds
Content-Security-Policy: sandboxandX-Content-Type-Options: nosniffto the response. - It keeps your app’s redirects on your app, as below.
Redirects through the gateway
Section titled “Redirects through the gateway”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.
What your app sees
Section titled “What your app sees”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}from forculus import require_user
user = require_user(request.headers)if user.kind == "service": ... # user.sub is "service:bt_…" and user.email is Noneuser, err := forculus.RequireUser(r)if err == nil && user.Kind == "service" { // user.Sub is "service:bt_…" and user.Email is ""}let user = verifier.require_user(&headers).await?;if user.kind == forculus::Kind::Service { // user.sub is "service:bt_…" and user.email is None}The SDK pages cover setup for each language.
List tokens
Section titled “List tokens”forculus token list acme/hellobt_c12w2c7832i15yme editor active nightly-sync ends 2026-11-03bt_fxcm00j6joovjjxj viewer active ci ends 2027-10-04Each 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.
Revoke a token
Section titled “Revoke a token”forculus token revoke acme/hello bt_fxcm00j6joovjjxjRevoked 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:
forculus curl acme/helloThat token isn't good here: it was revoked, it has expired, or it isn't one of ours.On stderr:
401 UnauthorizedThis 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.
When its maker loses access
Section titled “When its maker loses access”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.
When a call is refused
Section titled “When a call is refused”| 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.
Limits
Section titled “Limits”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/billingRequests made with a token count toward the workspace’s monthly requests, like a person’s. See Plans and billing.
In the audit log
Section titled “In the audit log”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 helloforculus watch shows the same request live:
00:34:05 ALLOW token:bt_fxcm00j6joovjjxj hello GET /acme/hello/ -Next steps
Section titled “Next steps”- Share an app: access for people.
- Public paths and webhooks: for a caller that can’t hold any token.
- Secrets and outbound calls: credentials your app uses to call other services.