Skip to content

OAuth for end-user credentials

Let your users pay for their own model calls with their own Multigrid credit, instead of you paying for all of it.

7 min read·PKCE required

What this is for

If you ship a product that calls models, you have two options for paying for the tokens. Either you buy them and price your product to cover it, or your users bring their own. This flow is the second one: you send a user to Multigrid, they approve a spending limit, and your app receives an API key that draws on their balance. You never see their card, and their usage never appears on your invoice.

It is an ordinary OAuth 2.0 authorization-code flow with PKCE. If you have integrated “Sign in with” anything, this is the same three steps — the only difference is that what comes back at the end is an API key rather than an identity.

There is no client secret
Every client here is a public client: a CLI, a desktop app, a single-page app. A secret shipped inside one is a published string. PKCE replaces it, and it is required rather than optional — code_challenge is not a parameter you can leave off, and plain is refused.

Register your app

Registration needs a provisioning key, so every app on the consent screen belongs to an account. Create one under API keys with Manage API keys selected.

bash
curl https://multigrid.ai/api/oauth/clients \
  -H "Authorization: Bearer $MULTIGRID_PROVISIONING_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Notes",
    "description": "Summarises your notes on your own device.",
    "website_url": "https://acme.example",
    "redirect_uris": ["https://acme.example/oauth/callback", "http://localhost:8787/callback"]
  }'

# → 201 { "client_id": "oac_…", "token_endpoint_auth_method": "none", … }

The client_id that comes back is public and belongs in your source. Register every callback you will ever use, including the localhost one you develop against — matching is exact, so a URI you did not register is a URI you cannot use.

The whole flow, in one file

No dependencies, no build step. Save it, run it with node connect.mjs, and open the URL it prints. It uses a first-party client we keep registered for exactly this, so it works before you have registered anything of your own.

connect.mjs
import { createServer } from "node:http";
import { createHash, randomBytes } from "node:crypto";

const CLIENT_ID = "oac_multigrid_docs_example";
const REDIRECT_URI = "http://localhost:8787/callback";

// The verifier never leaves this process. Only its hash goes in the URL, so a
// code stolen out of the redirect is useless without this value.
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
const state = randomBytes(16).toString("base64url");

const authorize = new URL("https://multigrid.ai/oauth/authorize");
authorize.search = new URLSearchParams({
  response_type: "code",
  client_id: CLIENT_ID,
  redirect_uri: REDIRECT_URI,
  code_challenge: challenge,
  code_challenge_method: "S256",
  state,
}).toString();

console.log("Open this in your browser:\n" + authorize.toString());

createServer(async (req, res) => {
  const url = new URL(req.url, REDIRECT_URI);
  if (url.pathname !== "/callback") return res.end();

  const error = url.searchParams.get("error");
  if (error) {
    console.error(error, url.searchParams.get("error_description"));
    res.end("Authorization refused. You can close this tab.");
    return process.exit(1);
  }

  // Not optional. Without it you will happily exchange a code somebody else
  // dropped into your callback.
  if (url.searchParams.get("state") !== state) {
    res.end("state mismatch — discarded");
    return process.exit(1);
  }

  const response = await fetch("https://multigrid.ai/api/oauth/token", {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: url.searchParams.get("code"),
      client_id: CLIENT_ID,
      redirect_uri: REDIRECT_URI,
      code_verifier: verifier,
    }),
  });

  const token = await response.json();
  if (!response.ok) {
    console.error(token.error, token.error_description);
    res.end("Exchange failed. You can close this tab.");
    return process.exit(1);
  }

  // token.access_token is a real Multigrid API key, on the user's account.
  // Store it now — this response is the only place it ever appears.
  console.log("connected:", token.key_prefix, "cap:", token.spend_limit_usd);
  res.end("Connected. You can close this tab.");
  process.exit(0);
}).listen(8787);

1 · Send the user to us

Generate a verifier — 43 to 128 characters from A-Z a-z 0-9 - . _ ~ — hash it, and put the hash in the URL. Keep the verifier in memory. Then open https://multigrid.ai/oauth/authorize in the user’s browser.

