Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

Fee Sponsorship

Every user-initiated intent authenticates with the calling app's JWT. The app independently chooses, per intent, whether to sponsor (pay the gas/bridge fees) or to let the user pay from their source assets.

Sponsored vs. user-paid

ModeSDK callApp JWTExtension tokenWho pays
Sponsored (default)sendIntent({ ... })requiredrequiredapp
User-paidsendIntent({ ..., sponsorshipMode: "disabled" })requirednot fetcheduser

sponsorshipMode is an explicit per-call policy. required is the default and fails closed. preferred opts into a newly reviewed self-funded quote only when sponsorship fails. disabled requests self-funding directly and skips the grant.

Set up sponsorship on the server

The SDK client signs every intent with short-lived JWTs minted by your backend. You expose two endpoints; the SDK calls them same-origin with the user's session cookie attached:

  • Access token — identifies your app to the orchestrator. Minted once and reused; expires after 1 hour.
  • Extension token — scoped to a single intent's digest, authorizing that exact operation. Minted per intent; expires after 5 minutes.

Short expiries mean a leaked token has a small blast radius — but it's no substitute for guarding the endpoints (see the warning below).

Environment variables

Create a Rhinestone app in the Rhinestone dashboard, then register a JWT signing key under API keys → JWT keys. The keypair is generated in your browser; the private half downloads as <key-id>.jwk.json.

VariableDescription
RHINESTONE_JWT_PRIVATE_KEYThe downloaded JWK, minified to one line (EC P-256 / ES256 recommended, RSA accepted)
RHINESTONE_INTEGRATOR_IDIntegrator ID you set when registering the key (JWT iss)
RHINESTONE_PROJECT_IDProject ID, from the dashboard project page (JWT sub)
RHINESTONE_APP_IDA label you pickprod, staging, one per deployment (JWT app_id)
RHINESTONE_KEY_IDKey ID you set when registering the key (kid header)

<key-id>.jwk.json is pretty-printed across several lines, but an env var holds one — minify it, single-quoted:

printf "RHINESTONE_JWT_PRIVATE_KEY='%s'\n" "$(jq -c . prod-2026-06.jwk.json)" >> .env
# .env
RHINESTONE_JWT_PRIVATE_KEY='{"crv":"P-256","d":"uUXnoBer-9yC2sC77Yua21EZnjSGY-X9Q1-d0Ubrn1M","ext":true,"key_ops":["sign"],"kty":"EC","x":"YbfueCnHp97cMIi98g9wQG6Z_6ftBBCHSsrUMw5KBvc","y":"pKyNjQtZ6jO1DIQ2SUCesJABKDmgE95U3pqg2I0-0YU"}'
RHINESTONE_INTEGRATOR_ID=acme
RHINESTONE_PROJECT_ID=cmd7k2a0h0001x8n4v2q9wbz3
RHINESTONE_APP_ID=prod
RHINESTONE_KEY_ID=prod-2026-06

Next.js App Router

// app/api/sponsorship/access-token/route.ts
import { NextResponse } from "next/server";
import { createSponsorshipSigner } from "@rhinestone/1auth/server";
import { getSession } from "@/lib/session"; // your auth
 
const signer = createSponsorshipSigner();
 
