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.
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/callbackin dev,https://yourapp.com/auth/quota/callbackin prod. Exact match — same scheme, host, port, path. - Allowed scopes: the identity set plus credits.
openid profile email credits.read credits.spendcovers 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.
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-string03 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);
});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.subfor production. Refresh tokens are long-lived bearer credentials. - Encrypt at rest. Treat
refresh_tokenlike a password. - Cookies.
Secure+HttpOnly+SameSite=Laxin production. Forhttp://localhostdev, leaveSecureoff 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.
createRemoteJWKSethandles this; don't cache the keys yourself. - OIDC issuer in production. Quota's discovery doc derives
issuerfromALLOWED_ORIGINS, which in production may be the marketing-site origin rather than the API origin serving discovery. If your deployment hits this asymmetry, setOIDC_ISSUER=https://api.usequota.aiin 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.subfrom Quota and add a join table for(provider, external_id)pairs.
Troubleshooting
| Error | Where | Fix |
|---|---|---|
invalid_scope | /oauth/authorize | Scope not in app's allowed_scopes. Add it at create time (step 01) or via PATCH /developers/apps/:id. |
invalid_redirect_uri | /oauth/authorize | Exact-match the redirect URI registered on the app. Same scheme, host, port, path. |
state_mismatch | /auth/quota/callback | Session 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 failure | jwtVerify | JWKS 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 mismatch | jwtVerify | The 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/completions | Token missing credits.read (balance) or credits.spend (chat). Re-authorize with both. |
402 insufficient_credits | /v1/chat/completions | Wallet empty. Render <QuotaBuyCredits /> from the Next.js SDK or trigger your own Stripe Checkout. |