Quota
/ docs
Dashboard
Docs/Recipes/Sign in with Quota

Sign in with Quota

One round-trip gives your app a verified user identity and a spendable AI credit balance for that user. No users table, no password reset, no payment integration to build.

Is this the right recipe?

You want this if you're building a new app and don't have auth yet. If your app already has users (NextAuth, Clerk, custom), use Connect Quota Wallet to attach Quota credits without rebuilding auth.

01 Register an OAuth app

Open /dashboard/sign-in-with-quota or hit POST /developers/apps. Two things to set:

  • Redirect URI: http://localhost:5280/auth/quota/callback in dev, https://yourapp.com/auth/quota/callback in prod. Exact match — same scheme, host, port, path.
  • Allowed scopes: the identity set plus credits.openid profile email credits.read credits.spend covers signing the user in, reading their balance, and spending on their behalf.
curl -X POST https://api.usequota.ai/developers/apps \
  -H "Authorization: Bearer sess_…" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "My App",
    "redirect_uris": ["http://localhost:5280/auth/quota/callback"],
    "allowed_scopes": [
      "openid", "profile", "email",
      "credits.read", "credits.spend"
    ]
  }'

Each scope unlocks a specific claim or capability. See Scopes for the full vocabulary and the consent-screen copy that ships with each.

redirect_uri must match exactly — and HTTPS in production

Quota does exact-string matching on the redirect URI. The dev example uses http://localhost (both localhost and 127.0.0.1 are accepted for local dev); production URIs must use HTTPS. No wildcards. Register every URI your app might callback to — dev and prod — on the same OAuth client.

02 Set environment variables

Three required vars. Everything else has a sensible default — the callback URL derives from each request, base URL defaults to production, and the OIDC scope set is the recipe default.

# Required
QUOTA_CLIENT_ID=quota_client_…
QUOTA_CLIENT_SECRET=quota_secret_…
SESSION_SECRET=any-random-32-char-string

03 Bootstrap OIDC

Per RFC 8414, the OIDC discovery doc at {QUOTA_BASE_URL}/.well-known/openid-configuration lists the issuer, JWKS URL, and every endpoint. Fetch it once at startup and cache; the Next.js SDK does this for you.

import { createRemoteJWKSet } from "jose";

const r = await fetch(`${process.env.QUOTA_BASE_URL}/.well-known/openid-configuration`);
const doc = await r.json();

export const oidc = {
  issuer: doc.issuer,                                  // verify id_token.iss
  jwks: createRemoteJWKSet(new URL(doc.jwks_uri)),     // verify id_token.signature
  authorize: `${process.env.QUOTA_BASE_URL}/oauth/authorize`,
  token: `${process.env.QUOTA_BASE_URL}/oauth/token`,
  endSession: doc.end_session_endpoint,                // for sign-out
};

04 Kick off the OAuth round-trip

Generate PKCE verifier + challenge, a random state, and a random nonce. Stash all three on the session — you'll verify them at the callback. Redirect to the authorize endpoint with the challenge.

import crypto from "node:crypto";

// Trust the reverse proxy so req.protocol returns "https" in production.
app.set("trust proxy", true);

function callbackUrl(req) {
  return `${req.protocol}://${req.get("host")}/auth/quota/callback`;
}

function pkcePair() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto.createHash("sha256")
    .update(verifier).digest("base64url");
  return { verifier, challenge };
}

const SCOPES = process.env.QUOTA_SCOPES
  || "openid profile email credits.read credits.spend";

