Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Fee Sponsorship – 1auth
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({ ..., sponsor: false })requirednot fetcheduser

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:

VariableDescription
RHINESTONE_JWT_PRIVATE_KEYJSON-encoded JWK (EC P-256 / ES256 recommended, RSA accepted)
RHINESTONE_INTEGRATOR_IDYour integrator handle
RHINESTONE_PROJECT_IDProject ID the app belongs to
RHINESTONE_APP_IDApp ID used as the JWT app_id claim
RHINESTONE_KEY_IDKey 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:

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.

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,
})