Skip to content

TokenGate

Access control on Smart Engines sits at two distinct boundaries: where you sign in as your app, and where your customers sign in to it. We call them the two faces of TokenGate. Getting this straight saves you from the most common confusion on the platform: “wait, do I need an HSUITE NFT or does my customer?” You need an active subscription, whose proof is an NFT on XRPL. Your customers don’t.


flowchart TB
    subgraph FaceA["Face A — you → the platform (platform-fixed)"]
      You["You, the builder"] -->|sign a challenge with your wallet| Host["Platform sign-in"]
      Host -->|app ACTIVE + subscription active? yes → session| You
    end
    subgraph FaceB["Face B — customer → your Smart App (you configure)"]
      Cust["Your customer"] -->|sign a challenge with their wallet| Login["Customer sign-in for your app"]
      Login -->|chain accepted + signature valid? yes → customer session| Cust
    end

The difference is which identity is being checked and who controls the configuration.


Face A — you, authenticating to the validator

Section titled “Face A — you, authenticating to the validator”

When you (the builder, through the SDK or the CLI) sign in as your app, you go through Face A. It is platform-fixed, not configurable Platform-fixed:

  • You sign a challenge with your wallet. The CLI uses the XRPL wallet in your env file.
  • The app must be ACTIVE with an active subscription. An app becomes ACTIVE once its subscription NFT is minted on XRPL (see Anatomy & lifecycle). Otherwise no session is issued: the sign-in fails with 401 Invalid subscription. There is no free tier.
  • The session can’t outlive the subscription. Its lifetime is capped at the subscription’s expiry.

The HSUITE token and the subscription NFTs are on XRPL. You don’t write config for Face A; you subscribe with hsuite subscribe.


Face B — your customers, authenticating to your app

Section titled “Face B — your customers, authenticating to your app”

When your customers sign in to your Smart App, that’s Face B — and you configure it You configure, per app, in your app config. Your customers do not need an HSUITE NFT.

Customers sign in with the SDK’s customer-session client (CustomerSessionClient, also at @hsuite/smart-engines-sdk/customer-session): the host issues a challenge, the customer’s wallet signs it, the host verifies the signature and returns a customer session. Your app then forwards that session on each call it makes for the customer (see customer scope below).

The Face-B fields of the app config, and what each does today:

Field Today
acceptedLoginChains Enforced at sign-in. The chains your customers may sign in from. A challenge for any other chain is refused with 400. If the platform can’t read your config at that moment, sign-in falls back to the platform’s own chain list. The CLI sets ['xrpl'].
acceptedPaymentTokens Stored with your config, not enforced at sign-in.
subscriptionNftChain, subscriptionNftTokenId Stored with your config, not enforced at sign-in.
{
appId: 'my-app',
// Which chains your customers may sign in from. At least one.
acceptedLoginChains: ['xrpl'],
// Stored with the config; not enforced at sign-in.
acceptedPaymentTokens: [],
}

Face A Face B
Who is authenticating You (builder) Your customers
Boundary You → the platform Customer → your Smart App
Chain Your wallet’s chain (XRPL with the CLI) The chains in your acceptedLoginChains
What it checks Wallet signature + ACTIVE app with an active subscription Wallet signature + an accepted chain
Configured by Platform You, in your app config

TokenGate decides who may sign in to your app. Customer scope is a separate, complementary setting that decides what each signed-in customer may touch once inside — the isolation boundary between one of your customers and the next. It’s a single field on the same app config, customerScope:

{
appId: 'my-app',
acceptedLoginChains: ['xrpl'],
// Resource isolation between YOUR customers. 'strict' is the default for
// every app — you don't have to write it.
customerScope: 'strict',
}

'strict' is the default for every app (least privilege). Under strict scope, every customer-reachable BaaS route — across the database, storage, messaging, entities and agents data planes — must carry the identity of the customer it acts for. Your app forwards that identity on each call: either the customer’s session token in X-Customer-Session-Token (your app acting on the customer’s behalf, keeping its own app session), or a delegated customer token in X-Customer-Session-Claim. A customer-reachable call that arrives without a customer identity is refused with 403 — it can never silently run with tenant-wide authority. Which header counts depends on what the route validates: the database, storage, messaging and receipts routes validate either one; the agent create and agent owner routes validate only X-Customer-Session-Claim (which the SDK’s forCustomer sends there); the entity value-movement routes validate neither, so only a customer’s own session names a customer there. A customer header the route would not validate is refused with 403 (CUSTOMER_IDENTITY_NOT_VALIDATED_ON_ROUTE) instead of being admitted and ignored. The net guarantee: a customer can only ever see and act on its own resources.

The SDK does the forwarding for you: client.forCustomer(customerToken) returns a client whose every call carries that customer.

Setting Behavior Who gets it
'strict' Default Every customer-reachable route requires a forwarded customer identity; each customer is confined to its own resources. Identity-less customer call → 403. The default: every app, including any whose stored config has no customerScope field.
'permissive' The app acts as itself — tenant-wide access across every object, except on the entities and agents planes, where it reaches only what it owns itself. Only an app that explicitly set it (hsuite customer-scope permissive). Opt in only if you truly want tenant-wide behavior: an app that forwards no customer identity serves all of its customers one shared pool under it. hsuite deploy warns while it is set.

Tenant-administration work your app performs acting as itself — dropping a collection, reading state transitions or usage stats — is exempt: those routes are app-only by definition and are gated by a stronger app-owner check, so strict scope doesn’t get in their way.


A subscription pass is an NFT, and holding it is not the same as being current. Nothing ever burns an expired subscription NFT: it stays in the wallet indefinitely. So a check that only asked “does the wallet hold the pass?” would let a single one-time payment unlock an app forever.

The platform therefore checks expiry, not just ownership. When a wallet’s subscription to an app is resolved — for example through the marketplace’s GET /api/v3/baas/marketplace/apps/:appId/subscription hand-off (Anatomy & lifecycle) — the answer is active: true only when the wallet holds the pass and its expiresAt is still in the future.

An expired pass reports active: false, so the caller falls through to the app’s own renew or subscribe flow. The same holds for your own subscription at Face A: once it expires, your app’s sessions end and new ones are refused until you renew.