SDK overview
Every request that reaches your app has been through our sign-in, unless it came in on a public path. The edge adds a signed token saying who the caller is, and your app verifies it to learn their email and role. A webhook delivery on a verified public path gets a different kind of token, which says which provider signed it. See Webhook deliveries.
The SDKs verify the token in one call. To deploy, share or read the audit log from code instead, see API clients. They are open source (Apache-2.0) at github.com/Forculus-Labs/forculus.
Install
Section titled “Install”npm install @forculus/sdkWorkers, Node 20 or newer, Deno and Bun.
pip install forculusPython 3.10 or newer.
go get forculuslabs.tech/sdkGo 1.22 or newer fetches it through the default module proxy. With GOPROXY=direct it needs Go 1.25 or newer.
cargo add forculusAdd --features axum for the axum layer and extractor.
Verify a request
Section titled “Verify a request”import { requireUser, Unauthorized } from "@forculus/sdk";
export default { async fetch(request, env) { try { const user = await requireUser(request, env); return new Response(`hello ${user.email}`); } catch (err) { if (err instanceof Unauthorized) return err.toResponse(); throw err; } },};from forculus import Unauthorized, require_user
@app.get("/")def index(): try: user = require_user(request.headers) except Unauthorized: return "unauthorized", 401 return f"hello {user.email}"import forculus "forculuslabs.tech/sdk"
http.Handle("/", forculus.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, _ := forculus.UserFrom(r.Context()) fmt.Fprintf(w, "hello %s\n", user.Email)})))// Once, at startup.let verifier = forculus::Verifier::from_env()?;
// Per request, from any http::HeaderMap.let user = verifier.require_user(&headers).await?;Deploy it and call it as yourself:
forculus curl acme/hellohello someone@example.com200 OKEach language page covers the framework integrations: Node.js, Python, Go and Rust.
What you get back
Section titled “What you get back”A verified caller has six fields. Each SDK spells them its own way.
| Node.js | Python | Go | Rust | |
|---|---|---|---|---|
| Person or machine | kind |
kind |
Kind |
kind |
| Stable id | sub |
sub |
Sub |
sub |
email |
email |
Email |
email |
|
| Role on this app | role |
role |
Role |
role |
| Workspace id | workspaceId |
workspace_id |
WorkspaceID |
workspace_id |
| Agent’s name | agentName |
agent_name |
AgentName |
agent_name |
| Is it an agent? | isAgent(user) |
user.is_agent |
user.IsAgent() |
user.is_agent() |
kindisuserfor a person andservicefor a bypass token, one of the workspace’s agents or one of the app’s scheduled jobs. These calls refuse a webhook delivery’s token. See Webhook deliveries.subis the person’s Forculus id, likeusr_…,service:<token id>for a token,agent:<agent id>for an agent, orscheduler:<schedule id>for a scheduled job. Use it as the key when you store something per caller. Before doing a scheduled job’s work, checkkindisserviceandsubstarts withscheduler:.emailis empty for a token or an agent:nullin Node.js,Nonein Python and Rust,""in Go.- The agent’s name is there only when one of the workspace’s agents called, and empty for everyone else, the same way
emailis. Show it, so people can see which agent did something, but decide what an agent may do bysub: whoever manages an agent can rename it, and once it ends its name is free for a new one. The agent’s name, and whether it’s an agent, are new in version 0.2.0 of each SDK. roleisviewer,editororowner.
A script calling with a bypass token has no email, so code that prints user.email prints null:
FORCULUS_TOKEN=fcb_... forculus curl acme/hellohello null200 OKFall back to the id when there is no email: user.email ?? user.sub.
Roles are ordered viewer, editor, owner. An owner can do anything an editor can.
Every SDK has a check that returns true or false, and one that fails with a 403 error:
| Ask | Refuse | |
|---|---|---|
| Node.js | hasRole(user, "editor") |
requireRole(user, "editor") throws Forbidden |
| Python | has_role(user, "editor") |
require_role(user, "editor") raises Forbidden |
| Go | forculus.HasRole(user, forculus.RoleEditor) |
forculus.RequireRole(user, forculus.RoleEditor) returns an error matching forculus.ErrForbidden |
| Rust | user.has_role(Role::Editor) |
user.require_role(Role::Editor) returns Err(Forbidden) |
Answer 401 when you don’t know who the caller is, and 403 when you know and they may not do this.
A role the SDK doesn’t know grants nothing. If we add a role, an app built with an older SDK still lets callers with it through requireUser, and every role check refuses them, so a route that checks a role is closed to them and one that doesn’t is open. Asking for a role that doesn’t exist refuses everyone.
The identity token
Section titled “The identity token”The edge sends the token in the X-Forculus-Identity header. It is a JWT signed with RS256 that lasts 60 seconds, and the edge makes a new one for every request. A request with two of the header is refused by every SDK.
Decoded, a token looks like this:
{ "alg": "RS256", "kid": "…", "typ": "JWT"}{ "wid": "2a64ea1d-cebb-4356-89a1-52d01912cf20", "role": "owner", "kind": "user", "email": "someone@example.com", "iss": "https://edge.forculuslabs.tech", "aud": "adc2f06a-aec5-45e0-bea7-746ac5390091", "sub": "usr_8xeuqtea67e3kkqpa0od9qhfvl", "iat": 1790196735, "exp": 1790196795, "jti": "97369b6b-123d-4a16-acfe-9c07460bb259"}| Claim | What it is |
|---|---|
iss |
The edge, https://edge.forculuslabs.tech |
aud |
Your app’s id. A token made out to another app fails here. |
sub |
The caller’s stable id |
kind |
user or service |
email |
The person’s email. Absent for a token. |
role |
Their role on this app |
wid |
The app’s workspace id |
iat, exp |
When it was made, and 60 seconds later |
jti |
A random id for this token |
The SDKs check, and refuse the request if any check fails:
- the algorithm is RS256, whatever the token’s own header says
- the token names a key (
kid), and that key signed it issisFORCULUS_IDENTITY_ISSUERaudisFORCULUS_APP_ID, as one string and not a listexphasn’t passed, with no clock leeway, andnbfhas passed if the token has oneiat,sub,kind,roleandwidare there, andemailtoo for a personkindisuserorservice. A scheduled job’s isservice, with asubthat starts withscheduler:- no claim repeats one of those names in another case, like
EXP
The keys come from FORCULUS_JWKS_URL. The SDKs cache them for five minutes. A token signed by a key they haven’t seen makes them fetch again, at most once every ten seconds. A fetch that takes more than three seconds fails, redirects aren’t followed, and RSA keys shorter than 2048 bits are ignored.
Webhook deliveries
Section titled “Webhook deliveries”A delivery on a verified public path carries a token of kind webhook. We check the provider’s signature before sending the delivery on, so your app checks our token and doesn’t need the provider’s secret. The token is made out to webhook:<app id>, not your app’s id. It names the provider and the public path’s pattern, and has no email and no role. requireUser refuses it, and requireWebhook refuses everything else, a person’s token included.
import { requireWebhook, Unauthorized } from "@forculus/sdk";
export default { async fetch(request, env) { try { const hook = await requireWebhook(request, env, { provider: "stripe" }); const event = await request.json(); // hook.delivery is Stripe's event id. Skip one you've handled. return new Response(null, { status: 204 }); } catch (err) { if (err instanceof Unauthorized) return err.toResponse(); throw err; } },};from forculus import Unauthorized, require_webhook
@app.post("/hooks/stripe")def stripe_hook(): try: hook = require_webhook(request.headers, provider="stripe") except Unauthorized: return "unauthorized", 401 event = request.get_json() return "", 204mux.Handle("POST /hooks/stripe", forculus.WebhookMiddleware(forculus.ProviderStripe)(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { hook, _ := forculus.WebhookFrom(r.Context()) // hook.Delivery is Stripe's event id. Skip one you've handled. w.WriteHeader(http.StatusNoContent) },)))use forculus::Provider;
// Per request, from any http::HeaderMap.let hook = verifier.require_webhook(&headers, &[Provider::Stripe]).await?;Each call names the providers the route takes, one or a list. A delivery from any other provider is refused, and so is every delivery when none is named. Each language’s page shows where to put the webhook route in its frameworks, so a check for people doesn’t refuse the delivery first.
A verified delivery has five fields:
| Node.js | Python | Go | Rust | |
|---|---|---|---|---|
| Provider | provider |
provider |
Provider |
provider |
| Public path pattern | pattern |
pattern |
Pattern |
pattern |
| Provider’s signed id | delivery |
delivery |
Delivery |
delivery |
| When it was signed | signedAt |
signed_at |
SignedAt |
signed_at |
| Workspace id | workspaceId |
workspace_id |
WorkspaceID |
workspace_id |
providerisstripe,github,slack,shopify,standard_webhooksorhmac_sha256.patternis the public path it came in on, such as/hooks/stripeor/hooks/*.deliveryis the provider’s id where its signature covers one: Stripe’s event id, Slack’sevent_id, the Standard Webhooks message id. GitHub, Shopify and HMAC-SHA256 sign none, and it’s empty:nullin Node.js,Nonein Python and Rust,""in Go.signedAtis when the provider signed it, for Stripe, Slack and Standard Webhooks: unix seconds, or atime.Timein Go. For the others it’s empty, likedelivery, and a zerotime.Timein Go.
For a webhook’s token the SDKs check the signature, iss and the expiry as they do for a person’s, and then:
audiswebhook:followed byFORCULUS_APP_ID, as one stringkindiswebhook, andsubandwidare thereprovideris one the SDK knows and the route namespatternisn’t emptydelivery, if present, is a string that isn’t empty, andsigned_at, if present, is a whole number of seconds
Webhooks and public paths lists the token’s claims.
What we set
Section titled “What we set”We give every app three variables:
| Variable | Value |
|---|---|
FORCULUS_APP_ID |
Your app’s id, the only audience the SDK accepts |
FORCULUS_IDENTITY_ISSUER |
https://edge.forculuslabs.tech |
FORCULUS_JWKS_URL |
https://edge.forculuslabs.tech/.well-known/jwks.json |
A Worker gets them as bindings, the env your fetch handler receives. A container gets them as environment variables. You can’t set or change them, because forculus env set refuses any name starting with FORCULUS_.
The issuer and key set are always https. From version 0.3.0 the SDKs refuse an http one unless FORCULUS_DEV is 1, which only forculus dev sets, on your machine. We never set it, and a container starts with it empty.
The key set is the one address of ours your app’s outbound requests can reach, so the fetch isn’t blocked.
Requests with only X-Forculus-Email
Section titled “Requests with only X-Forculus-Email”The edge also sends X-Forculus-Email, the caller’s email in plain text, for display in a log line or a greeting. Your app can’t tell from the header whether the edge set it: a request that reached your app some other way, or on a public path, can carry any header. See How access works.
The SDKs read only the signed token. A request with X-Forculus-Email and no valid token gets 401, the same as a request with no headers.
Without an SDK
Section titled “Without an SDK”If your language has no SDK, verify the token yourself with a JWT library:
- Read
X-Forculus-Identity. Refuse the request if it’s missing. - Pin the algorithm to RS256. Don’t let the token’s header choose it.
- Fetch the key set from
FORCULUS_JWKS_URL, and pick the key by the token’skid. - Check the signature,
issagainstFORCULUS_IDENTITY_ISSUER,audagainstFORCULUS_APP_ID, andexp. - Refuse on any failure, including a key set you couldn’t fetch.
On a route for a verified webhook, check the same way with one change: aud is webhook: followed by FORCULUS_APP_ID. Then check that kind is webhook and provider is the one the route takes. The audience is what keeps a webhook’s token from being accepted as a person’s, and a person’s as a webhook’s, so compare it exactly.
Don’t authorize on a header alone.
Local development
Section titled “Local development”The SDKs have no setting that skips the signature check. To run your app locally with real tokens, as an owner, a viewer, an agent, a webhook or someone the app isn’t shared with, use forculus dev. It sets FORCULUS_DEV=1, which lets the SDKs take its http issuer on your machine and changes nothing else.
Next steps
Section titled “Next steps”- Set up your language: Node.js, Python, Go or Rust.
- Local development: run your app on your machine with real tokens.
- Webhooks and public paths: take deliveries from Stripe, GitHub and others.