export async function GET() {
  const session = await getSession();
  if (!session.userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  return NextResponse.json({ token: await signer.accessToken() });
}
// app/api/sponsorship/extension-token/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createSponsorshipSigner } from "@rhinestone/1auth/server";
import { getSession } from "@/lib/session";
 
// `shouldSponsor` runs server-side *before* the grant is signed, so it
// protects you against every caller — including scripted requests that
// ignore CORS. Scope it as tightly as your product allows.
const signer = createSponsorshipSigner({
  shouldSponsor: {
    // Only chains you actually operate on.
    chain: ({ id }) => SUPPORTED_CHAIN_IDS.has(id),
    // Only your own contracts — stops an attacker from billing you for
    // gas on arbitrary (e.g. gas-heavy) contracts.
    calls: (calls) => calls.every((c) => ALLOWED_CONTRACTS.has(c.to.toLowerCase())),
  },
});
 
export async function POST(req: NextRequest) {
  const session = await getSession();
  if (!session.userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
 
  const { intentOp } = await req.json();
  try {
    return NextResponse.json({ token: await signer.extensionToken(intentOp) });
  } catch (err) {
    // `shouldSponsor` rejected this intent — expected, not a server fault.
    if (err instanceof Error && err.name === "SponsorshipDeniedError") {
      return NextResponse.json({ error: "not sponsorable" }, { status: 403 });
    }
    throw err;
  }
}

createSponsorshipSigner() reads all five env vars automatically. Pass { credentials: { ... } } to override them, or { shouldSponsor } to filter which intents you'll sponsor.

Securing the sponsorship endpoints

These endpoints mint tokens billed against your Rhinestone project — treat a sponsorship grant as money, not as an identity. The extension token is the grant that actually says "the app pays this intent's gas" (the access token only identifies your app). Because the SDK calls these endpoints from the browser, anyone who can reach them can request a grant unless you stop them.

No single control is enough — defend in layers, because each covers a different class of caller:

LayerWhat it stopsCovers scripted (non-browser) callers?
1. shouldSponsor policy (chain / contract / value caps)Billing you for intents you never meant to sponsor✅ Yes — enforced before signing, regardless of caller
2. Session auth (httpOnly + Secure + SameSite cookie)Anonymous & cross-site browser abuse❌ No — cookies/CORS only constrain browsers
3. Intent binding (built in)Replaying a leaked grant against a different intent✅ Yes — the 5-min token is pinned to the intent digest
4. Budget cap + rate limits (Rhinestone dashboard)Bounding total damage if anything else leaks✅ Yes

The key takeaway: a session cookie is necessary but not sufficient. It stops opportunistic browser abuse, but a curl attacker ignores cookies and CORS entirely — so the shouldSponsor policy is your only protection against them.

Bind the grant to the logged-in user

shouldSponsor.account receives the smart-account address the intent will execute from. Combined with your session, this stops one authenticated user from minting grants for other accounts:

export async function POST(req: NextRequest) {
  const session = await getSession();
  if (!session.userId) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
 
  // Resolve the account this user is allowed to spend for, then bind the
  // grant to it so a logged-in user can't sponsor someone else's intents.
  const usersAccount = await getOneAuthAddressForUser(session.userId);
  const signer = createSponsorshipSigner({
    shouldSponsor: {
      chain: ({ id }) => SUPPORTED_CHAIN_IDS.has(id),
      account: (address) => address.toLowerCase() === usersAccount.toLowerCase(),
    },
  });
 
  const { intentOp } = await req.json();
  return NextResponse.json({ token: await signer.extensionToken(intentOp) });
}

verifyOneAuthAccount() (also exported from @rhinestone/1auth/server) lets you confirm server-side that an address really belongs to a known 1auth account before you bind to it.

Any HTTP server

The factory returns plain async functions, so it works with any HTTP framework — or with a dev server's middleware, when your app is a static SPA with no backend of its own. Both routes are plain JSON: GET{ token }, POST { intentOp }{ token }.

The signer itself is runtime-agnostic (jose + WebCrypto, no node: imports), so Workers, Deno, and Bun work too — but there is no process.env to read there, so pass credentials explicitly instead of relying on the default lookup.

Express
import express from "express";
import { createSponsorshipSigner } from "@rhinestone/1auth/server";
 
const signer = createSponsorshipSigner();
const app = express();
 
app.get("/api/sponsorship/access-token", requireSession, async (_req, res) => {
  res.json({ token: await signer.accessToken() });
});
 
app.post("/api/sponsorship/extension-token", requireSession, async (req, res) => {
  res.json({ token: await signer.extensionToken(req.body.intentOp) });
});

The Vite plugin is a local stand-in for the backend you ship, not a substitute for it: it only runs under vite dev (vite build emits static files with no server attached), and it has neither a session check nor a shouldSponsor filter. Both are fine to omit on a loopback dev server and are the first things to add anywhere else.

Advanced: custom claims

If you need a custom audience or additional claims, drop down to the raw createJwtSigner from @rhinestone/sdk/jwt-server, also re-exported from @rhinestone/1auth/server.

Configure sponsorship on the client

Point the SDK at the two endpoints you just created:

import { OneAuthClient } from "@rhinestone/1auth";
 
const client = new OneAuthClient({
  providerUrl: "https://passkey.1auth.app",
  clientId: "my-app",
  sponsorship: {
    accessTokenUrl: "/api/sponsorship/access-token",
    extensionTokenUrl: "/api/sponsorship/extension-token",
  },
});

sponsorship is required whenever the client submits user intents — there is no anonymous fallback. A client without sponsorship configured will reject sendIntent calls with MISSING_APP_CREDENTIALS.

Demo

User-paid intent (sponsorshipMode: disabled)

client.sendIntent({
  targetChain: 84532,
  calls: [{ to: mUSD, data: transfer(0x180b..., 100000) }],
  tokenRequests: [{ token: mUSD, amount: 100000 }],
  sourceChainId: 84532,
  sponsorshipMode: "disabled",
})