Who can use it
Websites and hosted apps
A limited trial for selected commercial partners. You need an OpenAI client ID (oaiapp_…) and a registered callback URL per environment. Join by filling in OpenAI's interest form.
Open-source and local apps
Available now, no approval. Apps register themselves during the user's first sign-in, with no client secret or partner API key. Meant for apps on the user's machine or self-hosted VMs. Paid or remotely hosted apps use the partner path instead.
How it works
Sign in with ChatGPT is the OAuth 2.0 authorization code flow with PKCE, plus OpenID Connect for identity. Using the person's ChatGPT plan for AI requests is an optional extra permission on top of sign-in.
- 01RedirectYour backend creates state, nonce and a PKCE verifier, then sends the user to auth.openai.com.
- 02ConsentThe user signs in to ChatGPT and approves your app's requested scopes.
- 03ExchangeOpenAI redirects back with a code. Your backend exchanges it for tokens and verifies the ID token.
- 04UseStart your own session from the verified sub. With plan-usage scopes, call the Responses API with the access token.
Endpoints and scopes
Load these from the discovery document at https://auth.openai.com/.well-known/openid-configuration rather than hard-coding them.
| Endpoint | URL |
|---|---|
| Issuer | https://auth.openai.com |
| Authorization | https://auth.openai.com/api/accounts/authorize |
| Token | https://auth.openai.com/api/accounts/oauth/token |
| JWKS | https://auth.openai.com/.well-known/jwks.json |
| Revocation | https://auth.openai.com/api/accounts/oauth/revoke |
| User info | https://auth.openai.com/api/accounts/oauth/userinfo |
| Scopes | Grants |
|---|---|
openid profile email | Sign-in: the user's identity (sub), name and email. |
offline_access resource.invoke chatgpt.tokens.use.direct | Using the person's ChatGPT plan for eligible Responses API requests (open-source flow, with resource=https://api.openai.com/v1). offline_access adds a refresh token. |
PKCE is required and only S256 is supported.
Website integration (Next.js)
For partners with a client ID. Two route handlers: one starts sign-in, one handles the callback. The code uses jose to verify the ID token. Your button (“Continue with ChatGPT”) links to /auth/chatgpt.
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
const AUTHORIZE = "https://auth.openai.com/api/accounts/authorize";
const b64url = (bytes: Uint8Array) => Buffer.from(bytes).toString("base64url");
const random = () => b64url(crypto.getRandomValues(new Uint8Array(32)));
export async function GET() {
// Fresh one-time transaction per sign-in; expires after 10 minutes.
const state = random();
const nonce = random();
const verifier = random();
const challenge = b64url(
new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))),
);
(await cookies()).set("siwc_tx", JSON.stringify({ state, nonce, verifier }), {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 600,
path: "/auth/chatgpt",
});
const url = new URL(AUTHORIZE);
url.search = new URLSearchParams({
response_type: "code",
client_id: process.env.OPENAI_CLIENT_ID!, // oaiapp_...
redirect_uri: process.env.OPENAI_REDIRECT_URI!, // must match the registered callback exactly
scope: "openid profile email",
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString();
redirect(url.toString());
}import { cookies } from "next/headers";
import { createRemoteJWKSet, jwtVerify } from "jose";
const TOKEN = "https://auth.openai.com/api/accounts/oauth/token";
const JWKS = createRemoteJWKSet(new URL("https://auth.openai.com/.well-known/jwks.json"));
export async function GET(req: Request) {
const params = new URL(req.url).searchParams;
const jar = await cookies();
const tx = jar.get("siwc_tx")?.value;
jar.delete({ name: "siwc_tx", path: "/auth/chatgpt" }); // consume the transaction once
if (!tx || params.get("error")) return new Response("Sign-in failed", { status: 400 });
const { state, nonce, verifier } = JSON.parse(tx);
if (params.get("state") !== state) return new Response("Invalid state", { status: 400 });
const res = await fetch(TOKEN, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
// Confidential client: HTTP Basic auth. Never put the secret in the form body.
// (Public clients omit this header.)
authorization:
"Basic " + btoa(`${process.env.OPENAI_CLIENT_ID}:${process.env.OPENAI_CLIENT_SECRET}`),
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: params.get("code")!,
redirect_uri: process.env.OPENAI_REDIRECT_URI!,
client_id: process.env.OPENAI_CLIENT_ID!,
code_verifier: verifier,
}),
});
if (!res.ok) return new Response("Token exchange failed", { status: 502 });
const { id_token } = await res.json();
// Verify signature (JWKS), issuer, audience and expiry, then the nonce.
const { payload } = await jwtVerify(id_token, JWKS, {
issuer: "https://auth.openai.com",
audience: process.env.OPENAI_CLIENT_ID!,
});
if (payload.nonce !== nonce) return new Response("Invalid nonce", { status: 400 });
// payload.sub is the stable ChatGPT identity. Link or create your user,
// then issue your own HttpOnly, Secure, SameSite=Lax session cookie.
// linkOrCreateUser and startSession are your app's own functions.
const user = await linkOrCreateUser({ sub: payload.sub!, email: payload.email as string | undefined });
await startSession(user);
return Response.redirect(new URL("/", req.url));
}Before you ship, check that:
- The callback URL matches the registered one exactly.
- Every sign-in gets fresh state, PKCE verifier and nonce.
- Invalid or replayed transactions are rejected.
- A confidential client fails without a valid secret.
- Invalid ID tokens (signature, issuer, audience, expiry, nonce) are rejected.
- Temporary state is cleared after success and failure.
- OpenAI tokens never reach browser JavaScript.
Open-source and local apps
Open-source apps register dynamically on the user's first sign-in: use client_id=dynamic_agent_client the first time, then reuse the client ID OpenAI issues. Each host (laptop, VM) needs its own stable ext_agent_host_id. The redirect is a loopback URL on 127.0.0.1 with path /callback.
import { createHash, randomBytes, randomUUID } from "node:crypto";
const b64url = (buf: Buffer) => buf.toString("base64url");
const verifier = b64url(randomBytes(32));
const state = b64url(randomBytes(16));
const nonce = b64url(randomBytes(16));
const port = 1455; // any free port; path must be /callback on 127.0.0.1
// Generate once per host (laptop, VM) and persist it. Reuse it on every sign-in.
const hostId = loadHostId() ?? saveHostId(`urn:uuid:${randomUUID()}`);
const url = new URL("https://auth.openai.com/api/accounts/authorize");
url.search = new URLSearchParams({
client_id: "dynamic_agent_client", // first sign-in only; reuse the issued client ID afterwards
agent_name_hint: "My Agent", // your app's real name, the same on every install
ext_agent_host_id: hostId,
response_type: "code",
redirect_uri: `http://127.0.0.1:${port}/callback`, // never "localhost"
scope: "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct",
resource: "https://api.openai.com/v1",
state,
nonce,
code_challenge_method: "S256",
code_challenge: b64url(createHash("sha256").update(verifier).digest()),
}).toString();
// Open url in the user's browser, listen on 127.0.0.1:${port}/callback,
// check state, then exchange the code (no client secret).Exchange the code at the token endpoint with grant_type=authorization_code, the issued client_id, code, code_verifier, the exact redirect_uri and resource=https://api.openai.com/v1. No client secret. Validate the ID token (signature, iss, aud, exp, nonce) and confirm the granted scopes include chatgpt.tokens.use.direct.
Store tokens with owner-only permissions (for example 0600), and keep them out of browser storage, source control, logs and analytics.
AI requests on the user's plan
Call the Responses API with the user's access token. Every request must set store: false and stream: true. Treat a request as successful only after the response.completed event.
curl https://api.openai.com/v1/responses \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-6.1-sol",
"input": [{"role": "user", "content": "Say exactly: Hello, world!"}],
"store": false,
"stream": true
}'List the models available to the user first, show only those with visibility: "list", display their display_name and send their slug as the model.
curl https://api.openai.com/v1/models \
-H "Authorization: Bearer $ACCESS_TOKEN"Tokens
| Token | Lifetime | Notes |
|---|---|---|
| Access token | 1 hour | JWT with aud https://api.openai.com/v1, issuer https://auth.openai.com. |
| Refresh token | 30 days | Returned with offline_access. Each refresh issues a replacement with a fresh 30 days, with no limit on successive refreshes. |
| ID token | Sign-in | Verify before trusting identity. Use sub as the stable account ID. |
To sign out, POST the refresh token to the revocation endpoint (token, token_type_hint=refresh_token, client_id). An empty 200 means success, even for an already-invalid token.
Errors
| Code | Status | What to do |
|---|---|---|
subscription_sharing_usage_limit_exceeded | 429 | Pause new requests and send the user to ChatGPT usage settings. |
subscription_sharing_usage_unavailable | 503 | Keep credentials; retry with bounded backoff. |
subscription_sharing_user_not_eligible | 403 | Explain the restriction. Don't retry or loop OAuth. |
subscription_sharing_unsupported_capability | 400 | Remove the unsupported input, tool, model or parameter. |
subscription_sharing_invalid_user | 401 | Ask the user to sign in again (consent was likely revoked). |
invalid_grant | — | Refresh token unusable: clear tokens and repeat OAuth. |
Don't erase credentials because of a temporary network or infrastructure failure.
Errors and recovery docsPreview limitations
- Send
inputas an array with all needed context; there's no persistent conversation storage. - No
role: "system"input items: useinstructionsor developer messages. - Unsupported parameters include
temperature,top_p,max_output_tokens,metadata,background,conversation,truncationanduser. - No image generation, file search, Code Interpreter, computer use or hosted MCP/connectors.
- No audio or video input, Files uploads or transcription.
UI and wording
| Where | Use |
|---|---|
| Sign-in button | Continue with ChatGPT |
| First sign-in (once) | Modal: “You're using your ChatGPT plan” / “Eligible usage in this app uses your ChatGPT plan”, dismissed with “Got it”. |
| Near the composer or model picker | “Using ChatGPT plan”, with a way to manage usage. |
| Usage page and limit errors | “Manage usage” linking to chatgpt.com/settings/usage (the primary action on limit errors). |
| Pricing page | “Use your ChatGPT plan” on eligible plans, with a Learn more link. |
Keep ChatGPT plan usage clearly separate from your app's own subscriptions and credits.
UI/UX guidelinesDeveloper FAQ
- Can any developer add Sign in with ChatGPT today?
- Not for websites and hosted apps: that path is a limited trial for selected commercial partners, and you join through OpenAI's interest form. Open-source and locally hosted apps can use the plan-usage flow directly, without approval, a client secret or a partner API key.
- Is Sign in with ChatGPT standard OAuth?
- Yes. It uses the OAuth 2.0 authorization code flow with PKCE (S256 only) plus OpenID Connect. Issuer and endpoints are published at https://auth.openai.com/.well-known/openid-configuration.
- Who pays for AI requests made with a user's ChatGPT plan?
- The user's ChatGPT plan. Eligible Responses API requests made with their access token count toward their ChatGPT plan limits instead of your API bill. When a limit is reached, the API returns subscription_sharing_usage_limit_exceeded.
- Which API do I call with the user's token?
- The Responses API: POST https://api.openai.com/v1/responses with Authorization: Bearer <access token>, and store: false and stream: true set on every request.
- How long do tokens last?
- Access tokens last one hour. Refresh tokens last 30 days, and each successful refresh returns a new refresh token with a fresh 30-day lifetime.
The feature is in preview and details change. Always check the official docs before shipping.