Make your app an OpenID Provider, step by step
Blog GuideYour users already have an account in your app. Now a second product, an internal dashboard or a tool you self-host, wants them to sign in too, and you would rather not hand out a second password. If your app runs on Rekey, it can be the OpenID Provider: the other product shows “Sign in with YourApp”, sends people to your app to sign in, and gets back a signed ID token saying who they are.
The shorter introduction covers when this is worth doing. This post is the long version: what each endpoint does, which claims you get and where they come from, a working relying party in Node, and the errors you are likely to hit on the way.
How it works
Each Application already has an OAuth 2.1 authorization server, the same one that signs people in to its hosted MCP server. Turning on the OpenID Provider adds three things to it: a discovery document, an id_token in the token response, and a /userinfo endpoint. Everything else (client registration, the authorization code, PKCE, refresh) is the server that already exists.
For an Application with the slug acme on api.rekey.dev, these are the addresses. If you host Rekey yourself, replace the origin with your API's public URL.
| What | URL |
|---|---|
| Issuer | https://api.rekey.dev/api/v1/mcp/acme |
| Discovery | https://api.rekey.dev/api/v1/mcp/acme/.well-known/openid-configuration |
| Authorize | …/api/v1/mcp/acme/oauth/authorize |
| Token | …/api/v1/mcp/acme/oauth/token |
| UserInfo | …/api/v1/mcp/acme/oauth/userinfo |
| Registration | …/api/v1/mcp/acme/oauth/register |
| Keys (JWKS) | https://api.rekey.dev/.well-known/jwks.json |
The /api/v1/mcp part of the issuer is there because MCP used this server first. It is fixed now, because registered clients and issued tokens already contain it. The same discovery document is also served at https://api.rekey.dev/.well-known/openid-configuration/api/v1/mcp/acme, for libraries that build the URL that way. The key set is shared by the whole deployment, not per Application.
The flow
- Your relying party sends the browser to
/oauth/authorizewith itsclient_id, a registeredredirect_uri, the scopes, a PKCEcode_challengewith methodS256, and usually astateand anonce. - The person signs in with their email and password (and an authenticator code if they use MFA), then chooses Allow or Deny. If you set a
hostedAuthorizeUrl, they see your own sign-in page instead. - The browser comes back to the
redirect_uriwith acode. It lives for 60 seconds and works once. - Your server posts the code and the PKCE
code_verifierto/oauth/tokenand gets an access token, a refresh token and, whenopenidwas granted, anid_token.
PKCE is required and only S256 is accepted. The response type is always code: there is no implicit or hybrid flow. There is also no single sign-on session at the provider, so every authorization asks the person to sign in again, and prompt=none answers login_required.
Scopes and claims
| Scope | What you get |
|---|---|
openid | An ID token and access to /userinfo. Required for both, and for the two scopes below. |
profile | name, given_name, family_name, preferred_username, picture and updated_at, for the claims that are set. |
email | email and email_verified. Offered only when the Application requires a verified email. |
Scopes the Application does not offer are dropped from the request. If nothing you asked for can be granted, for example scope=email profile without openid, the browser comes back with error=invalid_scope.
Why email needs verified addresses
An ID token that says "email": "[email protected]" is a claim the relying party will believe. Many relying parties match local accounts on that claim and never look at email_verified. If the address was never proved, anyone who signs up with someone else's address could arrive at the relying party as that person.
So an Application only offers the email scope when requireEmailVerification is on. Without it, discovery leaves email out of scopes_supported, and a request for openid email is granted openid alone. When the claim is present, email_verified is always true. The setting applies to accounts that already exist, so read its note in the panel before you switch it on.
Where profile claims come from
Rekey has no display name column. Profile claims come from one key in the user's metadata, metadata.oidc, and only the standard claim names inside it are read. Everything else in metadata stays private, including a top-level name.
{
"oidc": {
"name": "Ada Lovelace",
"preferred_username": "ada",
"picture": "https://cdn.example.com/ada.png"
},
"plan_notes": "never emitted"
}Values must be strings. Names are capped at 256 characters, and picture must be an https: URL of up to 512. A value that fails these checks is left out.
Only you can write metadata.oidc: with a secret key, or through PATCH /api/v1/tenant/applications/:id/end-users/:endUserId. The routes a user can reach (PATCH /api/v1/users/me, and sign-up with a publishable key) refuse it with 400 METADATA_KEY_RESERVED. That matters because relying parties often provision accounts by preferred_username; a user who could set their own could pick someone else's. The operator PATCH replaces the whole metadata object, so send the keys you want to keep along with oidc.
RS256, HS256 and the ID token
The Application's token algorithm setting decides how its access tokens are signed, and HS256 is the default. It has no effect here. ID tokens are always RS256, signed with the deployment's active key and checkable against the public JWKS, because a relying party only ever has the public half. The kid in the token header tells you which key.
{
"iss": "https://api.rekey.dev/api/v1/mcp/acme",
"sub": "cm1x...", // the end-user id
"aud": "cm9k...", // your client_id
"exp": 1754050000, // 10 minutes after iat
"iat": 1754049400,
"auth_time": 1754049400,
"nonce": "n-0S6_WzA2Mj", // only if you sent one
"at_hash": "9NCiP...",
"email": "[email protected]",
"email_verified": true
}Key local accounts on sub. It is stable for a user within one Application, and the same person in two Applications gets two unrelated values. The refresh grant returns no new ID token: a refresh is not a sign-in, so if you need a fresh assertion, send the person back through /oauth/authorize.
/oauth/userinfo returns the same claims for the access token's scopes. It takes the token only in the Authorization: Bearer header, on GET or POST.
Client registration
Clients register themselves with POST /oauth/register (RFC 7591). Registration is open by default, needs no credentials, and gives back a public client: a client_id, no secret, and PKCE in place of client authentication. There is no operator-side form for creating clients yet, which is why it starts open. Once your relying parties are registered, close it: with dynamicClientRegistration off, the endpoint answers 403 CLIENT_REGISTRATION_DISABLED, discovery stops listing registration_endpoint, and existing clients keep working.
Build it
The same steps live in the docs under Set it up and Sign in from a relying party.
1. Turn it on
In the panel, open the Application, go to Authentication → OAuth clients, and switch on Act as an OpenID Connect provider. If you want the email claim, also turn on Require a verified email under Authentication → Methods → Verification & sessions. Over the API, both are one call:
curl -X PATCH "$REKEY_URL/api/v1/tenant/applications/$APP_ID/auth-config" \
-H "Authorization: Bearer $OPERATOR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "oidcEnabled": true, "requireEmailVerification": true }'Then check discovery:
curl https://api.rekey.dev/api/v1/mcp/acme/.well-known/openid-configuration
You should see openid, profile and email in scopes_supported, ["S256"] in code_challenge_methods_supported, and ["none"] in token_endpoint_auth_methods_supported.
2. Register the relying party
curl -X POST https://api.rekey.dev/api/v1/mcp/acme/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "Acme Dashboard",
"redirect_uris": ["http://localhost:3000/auth/callback"]
}'The 201 response has your client_id, "token_endpoint_auth_method": "none" and the redirect URIs you sent. You can register up to 20. Each must be https:, http: on localhost or 127.0.0.1, or a custom app scheme such as myapp://callback, and the one your app sends at sign-in has to match one of them character for character. Add the production URI when you deploy.
3. Add “Sign in with YourApp”
Any OpenID Connect library will do. This one uses openid-client v6 with Express. Give the library the issuer, not the discovery URL; it adds /.well-known/openid-configuration itself and checks that the document's issuer matches.
The full Express sample, with the PKCE verifier, state and nonce kept in the session and checked on the way back, is in the relying-party section of the OIDC provider docs, so there is one copy to keep current.
Open http://localhost:3000/login, sign in as one of your app's users, press Allow, and the callback prints the claims. Put a link to /login behind your “Sign in with YourApp” button and you are done with the happy path.
4. Verify the ID token against the JWKS
The library already checks iss, aud, exp and the nonce. If the token travels further, to another service or a queue, verify the signature there with the public keys. With jose:
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://api.rekey.dev/.well-known/jwks.json'));
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://api.rekey.dev/api/v1/mcp/acme',
audience: CLIENT_ID,
algorithms: ['RS256'],
});
if (payload.nonce !== expectedNonce) throw new Error('nonce mismatch');Rotated keys stay in the set for a while after a rotation, and the response can be cached for five minutes, so a token signed just before a rotation still verifies.
Common errors and fixes
| What you see | What to do |
|---|---|
Discovery answers 404 OIDC_NOT_FOUND | The provider switch is off, the slug is wrong, or the Application is disabled. Check the slug in the panel and turn the switch on. |
No email in scopes_supported, and no email claim | Turn on Require a verified email. Users who never confirmed their address are then asked to. |
| The authorize page says “Unknown client_id or unregistered redirect_uri.” | The redirect_uri must match a registered one exactly, including scheme, port, path and trailing slash. Register the URI you actually send. |
Registration answers 400 INVALID_REDIRECT_URI | Plain http works only on localhost or 127.0.0.1; otherwise use https or a custom app scheme, and send between 1 and 20 URIs. |
Registration answers 403 CLIENT_REGISTRATION_DISABLED | Open registration is off. Turn it on for the time it takes to register, then off again. |
Back at the callback with error=invalid_scope | Nothing you asked for can be granted. Include openid, and check scopes_supported. |
Back with error=invalid_request | code_challenge_method was not S256. Send the SHA-256 challenge, never plain. |
Back with error=login_required | You sent prompt=none. There is no provider session to reuse, so drop it and let the person sign in. |
Back with error=access_denied | The person pressed Deny. Show them your sign-in page again. |
Token endpoint: invalid_grant | The code is older than 60 seconds, was already used, the redirect_uri differs from the authorize request, or the code_verifier does not match. Start the sign-in again; do not retry the same code. |
Token endpoint: 401 invalid_client | The client_id is unknown to this Application. Check you registered against the same slug. |
/userinfo answers 401 invalid_token | Send the access token, not the ID token, in the Authorization header. A token from another Application, or for a user who was banned or erased, is also refused. |
/userinfo answers 403 insufficient_scope | The access token was granted without openid. Ask for openid at authorize. |
If the redirect after Allow goes nowhere
form-action leaves out your redirect origin. The only trace is a violation in the browser console. This applies to your own page if you set a hostedAuthorizeUrl.What is not built
- Clients with a secret, and
private_key_jwt. Public clients with PKCE only. - RP-initiated logout, front-channel and back-channel logout.
- The
claims,requestandrequest_uriparameters, and signed or encrypted UserInfo. - Enforcing
max_age. Compareauth_timein the ID token yourself if you need it. - Remembered consent. The person signs in and approves every time.
The full reference, including every scope rule and refusal, is docs/oidc-provider.md. For the agent side of the same server, see the MCP guide.
Was this page helpful?
