The one-line version: Sign in with ChatGPT is a standard OpenID Connect authorization-code flow with PKCE against auth.openai.com. If you have ever wired Sign in with Google, you already know the shape — you are the OAuth client, OpenAI is the identity provider, and at the end you get exactly three claims back: name, email, and profile picture. Here is the whole thing in one file.

On August 2, 2026, OpenAI turned ChatGPT into a login button, launching Sign in with ChatGPT in beta with six partners — Airtable, GitLab, HubSpot, Notion, Supabase, and Vercel (Tech Times). We already covered whether it belongs in your product and how it compares to the other login buttons. This piece is the build.

First: get the direction right#

The single most common mistake is confusing this with a GPT Action or the Apps SDK. Those go the other way: they make your service the OAuth provider so a GPT can call your API on a user's behalf. Sign in with ChatGPT is the reverse — OpenAI is the identity provider, and your app is the client. The role you play here is identical to the role you play with Sign in with Google: you send users out to an authorization server and get an identity back.

Once you hold that straight, the rest is textbook OIDC.

The flow, in four hops#

  1. Redirect out. Send the user to OpenAI's authorization endpoint with your client_id, a redirect_uri, scope=openid email profile, a random state, and a PKCE code_challenge.
  2. User signs in. OpenAI authenticates them and shows a consent screen for the three profile fields.
  3. Callback in. OpenAI redirects back to your redirect_uri with a short-lived code and your state.
  4. Exchange + verify. Your server POSTs the code and the original code_verifier to the token endpoint, gets back an id_token, verifies it, and reads the claims.

That's it. The two endpoints, per the OAuth flow OpenAI's login runs, are https://auth.openai.com/oauth/authorize and https://auth.openai.com/oauth/token. Confirm the exact values against OpenAI's own auth docs — this is a two-day-old beta, so treat any endpoint you copy from a blog (including this one) as to be verified before you ship.

Step 1 — build the authorize URL (with PKCE)#

PKCE is not optional here, and it is what lets even a browser-only app do this safely. Generate a random code_verifier, hash it with SHA-256, base64url-encode the hash into a code_challenge, and keep the verifier server-side.

// auth-start.js  (Node 18+, Express)
import crypto from "node:crypto";

const b64url = (buf) =>
  buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");

export function startLogin(req, res) {
  const codeVerifier = b64url(crypto.randomBytes(32));
  const codeChallenge = b64url(
    crypto.createHash("sha256").update(codeVerifier).digest()
  );
  const state = b64url(crypto.randomBytes(16));

  // Stash both in the session — you need them at the callback.
  req.session.pkce = { codeVerifier, state };

  const url = new URL("https://auth.openai.com/oauth/authorize");
  url.searchParams.set("client_id", process.env.OPENAI_CLIENT_ID);
  url.searchParams.set("redirect_uri", process.env.OPENAI_REDIRECT_URI);
  url.searchParams.set("response_type", "code");
  url.searchParams.set("scope", "openid email profile");
  url.searchParams.set("state", state);
  url.searchParams.set("code_challenge", codeChallenge);
  url.searchParams.set("code_challenge_method", "S256");

  res.redirect(url.toString());
}

The state defeats CSRF; the code_challenge defeats code interception. Storing both server-side is the part people skip and then can't validate the callback.

Step 2 — handle the callback and exchange the code#

When OpenAI redirects back, first check state, then exchange the code. The token exchange is a server-side POST — never do it from the browser, and never put a client secret in client code.

// auth-callback.js
export async function handleCallback(req, res) {
  const { code, state } = req.query;
  const saved = req.session.pkce;

  // 1. CSRF check — reject anything whose state we did not issue.
  if (!saved || state !== saved.state) {
    return res.status(400).send("bad state");
  }

  // 2. Exchange the authorization code for tokens.
  const body = new URLSearchParams({
    grant_type: "authorization_code",
    code,
    redirect_uri: process.env.OPENAI_REDIRECT_URI,
    client_id: process.env.OPENAI_CLIENT_ID,
    code_verifier: saved.codeVerifier,
  });
  // Confidential clients also send client_secret (or use HTTP Basic auth).
  if (process.env.OPENAI_CLIENT_SECRET) {
    body.set("client_secret", process.env.OPENAI_CLIENT_SECRET);
  }

  const r = await fetch("https://auth.openai.com/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body,
  });
  if (!r.ok) return res.status(502).send("token exchange failed");
  const tokens = await r.json(); // { id_token, access_token, ... }

  const claims = await verifyIdToken(tokens.id_token);
  // claims: { sub, name, email, picture, iss, aud, exp, ... }

  req.session.user = {
    id: claims.sub,          // key your account on sub, NOT email
    name: claims.name,
    email: claims.email,
    picture: claims.picture,
  };
  res.redirect("/");
}

Step 3 — verify the id_token (do not skip this)#

The id_token is a signed JWT. Trusting its contents without checking the signature is the single most common way an OIDC integration turns into an account-takeover bug. Fetch OpenAI's JWKS from its OIDC discovery document, verify the signature, and check iss, aud, and exp.

// verify.js
import { createRemoteJWKSet, jwtVerify } from "jose";

// The issuer publishes /.well-known/openid-configuration; read jwks_uri from it.
const JWKS = createRemoteJWKSet(
  new URL("https://auth.openai.com/.well-known/jwks.json")
);

export async function verifyIdToken(idToken) {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: "https://auth.openai.com",
    audience: process.env.OPENAI_CLIENT_ID, // aud MUST equal your client_id
  });
  return payload; // signature, iss, aud, exp all validated by jwtVerify
}

jwtVerify checks the RS256 signature against the right key, plus iss, aud, and expiry, in one call. If it throws, you reject the login — no partial trust.

The three gotchas that bite in production#

1. It is a beta with a fixed partner list. Client registration is currently gated to the six launch partners. Confirm you actually have a client_id before you build anything that depends on this button existing — a demo that works against a borrowed credential is not a launch.

2. Enterprise admins can turn it off. OpenAI gives org admins the ability to disable Sign in with ChatGPT organization-wide, or restrict it to an approved partner list; orgs with no explicit policy are opted in by default (Tech Times). Translation: a user who signed up with it can lose the button next quarter. Never make it your only login. Offer it as one federated option beside email and Google, and let users link a second method.

3. Key accounts on sub, not email. The sub (subject) claim is the stable identifier for that ChatGPT account; the email can change. If you use the email as your primary key, a user updating their address becomes a new — or worse, a colliding — account. Store sub as the account key and treat email as mutable profile data.

What "done" looks like#

A correct integration is short: a button that redirects to auth.openai.com/oauth/authorize with PKCE, a callback that checks state and exchanges the code server-side, and an id_token you verify before trusting. You get name, email, and picture back — enough to create a session, not enough to be a data-sharing liability. Wire it as one option, keep a fallback, and you have added the newest login button on the web without betting your auth on a beta.

If you are still deciding whether to add it at all, the three-way comparison against Google and Apple is the piece to read next; for the strategic case, see why ChatGPT-as-login is really a distribution surface.