Skip to content

Sign in to a subscription

A subscription backend takes a short-lived token, not an API key. @providerkit/core/auth gets that token and renews it. It uses only fetch, so it runs in a browser extension, a worker or a server.

Once you have signed a person in, getting a token is one call:

const { accessToken } = await token.getToken(); // a token that works now

token comes from tokenSource, below. If the stored token has expired, this call renews it first.

To sign in the first time, your app supplies the two things a package can’t do: opening a browser page and catching a redirect. It also decides where tokens are stored.

import { createAuthFlow, tokenSource, withToken, type AuthHost } from "@providerkit/core/auth";
import { createPresetProvider } from "@providerkit/core";
const host: AuthHost = {
appName: "MyApp/1.0", // sent to Copilot and xAI to say who is signing in
openUrl: (url) => openInBrowser(url),
captureRedirect: ({ authorizeUrl, redirectUri, state, signal }) =>
openAndWaitForCode(authorizeUrl, redirectUri, state, signal), // resolves to the `code`
};
const flow = createAuthFlow("github-copilot", host);
// Once: show the code, wait for the user to approve it, keep the result.
const credential = await flow.signIn(signal, ({ url, userCode }) => showCode(userCode, url));
await save(credential);
// Every call: a token that works now.
const token = tokenSource({ load, save, refresh: flow.refresh });
const provider = withToken(token, (auth) => createPresetProvider("github-copilot", auth));

withToken gets a token for each call and hands it to the preset as apiKey, along with the host the sign-in named (baseUrl) and ChatGPT’s ChatGPT-Account-Id header. If the server answers 401 before any output, it renews the token and sends the call once more. A second 401 is thrown as an auth error. To add headers of your own, merge them with auth.headers instead of replacing them.

withToken builds a new provider for every call. The chatgpt preset sends a session id, and each new provider makes up its own, so the backend sees every call as a new session and its prompt cache misses. Pick one id and pass it each time: createPresetProvider("chatgpt", { ...auth, instanceSessionId }).

createAuthFlow takes chatgpt, kimi-plan, grok, github-copilot, meta or openrouter. ChatGPT and OpenRouter send the user through a redirect, so they call your captureRedirect. The others show a code and call your openUrl with the approval page.

  • Two calls at the same moment refresh once, and the new token is saved before anyone uses it.
  • If the server rejects the refresh, it reloads what is stored. When another tab or process saved a newer token in the meantime, it uses that one. Otherwise it throws.
  • A network failure keeps the credential and rethrows the network error. The user is not told to sign in again because their wifi dropped.
  • A server can refuse a token before it expires. Pass that token as getToken({ rejected: accessToken }) and it is renewed anyway. withToken does this for you.
  • Each request to a sign-in server gives up after 30 seconds, so a server that never answers can’t hold every call that waits on the refresh. Set timeoutMs on the host to change it. A timeout keeps the credential, like a network failure.

Read the stored credential in load every time. These tokens rotate when they are used, and a copy you kept in memory may hold one that is already spent.

One case is not covered: two contexts that both lose a refresh before either saves. Closing it needs a lock on your storage.

A sign-in that ends without a credential throws SignInError with a reason of denied, expired or cancelled. The rest are ProviderErrors with a code:

Code What happened
token_refused The sign-in server said no. status and body say why.
token_incomplete Its reply had no token or no expiry.
device_response_invalid Its reply had no code, or no http(s) approval page.
setup_required Meta account without Muse set up. The message has the link.
refresh_dead The refresh was rejected, or nobody is signed in. Sign in again.

Show your own text for each code. The package’s messages are English and meant for logs.

Copilot’s device sign-in ends in a GitHub token that the Copilot API doesn’t take. The flow trades it for a Copilot token that lasts about 25 minutes. Trading again is the refresh, so getToken handles it. The credential also carries baseUrl, because each plan has its own host. withToken passes it to the preset, as above.

The preset sends X-Initiator: user when the last message is the user’s and agent after a tool result. GitHub bills one premium request per user turn, so this decides what the run costs. To name your app in Editor-Version, pass headers: copilotHeaders("MyApp/1.0", "user") too. GitHub Enterprise Server isn’t supported: its endpoints live on the tenant’s own domain.