--- canonical_url: https://docs.configure.dev/oidc last_updated: 2026-10-31 --- # Sign in with Configure over OpenID Connect If you already have sign-in (Auth.js, Clerk, Auth0, Better Auth or your own), add Configure as one more OpenID Connect provider. The user signs in on Configure's hosted flow and comes back to your callback with an ID token. The token carries their Configure user id and, when you ask for it, a verified email. Then your server reads what they imported, with your own user id. This server does the whole sign-in with Node's built-in modules and `jose`: ```ts [server/oidc.ts] import { createServer, type IncomingMessage } from "node:http"; import { createHash, randomBytes } from "node:crypto"; import { createRemoteJWKSet, jwtVerify } from "jose"; // Endpoints from https://api.configure.dev/.well-known/openid-configuration const ISSUER = "https://api.configure.dev"; const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`)); const CLIENT_ID = process.env.CONFIGURE_CLIENT_ID ?? ""; // oc_..., from step 1 const CLIENT_SECRET = process.env.CONFIGURE_CLIENT_SECRET; // ocs_..., confidential clients only const REDIRECT_URI = "http://localhost:3000/callback"; // registered on the client, exact string const COOKIE = "configure_oidc"; const SECURE = REDIRECT_URI.startsWith("https://") ? "; Secure" : ""; const random = () => randomBytes(32).toString("base64url"); function readCookie(req: IncomingMessage, name: string): string { for (const part of (req.headers.cookie ?? "").split(";")) { const [key, value] = part.trim().split("="); if (key === name && value) return value; } return ""; } createServer(async (req, res) => { const url = new URL(req.url ?? "/", REDIRECT_URI); try { // 2. GET /login: make a PKCE verifier, a state and a nonce for this attempt. // Keep all three in an httpOnly cookie, then send the browser to Configure. if (url.pathname === "/login") { const verifier = random(); const state = random(); const nonce = random(); const authorize = new URL(`${ISSUER}/oauth/authorize`); authorize.search = new URLSearchParams({ response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, scope: "openid email profile", // always send it code_challenge: createHash("sha256").update(verifier).digest("base64url"), code_challenge_method: "S256", // required for every client state, nonce, }).toString(); res.writeHead(302, { Location: authorize.toString(), "Set-Cookie": `${COOKIE}=${verifier}.${state}.${nonce}; HttpOnly; SameSite=Lax; Path=/callback; Max-Age=900${SECURE}`, }); return res.end(); } // 3. GET /callback?code=...&state=...: check the state against the cookie, then clear it. if (url.pathname === "/callback") { const [verifier = "", state = "", nonce = ""] = readCookie(req, COOKIE).split("."); res.setHeader("Set-Cookie", `${COOKIE}=; HttpOnly; SameSite=Lax; Path=/callback; Max-Age=0${SECURE}`); if (!state || url.searchParams.get("state") !== state) { res.writeHead(400); return res.end("Invalid sign-in state."); } if (url.searchParams.has("error")) { res.writeHead(400); // access_denied: the user declined return res.end(`Sign-in ended: ${url.searchParams.get("error")}`); } // 4. Exchange the code at the token endpoint. The body is form-encoded. const headers: Record = { "Content-Type": "application/x-www-form-urlencoded" }; if (CLIENT_SECRET) { headers.Authorization = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")}`; } const tokenRes = await fetch(`${ISSUER}/oauth/token`, { method: "POST", headers, body: new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code") ?? "", redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }), }); const tokens = (await tokenRes.json()) as { access_token?: string; id_token?: string; error?: string; error_description?: string; }; if (!tokenRes.ok || !tokens.id_token) { res.writeHead(400); return res.end(`Token exchange failed: ${tokens.error ?? "no id_token"}: ${tokens.error_description ?? ""}`); } // 5. Verify the ID token: the RS256 signature against the JWKS, iss, aud and exp. // Then compare the nonce with the one from this attempt. const { payload } = await jwtVerify(tokens.id_token, JWKS, { issuer: ISSUER, audience: CLIENT_ID, algorithms: ["RS256"], }); if (payload.nonce !== nonce) throw new Error("ID token nonce does not match."); // 6. Use the claims. Key your user on sub. Trust the email only when email_verified is true. const signedIn = { configureUserId: payload.sub, email: payload.email_verified === true ? payload.email : null, name: payload.name ?? null, }; // Find or create your own user here. Keep tokens.access_token to link the two (step 7). res.writeHead(200, { "Content-Type": "application/json" }); return res.end(JSON.stringify(signedIn)); } res.writeHead(404); res.end(); } catch (err) { res.writeHead(500); res.end(`Sign-in failed: ${(err as Error).message}`); } }).listen(3000, () => console.log("Open http://localhost:3000/login")); ``` Create a client first ([step 1](#1-create-a-client)). Then install `jose` and start the server with your client id: ```bash npm install jose CONFIGURE_CLIENT_ID=oc_... npx tsx server/oidc.ts ``` Open `http://localhost:3000/login`. After the hosted flow, the page shows the user's Configure id, their email if it is verified, and their name. ## The endpoints Configure publishes its endpoints at the discovery URL: ```bash curl https://api.configure.dev/.well-known/openid-configuration ``` The fields you use: ```json { "issuer": "https://api.configure.dev", "authorization_endpoint": "https://api.configure.dev/oauth/authorize", "token_endpoint": "https://api.configure.dev/oauth/token", "jwks_uri": "https://api.configure.dev/.well-known/jwks.json", "response_types_supported": ["code"], "grant_types_supported": ["authorization_code", "refresh_token"], "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256"], "scopes_supported": ["openid", "email", "profile"], "code_challenge_methods_supported": ["S256"], "token_endpoint_auth_methods_supported": ["none", "client_secret_basic", "client_secret_post"] } ``` `/oauth/token` has no `/v1` prefix. ## If you use an auth library Most auth libraries take an OpenID Connect provider as a set of values. Give yours these: | Setting | Value | | --- | --- | | Issuer | `https://api.configure.dev` | | Discovery URL | `https://api.configure.dev/.well-known/openid-configuration` | | Client ID | Your `oc_` client id from step 1. | | Client secret | Your `ocs_` secret. A public client has none. | | Client authentication | `none`, `client_secret_basic` or `client_secret_post`. Use the method you chose in step 1. | | Scope | `openid email profile` | | PKCE | On, with `S256`. Configure refuses an authorize request without it. | | Checks | `state` and `nonce` | | ID token algorithm | `RS256` | | Callback URL | The `redirect_uri` you registered, as the exact same string. | Read the user's claims from the ID token. Do not rely on the userinfo endpoint for the scoped claims. The ID token has no `auth_time`, `at_hash` or `azp` claim. If your library can require them, turn that off. ## 1. Create a client A client is your app's registration with Configure: an `oc_` client id and the callbacks it may return to. If your callback is on `localhost`, create the client with your publishable key. No other setup is needed: ```bash curl -X POST https://api.configure.dev/v1/sso/client \ -H "Content-Type: application/json" \ -d '{ "publishable_key": "'"$CONFIGURE_PUBLISHABLE_KEY"'", "redirect_uri": "http://localhost:3000/callback" }' ``` ```json { "client_id": "oc_4bN8..." } ``` If your callback is on your own domain, register its origin once with your secret key: ```bash curl -X POST https://api.configure.dev/v1/sso/origins \ -H "X-API-Key: $CONFIGURE_API_KEY" \ -H "X-Agent: $CONFIGURE_AGENT" \ -H "Content-Type: application/json" \ -d '{ "origin": "https://yourapp.example" }' ``` ```json { "ok": true, "origin": "https://yourapp.example" } ``` Then create the client for that callback. A server-side app can ask for a confidential client: ```bash curl -X POST https://api.configure.dev/v1/sso/client \ -H "Content-Type: application/json" \ -d '{ "publishable_key": "'"$CONFIGURE_PUBLISHABLE_KEY"'", "redirect_uri": "https://yourapp.example/callback", "token_endpoint_auth_method": "client_secret_basic" }' ``` ```json { "client_id": "oc_Qe71...", "client_secret": "ocs_Hc0v..." } ``` | Field | Required | Notes | | --- | --- | --- | | `publishable_key` | yes | Your `pk_` key. This call needs no other auth header. | | `redirect_uri` | yes | Your callback. `https`, or `http` on `localhost` or `127.0.0.1`. No wildcards, fragments or credentials. | | `token_endpoint_auth_method` | no | `none` (the default) for a public client. `client_secret_basic` or `client_secret_post` for a confidential client. | The rules: - Configure matches `redirect_uri` as an exact string. - A public client has no secret. The same `redirect_uri` gets the same client id back. - A confidential client gets an `ocs_` secret. Configure shows the secret once. Store it on your server. - The client acts for your oldest active agent. Your agent is the handle that names your app on every request, the `CONFIGURE_AGENT` that `npx configure setup` wrote. You cannot choose another agent. - The client registers the `openid`, `email` and `profile` scopes. - To add a callback, run `npx configure sso add-callback `. A client holds up to 25. `npx configure sso` lists them. A client that you made in the dashboard or with `npx configure setup` also works. Setup writes its id as `CONFIGURE_OAUTH_CLIENT_ID` and its secret as `CONFIGURE_OAUTH_CLIENT_SECRET`. Use them as `CONFIGURE_CLIENT_ID` and `CONFIGURE_CLIENT_SECRET` in the example. This client has no OpenID Connect scopes registered. Always send `scope=openid email profile` with it. Without `openid`, Configure returns no ID token. ## 2. Send the user to Configure `/login` in the example sends the browser to `/oauth/authorize` with these parameters: | Parameter | Required | Value | | --- | --- | --- | | `response_type` | yes | `code`. No other value is accepted. | | `client_id` | yes | Your `oc_` client id. | | `redirect_uri` | yes | A callback registered on the client. | | `code_challenge` | yes | The base64url SHA-256 hash of your PKCE verifier. | | `code_challenge_method` | yes | `S256`. Every client needs PKCE, confidential clients too. | | `scope` | send it | `openid email profile`. Without it, the client's registered OpenID Connect scopes apply. | | `state` | recommended | A random value for this attempt. Configure sends it back unchanged. | | `nonce` | recommended | A random value for this attempt, up to 512 characters. Configure puts it in the ID token. | | `required` | no | What the user must connect or import before they continue. See [Require a verified mailbox](#require-a-verified-mailbox). | | `resource` | no | Leave it out. | Configure ignores `prompt`, `login_hint`, `max_age` and other parameters. The user then sees the hosted flow. A user new to Configure sees a screen with your logo next to Configure's, then the import screen. A request that requires a mailbox opens on the mailbox page instead. [What the hosted flow asks for](https://docs.configure.dev/hosted-flow.md) shows each screen. The user has 15 minutes to finish. ## 3. Handle the callback When the user finishes, Configure redirects the browser to your callback: ```text http://localhost:3000/callback?code=...&state=... ``` - Compare `state` with the value in your cookie. Refuse the request if they differ. Then clear the cookie. - The cookie is `SameSite=Lax`, so the browser sends it on this redirect from Configure. - If the user declines, the callback gets `?error=access_denied&state=...` and no code. - The redirect has no `iss` parameter. ## 4. Exchange the code `/callback` in the example sends this request. The body is form-encoded, not JSON: ```bash curl -X POST https://api.configure.dev/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=authorization_code" \ --data-urlencode "code=$CODE" \ --data-urlencode "redirect_uri=http://localhost:3000/callback" \ --data-urlencode "client_id=$CONFIGURE_CLIENT_ID" \ --data-urlencode "code_verifier=$CODE_VERIFIER" ``` ```json { "access_token": "mcp_at_...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "mcp_rt_...", "scope": "profile.read profile.search profile.remember profile.commit openid email profile", "resource": "https://mcp.configure.dev", "id_token": "eyJhbGciOiJSUzI1NiIs..." } ``` - The code lasts 5 minutes and works once. A second exchange of the same code fails. - The access token lasts 1 hour. - `id_token` is in the response when `openid` was in the scope. - A confidential client also authenticates. Use Basic auth (`-u "$CONFIGURE_CLIENT_ID:$CONFIGURE_CLIENT_SECRET"`) or send `client_secret` in the body. ## 5. Verify the ID token The ID token is a JWT signed with RS256. Its header names the signing key in `kid`. Check it in this order: 1. The signature, with the key from `https://api.configure.dev/.well-known/jwks.json` that matches `kid`. 2. `iss` is `https://api.configure.dev`. 3. `aud` is your client id. 4. `exp` is in the future. The token lasts 1 hour. 5. `nonce` is the nonce you sent for this attempt. `jwtVerify` from `jose` does the first four checks. The example compares the nonce after it. A verified token decodes to this: ```json { "iss": "https://api.configure.dev", "sub": "efc419f4-797e-4e19-9615-70c7e2945444", "aud": "oc_4bN8...", "iat": 1790294400, "exp": 1790298000, "nonce": "23LG6O47fgh9twF8UxRixuhyGVyhfHvEqM9yg2ArY5A", "email": "dana@example.com", "name": "Dana Reyes", "given_name": "Dana", "family_name": "Reyes", "email_verified": true } ``` ## 6. Read the claims Each scope adds claims to the ID token: | Scope | Claims | | --- | --- | | `openid` | `sub`: the Configure user id. It is the same for every client. | | `email` | `email` and `email_verified`, when the account has an email. | | `profile` | `name`, `given_name`, `family_name`, `picture`, `birthdate` and `location`, each when the user's context holds it. | - Each claim in this table except `sub` can be missing. Treat it as optional. - There is no phone claim. - The location claim is `location`. Configure sends no `locale` claim. - `birthdate` holds the text from the user's context. Do not parse it as a date. - Key your link to the user on `sub`. The email on an account can change. ### What `email_verified` means `email_verified` is `true` only when Configure has proof that the user controls the address: - A connected Gmail or Outlook mailbox that owns the address. - A six-digit code that Configure emailed to the address. - An address that came from Google or GitHub. It is `false` for an address that the user typed. An email that another app gave Configure never makes it `true`. Configure checks again each time it issues an ID token. ## 7. Link the user to your own id Do this in the callback, because the access token lasts 1 hour. Send the `access_token` from step 4 to `POST /v1/profile/link` with your secret key and your own user id. [Link an OIDC user](https://docs.configure.dev/reference/api.md#link-an-oidc-user) shows the request and its response. After the link, read the user's context with your own id: ```bash curl https://api.configure.dev/v1/profile \ -H "X-API-Key: $CONFIGURE_API_KEY" \ -H "X-Agent: $CONFIGURE_AGENT" \ -H "X-User-Id: your-own-user-id" ``` In `X-Agent`, send the agent that your client acts for. The user approved that agent when they signed in. ## Require a verified mailbox The `email` scope asks the hosted flow for a proven mailbox. If the account has none, the flow asks for Gmail or Outlook after the code. To ask for the mailbox first, add `required` to the authorize request. In the example, add this line to `/login`, after `authorize.search` is set: ```ts [server/oidc.ts] authorize.searchParams.set("required", "gmail|outlook"); // or "email", the short form authorize.searchParams.set("mailbox_access", "no"); // optional: the address is enough, the mail can stay private ``` The hosted flow then opens on the mailbox page. Its title reads "Connect your apps and assistants to" and your app's name. The line under the title reads "First, connect Gmail or Outlook to secure your account." The page has a **Continue with Gmail** and a **Continue with Outlook** button, and no Continue button. The import screen follows, then the phone number and the code. A reading app built on Configure signs its readers in this way. Its authorize request carries `required=gmail|outlook`, so a new reader sees the mailbox page first. `mailbox_access=no` is for a product that needs the address and not the mail. A person who signs in with Google but does not share their mail then still meets the requirement, goes on to the imports, and comes back with `email_verified: true`. Without it, the page asks them to share their mail before they can go on. Leave it out when your product reads the inbox. Check `email_verified` in your callback anyway. The hosted page enforces `required`. The approval behind it does not check for an email. Step 6 in the example sets `email` to `null` unless `email_verified` is `true`. ## Refresh tokens You need a refresh token only if your server calls Configure with the access token after the first hour. After step 7, your secret key and `X-User-Id` reach the user's context without it. ```bash curl -X POST https://api.configure.dev/oauth/token \ -H "Content-Type: application/x-www-form-urlencoded" \ --data-urlencode "grant_type=refresh_token" \ --data-urlencode "refresh_token=$REFRESH_TOKEN" \ --data-urlencode "client_id=$CONFIGURE_CLIENT_ID" ``` The response has the same fields as step 4, without `id_token`. - Each refresh returns a new refresh token. Store it, and discard the old one. - A refresh token expires after 90 days without use. - If a refresh response is lost, you can retry with the same token within 60 seconds. - A later reuse of an old refresh token revokes the whole chain. The next refresh fails with `invalid_grant`. - To revoke a token, send it to `POST /oauth/revoke` as the form field `token`. The response is `200` with an empty body. A confidential client authenticates here as in step 4. ## Errors `/oauth/authorize` and `/oauth/token` answer an error as JSON: ```json { "error": "invalid_grant", "error_description": "PKCE verification failed." } ``` An error on `/oauth/authorize` shows in the user's browser. Configure does not send it to your callback. Only a declined sign-in reaches your callback, as `error=access_denied`. ### Creating a client | Status | Message | What to do | | --- | --- | --- | | `403` | `Origin is not approved. Register it once with your secret key: POST /v1/sso/origins.` | Register the origin, then create the client again. | | `403` | `Unknown or non-publishable key.` | Send a `pk_` key that is not revoked. | | `400` | `publishable_key (pk_...) is required.` | Add `publishable_key`. | | `400` | `a valid redirect_uri is required.` | Send an absolute URL. | | `400` | `This developer has no active agent to bind SSO to.` | Create an agent on your account first. | | `400` | `origin must be an https:// origin (or localhost).` | Register an `https` origin. | ### Signing in | `error` | `error_description` | What to do | | --- | --- | --- | | `invalid_request` | `code_challenge_method=S256 is required.` | Send `code_challenge` and `code_challenge_method=S256`. | | `invalid_client` | `Unknown OAuth client.` | This comes from `/oauth/authorize`: check `client_id`. `/oauth/token` checks the code first. An unknown code returns `invalid_grant` `Invalid authorization code.`, and a real code sent with another `client_id` returns `invalid_client` `client_id does not match authorization code.` | | `invalid_request` | `redirect_uri must exactly match client registration. ...` | Register the callback with `npx configure sso add-callback `. | | `access_denied` | (on your callback) | The user declined. Show your sign-in page again. | | `invalid_grant` | `Authorization code was already used.` | A reload replayed the callback. Start again at `/login`. | | `invalid_grant` | `Authorization code expired.` | More than 5 minutes passed. Start again at `/login`. | | `invalid_grant` | `PKCE verification failed.` | Send the verifier from the same attempt as the challenge. | | `invalid_grant` | `redirect_uri does not match authorization code.` | Send the same `redirect_uri` as on the authorize request. | | `invalid_client` | `Client authentication required.` (`401`) | A confidential client must send its secret. | | `invalid_grant` | `Refresh token reuse detected.` | The chain is revoked. Sign the user in again. | `/oauth` and `/v1/sso` share a limit of 100 requests a minute per IP address. Over it, you get `429`. ### Linking | Status | `code` | What to do | | --- | --- | --- | | `401` | `invalid_access_token`: "Invalid or expired Configure access token." | Link in the callback, within the hour. | | `403` | `client_not_owned`: "The OAuth client that issued this token does not belong to this developer account." | Use the secret key of the account that owns the client. | | `400` | `link_requires_external_user` | Send `X-User-Id`. | | `400` | `agent_token_not_allowed` | Send the access token from step 4, not an agent token. | ## Where this fits - If Configure is your only sign-in, use [Sign in with Configure](https://docs.configure.dev/sso.md) instead. - [What the hosted flow asks for](https://docs.configure.dev/hosted-flow.md) shows every screen the user sees.