app.get("/auth/quota/start", (req, res) => {
  const state = crypto.randomBytes(16).toString("hex");
  const nonce = crypto.randomBytes(16).toString("hex");
  const { verifier, challenge } = pkcePair();
  const redirectUri = callbackUrl(req);
  req.session.oauth = { state, nonce, verifier, redirectUri };

  const u = new URL(oidc.authorize);
  u.searchParams.set("response_type", "code");
  u.searchParams.set("client_id", process.env.QUOTA_CLIENT_ID);
  u.searchParams.set("redirect_uri", redirectUri);
  u.searchParams.set("scope", SCOPES);
  u.searchParams.set("state", state);
  u.searchParams.set("nonce", nonce);
  u.searchParams.set("code_challenge", challenge);
  u.searchParams.set("code_challenge_method", "S256");
  res.redirect(u.toString());
});

05 Handle the callback

Quota redirects back with ?code=…&state=…. Verify the state, exchange the code (sending the PKCE verifier), then verify the returned id_token against the JWKS. Once verified, build your app session from the claims.

import { jwtVerify } from "jose";

app.get("/auth/quota/callback", async (req, res) => {
  const { code, state } = req.query;
  const stash = req.session?.oauth;
  if (!stash || state !== stash.state)
    return res.redirect("/?quota_error=state_mismatch");
  req.session.oauth = undefined;

  // Exchange code + PKCE verifier for tokens.
  const r = await fetch(oidc.token, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: stash.redirectUri,
      client_id: process.env.QUOTA_CLIENT_ID,
      client_secret: process.env.QUOTA_CLIENT_SECRET,
      code_verifier: stash.verifier,
    }),
  });
  if (!r.ok) return res.redirect("/?quota_error=token_exchange_failed");
  const tokens = await r.json();

  // Verify the id_token. jose throws on any failure — never log the raw
  // error to the client; it can include token internals.
  let claims;
  try {
    const { payload } = await jwtVerify(tokens.id_token, oidc.jwks, {
      issuer: oidc.issuer,
      audience: process.env.QUOTA_CLIENT_ID,
    });
    if (payload.nonce !== stash.nonce) throw new Error("nonce mismatch");
    claims = payload;
  } catch (err) {
    return res.redirect("/?quota_error=invalid_id_token");
  }

  // Build the app session from the verified claims.
  req.session.user = {
    sub: claims.sub,                  // your stable user id
    email: claims.email,
    name: claims.name ?? null,
    picture: claims.picture ?? null,
  };
  req.session.quota = {
    access_token: tokens.access_token,
    refresh_token: tokens.refresh_token,
    expires_at: Date.now() + tokens.expires_in * 1000,
    id_token: tokens.id_token,        // needed for end-session
  };

  res.redirect("/");
});

06 Bill the user's wallet

From here it's identical to the wallet-link path: pass the user's access_token as the OpenAI SDK apiKey and Quota debits their wallet, not yours.

import OpenAI from "openai";

app.post("/api/chat", async (req, res) => {
  const token = await getQuotaToken(req);  // refresh if needed
  if (!token) return res.status(401).end();

  const client = new OpenAI({
    apiKey: token,
    baseURL: `${process.env.QUOTA_BASE_URL}/v1`,
  });

  const completion = await client.chat.completions.create({
    model: "gpt-4o-mini",
    messages: req.body.messages,
  });
  res.json(completion);
});
That's the happy path

Steps 01–06 are a complete working integration. Paste the snippets above into a fresh project, set QUOTA_CLIENT_ID and QUOTA_CLIENT_SECRET in .env, run it, and you have Sign in with Quota end-to-end. The rest of this page is production hardening — skip on the first read.


Production hardening

Everything below is needed before you ship to real users, but you can skip it on the first read-through. The Next.js SDK handles most of it (refresh, JWKS rotation, cookie flags) for you; the Express snippets show what to do without the SDK.

Refresh on expiry

Access tokens expire (typically one hour). Before each billed request, check the cached expiry; if it's within 30 seconds of expiring, exchange the refresh token.

