SDK overview
Every request that reaches your app has already been through the platform’s 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 token of its own kind, saying which provider signed it: see Webhook deliveries.
The SDKs do the verifying in one call. They are open source (Apache-2.0) at github.com/ss2d22/jiayang.
Install
Section titled “Install”npm install @jiayang-cloud/sdkWorkers, Node 20 or newer, Deno and Bun.
pip install jiayangPython 3.10 or newer.
go get jiayang.cloud/sdkFetching it needs Go 1.25 or newer.
cargo add jiayangAdd --features axum for the axum layer and extractor.
Verify a request
Section titled “Verify a request”import { requireUser, Unauthorized } from "@jiayang-cloud/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 jiayang 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 jiayang "jiayang.cloud/sdk"
http.Handle("/", jiayang.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, _ := jiayang.UserFrom(r.Context()) fmt.Fprintf(w, "hello %s\n", user.Email)})))// Once, at startup.let verifier = jiayang::Verifier::from_env()?;
// Per request, from any http::HeaderMap.let user = verifier.require_user(&headers).await?;Deploy it and call it as yourself:
jiayang 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 five 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 |
kindisuserfor a person andservicefor a bypass token. A webhook delivery has a token of its own, which these calls refuse: see Webhook deliveries.subis the person’s platform id (usr_…), orservice:<token id>for a token. Use it as the key when you store something per caller.emailis empty for a token:nullin Node.js,Nonein Python and Rust,""in Go.roleisviewer,editororowner.
A script calling with a bypass token has no email. Code that prints user.email prints nothing useful for it:
JIAYANG_TOKEN=jyb_... jiayang curl acme/hellohello null200 OKFall back to the id when there is no email: user.email ?? user.sub.
Roles are ordered: viewer, then editor, then owner. An owner can do anything an editor can.
Every SDK has a check that answers yes or no, 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 | jiayang.HasRole(user, jiayang.RoleEditor) |
jiayang.RequireRole(user, jiayang.RoleEditor) returns an error matching jiayang.ErrForbidden |
| Rust | user.has_role(Role::Editor) |
user.require_role(Role::Editor) returns Err(Forbidden) |
Answer with the right status. 401 means “I don’t know who you are”. 403 means “I know, and you may not do this”.
A role the SDK doesn’t know counts for nothing. If the platform adds a role one day, an app built with an older SDK refuses it rather than guessing what it allows. Asking for a role that doesn’t exist refuses everyone too.
The identity token
Section titled “The identity token”The edge sends the token in the X-Jiayang-Identity header. It is a JWT signed with RS256 and lives for 60 seconds. The edge makes a new one for every request.
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.jiayang.cloud", "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.jiayang.cloud |
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 issisJIAYANG_IDENTITY_ISSUERaudisJIAYANG_APP_IDexphasn’t passed, with no clock leeway, andnbf(if present) hasiat,sub,kind,roleandwidare there, andemailtoo for a personkindisuserorservice- no claim repeats one of those names in another case, like
EXP
The keys come from JIAYANG_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 too, of kind webhook. The platform checked the provider’s signature before sending it on, so your app checks the platform’s token instead and never holds the provider’s secret. The token is addressed to webhook:<app id> rather than your app’s id, 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 "@jiayang-cloud/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 jiayang 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", jiayang.WebhookMiddleware(jiayang.ProviderStripe)(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { hook, _ := jiayang.WebhookFrom(r.Context()) // hook.Delivery is Stripe's event id. Skip one you've handled. w.WriteHeader(http.StatusNoContent) },)))use jiayang::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 the route goes in its frameworks, so that a check for people never sees 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 byJIAYANG_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
Public paths and webhooks lists the token’s claims.
What the platform sets
Section titled “What the platform sets”The platform gives every app three variables:
| Variable | Value |
|---|---|
JIAYANG_APP_ID |
Your app’s id, the only audience the SDK accepts |
JIAYANG_IDENTITY_ISSUER |
https://edge.jiayang.cloud |
JIAYANG_JWKS_URL |
https://edge.jiayang.cloud/.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: jiayang env set refuses any name starting with JIAYANG_.
Your app can always fetch the key set. It is the one platform address an app’s outbound requests may reach.
Why a request with only X-Jiayang-Email is refused
Section titled “Why a request with only X-Jiayang-Email is refused”The edge also sends X-Jiayang-Email, the caller’s email in plain text. It is there for display, like a log line or a greeting. It proves nothing.
The edge deletes every X-Jiayang-* header the client sent, but your app can’t tell from a header alone that this happened. A request reaching it some other way, or on a public path, can carry any header it likes. How access works has the full reasoning.
So the SDKs read only the signed token. A request with X-Jiayang-Email and no valid token gets 401, like a request with nothing.
Without an SDK
Section titled “Without an SDK”If your language has no SDK, verify the token yourself with a JWT library:
- Read
X-Jiayang-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
JIAYANG_JWKS_URL, and pick the key by the token’skid. - Check the signature,
issagainstJIAYANG_IDENTITY_ISSUER,audagainstJIAYANG_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 JIAYANG_APP_ID. Then check that kind is webhook and provider is the one the route takes. Never accept a webhook’s token where you expect a person, or a person’s where you expect a webhook: the audience keeps them apart, so check it exactly.
Never authorize on a header alone.
Try it on your machine
Section titled “Try it on your machine”The SDKs have no development mode, and there is no setting that skips the signature check. To run your app locally with real tokens, use jiayang dev.