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({ ..., sponsor: false }) | required | not fetched | user |
The sponsor flag is an explicit per-call decision — setting it to
false skips the extension-token fetch entirely, saving one HTTP round
trip when the user is paying.
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 to generate a JWT signing key, then copy these values into your server environment:
| Variable | Description |
|---|---|
RHINESTONE_JWT_PRIVATE_KEY | JSON-encoded JWK (EC P-256 / ES256 recommended, RSA accepted) |
RHINESTONE_INTEGRATOR_ID | Your integrator handle |
RHINESTONE_PROJECT_ID | Project ID the app belongs to |
RHINESTONE_APP_ID | App ID used as the JWT app_id claim |
RHINESTONE_KEY_ID | Key ID registered with the orchestrator (kid header) |
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:
| 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.
Other frameworks
The factory returns plain async functions, so it works with any HTTP framework. Express example:
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) });
});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",
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 (sponsor: false)
client.sendIntent({
targetChain: 84532,
calls: [{ to: mUSD, data: transfer(0x180b..., 100000) }],
tokenRequests: [{ token: mUSD, amount: 100000 }],
sourceChainId: 84532,
sponsor: false,
})