Server-side gating

Enforce entitlements on your backend, where users cannot tamper with them.

Beta

The Web SDK is in beta and its API may change between releases.

Why gate on the server

The browser SDK keeps subscription state locally so your UI can react instantly. That state is readable and writable from DevTools. Client-side checks control what the browser shows; they do not control access. Anything that costs money or exposes real data must be checked on the server.

Two packages do that. They differ in whether the check needs a network call.

PackageHow it checksUse when
@superwall/serverCalls Superwall's /entitlements endpoint, with cachingYou want the simplest correct thing
@superwall/verifyVerifies a signed token against bundled keys, no network on the steady-state pathYou want per-request checks with no round trip

@superwall/server

Create an instance, tell it how to find the current user, and gate routes with requires.

import { Superwall } from "@superwall/server";

const sw = Superwall<express.Request>({
  apiKey: process.env.SUPERWALL_API_KEY!,
  userId: (req) => req.session?.userId ?? null,
});

app.get("/api/export", sw.requires("pro"), exportHandler);

The middleware is Connect-style and works with Express and anything with the same shape. Pass your request type as the generic parameter, or req infers as unknown.

The userId extractor is the trust boundary. Read it from an authenticated session. Reading it from the request body, a query string, or an unverified header lets any caller claim to be any user.

Describing what is required

sw.requires("pro");                      // one entitlement
sw.requires(["pro", "team"]);            // all of them
sw.requires({ all: ["pro", "team"] });   // the same, explicitly
sw.requires({ any: ["pro", "team"] });   // at least one

Options

OptionPurpose
apiKeyYour Superwall API key. Keep it in the environment, not in source.
userIdHow to pull the user out of a request. Overridable per requires call.
cacheCache adapter and TTL for entitlement lookups. Defaults to 60 seconds, in-memory, single-process.
environmentNetwork environment. Defaults to "release".
timeoutMsTimeout for calls to Superwall. Defaults to 5000.
onRequestCalled after a completed check. Useful for tracing.

requires also takes allowAnonymous, which defaults to false. A request with no resolvable user is rejected. Setting it to true passes those requests through.

What a rejection looks like

By default a blocked request gets 403 with { error: "entitlement_required", entitlement }. Pass onUnauthorized to requires to customize it. It receives the missing entitlements and a reason of "no_user_id" or "not_entitled".

If Superwall cannot be reached, the middleware calls next(err) rather than rejecting with a 403, so it surfaces through your framework's error handler, typically as a 500. It fails closed either way.

Cache staleness

Entitlement lookups are cached for 60 seconds by default, so a revocation can take that long to take effect. Call sw.invalidate(userId) or sw.invalidateAll() when you need it immediately. The default cache is in-memory and single-process. Supply a Redis or KV adapter for multi-instance deployments.

Checking without middleware

if (await sw.userHas(userId, "pro")) {
  // …
}

The user ID comes first. Both parameters are strings, so reversing them still typechecks. It looks up a user named "pro", finds nothing, and denies everyone.

@superwall/verify

The client holds a Superwall-signed token describing its entitlements. Send it to your backend and verify the signature. A valid signature proves Superwall issued exactly those entitlements, with no call to Superwall needed.

Read the token in the browser:

sw.entitlementsToken.value;            // reactive
sw.purchases.getEntitlementsToken();   // snapshot

Verify it on the server. verifyEntitlements throws on any failure and never returns a partial result, so the call belongs in a try/catch:

import { verifyEntitlements, VerifyError } from "@superwall/verify";

try {
  const result = await verifyEntitlements(tokenFromClient, {
    publicApiKey: process.env.SUPERWALL_PUBLIC_API_KEY!,
  });

  if (!result.entitlements.some((e) => e.identifier === "pro")) {
    return res.status(402).end();
  }
} catch (err) {
  if (err instanceof VerifyError) return res.status(401).end();
  throw err;
}

The token's entitlements use identifier, not id. This is a different type from the client SDK's Entitlement, which uses id. See Tracking subscription state.

There are convenience helpers for the common shapes, both taking the token first:

import { userHasEntitlement, userHasAnyEntitlement } from "@superwall/verify";

Errors

Verification failures are typed. Catch VerifyError for any of them, or narrow to InvalidSignatureError, ExpiredError, AudienceMismatchError, MalformedTokenError, or KeyUnavailableError.

Expiry

A token carries its own expiry, and each entitlement inside it carries an expiresAt of its own. An entitlement can lapse while the token wrapping it is still valid.

The convenience helpers account for this, so prefer them over iterating the array yourself:

if (!(await userHasEntitlement(tokenFromClient, "pro", { publicApiKey }))) {
  return res.status(402).end();
}

If you read result.entitlements directly, check expiresAt. It is epoch milliseconds, and null means lifetime.

Run verification on the server. A check that runs in the browser can be bypassed in the browser.

How is this guide?

On this page