async function getQuotaToken(req) {
  const t = req.session?.quota;
  if (!t) return null;
  if (Date.now() < t.expires_at - 30_000) return t.access_token;

  const r = await fetch(oidc.token, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: t.refresh_token,
      client_id: process.env.QUOTA_CLIENT_ID,
      client_secret: process.env.QUOTA_CLIENT_SECRET,
    }),
  });
  if (!r.ok) { req.session.quota = undefined; return null; }
  const fresh = await r.json();
  req.session.quota = {
    ...t,
    access_token: fresh.access_token,
    refresh_token: fresh.refresh_token,
    expires_at: Date.now() + fresh.expires_in * 1000,
  };
  return fresh.access_token;
}

The Next.js SDK handles refresh transparently inside withQuotaAuth and QuotaProvider; no helper needed.

Sign out cleanly

Clearing your local session signs the user out of your app, but they're still signed into Quota — the next click on “Sign in with Quota” will silently restore the session without showing the consent screen. To sign out everywhere (shared-computer flow), redirect through the OIDC end_session_endpoint.

app.post("/auth/quota/logout", (req, res) => {
  const idToken = req.session?.quota?.id_token;
  const postLogout = `${req.protocol}://${req.get("host")}/`;
  req.session = null;

  if (!idToken) return res.json({ logout_url: "/" });
  const u = new URL(oidc.endSession);
  u.searchParams.set("id_token_hint", idToken);
  u.searchParams.set("post_logout_redirect_uri", postLogout);
  res.json({ logout_url: u.toString() });
});

Register the post-logout URL on the app first — PATCH /developers/apps/:id with post_logout_redirect_uris. Quota rejects any URL that isn't on the allowlist.

Production checklist

  • Token storage. Cookie sessions are fine for local dev; move tokens into your database keyed by claims.sub for production. Refresh tokens are long-lived bearer credentials.
  • Encrypt at rest. Treat refresh_token like a password.
  • Cookies. Secure + HttpOnly + SameSite=Lax in production. For http://localhost dev, leave Secure off so the browser keeps the cookie.
  • Single-flight refresh. Concurrent requests racing on refresh will break one. Wrap the refresh call in a per-user lock or in-flight promise.
  • JWKS rotation. Quota rotates signing keys on a schedule. createRemoteJWKSet handles this; don't cache the keys yourself.
  • OIDC issuer in production. Quota's discovery doc derives issuer from ALLOWED_ORIGINS, which in production may be the marketing-site origin rather than the API origin serving discovery. If your deployment hits this asymmetry, set OIDC_ISSUER=https://api.usequota.ai in the Quota server's env so discovery and tokens advertise the same host. Off-the-shelf OIDC clients refuse to bootstrap when they disagree.
  • Account linking. If you later add a second sign-in provider (GitHub, Google), key your user table by claims.sub from Quota and add a join table for (provider, external_id) pairs.

Troubleshooting

ErrorWhereFix
invalid_scope/oauth/authorizeScope not in app's allowed_scopes. Add it at create time (step 01) or via PATCH /developers/apps/:id.
invalid_redirect_uri/oauth/authorizeExact-match the redirect URI registered on the app. Same scheme, host, port, path.
state_mismatch/auth/quota/callbackSession cookie didn't survive the redirect. On http://localhost, don't set secure: true on the cookie — browsers drop Secure cookies on plaintext.
invalid_id_token / signature failurejwtVerifyJWKS rotation. Make sure you're using createRemoteJWKSet (it handles rotation), not a pinned key. If still failing, confirm issuer matches what the token actually carries — see the OIDC issuer note in the Production checklist above.
nonce mismatchjwtVerifyThe nonce in the id_token doesn't match the one you stashed on the session. Usually a session cookie cleared mid-flow or a replay. Discard and re-run sign-in.
403 insufficient_scope/v1/balance, /v1/chat/completionsToken missing credits.read (balance) or credits.spend (chat). Re-authorize with both.
402 insufficient_credits/v1/chat/completionsWallet empty. Render <QuotaBuyCredits /> from the Next.js SDK or trigger your own Stripe Checkout.