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
| Mode | SDK call | App JWT | Extension token | Who pays |
|---|---|---|---|---|
| Sponsored (default) | sendIntent({ ... }) | required | required | app |
| User-paid | sendIntent({ ..., sponsorshipMode: "disabled" }) | required | not fetched | user |
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.
| Variable | Description |
|---|---|
RHINESTONE_JWT_PRIVATE_KEY | The downloaded JWK, minified to one line (EC P-256 / ES256 recommended, RSA accepted) |
RHINESTONE_INTEGRATOR_ID | Integrator ID you set when registering the key (JWT iss) |
RHINESTONE_PROJECT_ID | Project ID, from the dashboard project page (JWT sub) |
RHINESTONE_APP_ID | A label you pick — prod, staging, one per deployment (JWT app_id) |
RHINESTONE_KEY_ID | Key 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-06Next.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:
| Layer | What it stops | Covers 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.
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",
})