ParameterRequiredDescription
response_typeRequiredAlways `code`. The implicit flow is not implemented.
client_idRequiredFrom registration. Public — it ships in your app.
redirect_uriRequiredMust match one of your registered URIs exactly, character for character.
code_challengeRequiredBase64url SHA-256 of your verifier, unpadded. 43 characters.
code_challenge_methodRequired`S256`. `plain` is refused.
stateOptionalOpaque value echoed back. Use it — it is how you detect a callback you did not start.

The user sees your app’s name, description and website, a plain statement that requests will be charged to their balance, and four spending limits to choose between. A limit is pre-selected; “no cap” is a deliberate choice they have to make.

2 · Receive the code

We send the browser back to your redirect_uri with ?code=…&state=…. If the user pressed Cancel you get ?error=access_denied instead. Compare state against what you sent before you do anything else with the code.

3 · Exchange it for a key

POST to https://multigrid.ai/api/oauth/token, form-encoded or JSON. The endpoint sets Access-Control-Allow-Origin: *, so a browser app can call it directly.

ParameterRequiredDescription
grant_typeRequired`authorization_code`.
codeRequiredThe code from your callback. Valid for 60 seconds, once.
client_idRequiredThe same one the code was issued to.
redirect_uriRequiredThe same one the code was issued for.
code_verifierRequiredThe random string you hashed into `code_challenge`.
200 OK
{
  "access_token": "mg_live_…",
  "token_type": "Bearer",
  "scope": "inference",
  "key_id": "key_…",
  "key_prefix": "mg_live_9f3a",
  "app_name": "Acme Notes",
  "spend_limit_usd": 20
}
This response is the only copy
We store SHA-256 of the key and nothing else, exactly as we do for keys created in the dashboard. There is no endpoint that returns it again, for you or for the user. Persist it in the exchange handler or the user has to authorize again.

There is no expires_in and no refresh token. What you hold is an ordinary inference key with no expiry; it stops working when the user disconnects your app or it reaches its cap, and both of those are answers you get as a 401 or a 402 on your next call. Handle both by starting a fresh authorization.

Using the key

It is a normal Multigrid key with inference scope. Every request it makes is billed to the user’s account and shows up in their activity log under your app’s name.

TypeScript
import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.multigrid.ai/v1",
  apiKey: token.access_token, // the user's key, from the exchange above
});

await client.chat.completions.create({
  model: "multigrid/auto",
  messages: [{ role: "user", content: "Summarise these notes." }],
});

Rules that will bite you

  • Exact redirect URIs. Not a prefix, not a pattern, no normalisation. https://app.example/cb and https://app.example/cb/ are two different URIs. Prefix matching is how authorization codes get handed to whoever found an open redirect on your domain, which is why it is not offered.
  • Sixty seconds. A code only has to survive one round trip from your callback. Exchange it there and then, not on a queue.
  • One use. A second exchange of the same code revokes the key the first one produced and returns invalid_grant. If your callback can fire twice — a retry, a refreshed tab, a prefetch — make it idempotent on your side.
  • One verifier per authorization. Generate a fresh one each time and never log it. It is the only thing standing between an intercepted code and the user’s balance.
  • Loopback http is fine, everything else must be https. A native app catching its callback on http://localhost is expected; plaintext anywhere else puts the code on the wire.

Errors

The token endpoint answers in the RFC 6749 shape — { "error", "error_description" } — not the envelope the rest of the API uses, so any OAuth client library already parses it.

CodeMeaning
invalid_requestA parameter is missing or malformed. `error_description` names it.
invalid_clientUnknown `client_id`, or the application has been disabled.
invalid_grantThe code is unknown, expired, already used, or the verifier does not match. Start a new authorization.
unsupported_grant_type`grant_type` was not `authorization_code`.
access_deniedArrives on your redirect URI, not from the token endpoint: the user pressed Cancel.
An invalid client_id or redirect_uri never redirects
Those two failures render an error page on our side instead of bouncing to the URI you sent. Redirecting to a URI we have not proved belongs to you is precisely the hole this flow exists to close — so during development, a callback that never fires means you should be looking at the browser, not at your server logs.

Your users manage connected apps under API keys → Connected apps, and can revoke yours there at any time.

Report it