Upgrading a self-hosted Rekey from 2.3.x to 2.4.0
Blog GuideThis is the upgrade from 2.3.x to 2.4.0 for a deployment you run yourself. It has one database migration, three settings to check, and one change that needs a decision before you deploy: required two-factor is now enforced on every session.
The full guide, with every command and a troubleshooting table, is docs/migrating-to-2.4.md. This page walks through it so you can decide when to upgrade.
On the hosted service
@rekey.dev/* packages.Before you start
- Back up the database. With the bundled Postgres:
docker compose -f docker-compose.prod.yml exec -T postgres \ pg_dump -U rekey -d rekey -Fc > rekey-$(date +%F).dump
- Note your version:
curl -fsS https://api.acme.example/health/liveprints it. - Find out whether any Application has
mfa: 'required', and how many of its users have no authenticator. The query is below.
Deploy the API, the panel and the portal together. The 2.4.0 portal reads an auth object from GET /api/v1/portal/config/:slug that a 2.3 API does not send, and without it the portal cannot render a page. Upgrade your SDK packages after the server side.
The three settings
No variable is new. Three that already existed now decide whether a portal feature works. With docker-compose.prod.yml the first two are already in place.
INTERNAL_CALLER_SECRET: one value on the API, the portal and the panel. Without it, a customer of an Application withdeviceBinding: 'required'cannot sign in to the portal: the API answersDEVICE_FINGERPRINT_REQUIREDand the portal tells them to use the installed app. Compose has required it since 2.2.PUBLIC_PORTAL_URLon the API andPORTAL_BASE_URLon the portal must name the same origin. When they differ, portal emails fail withAUTH_URL_NOT_ALLOWED, social sign-in withOAUTH_REDIRECT_URI_NOT_ALLOWED, and apps with an origin allowlist withORIGIN_NOT_ALLOWED. Compose derives both fromPORTAL_HOST.PORTAL_EXTRA_HOST_RULES, only if you want a portal on a custom domain: Traefik rule text that follows the portal's ownHost(), such as|| Host(`billing.acme.example`).
The guide has a block of commands that prints what each container sees, with a short hash in place of each secret, so you can compare the caller secret across all three without printing it.
The migration
20261110132835_portal_domain_verification changes only the applications table. It drops the unique index on the portal domain, adds a partial unique index so only one Application can hold a hostname verified, and adds three columns for re-checking verified domains. It locks applications for milliseconds, so it is safe to run on a live database.
With Compose it runs when the 2.4.0 api container starts. From a source checkout, run pnpm db:migrate:deploy.
Required two-factor needs a decision
Until 2.3, mfa: 'required' was checked at sign-in, and your app was trusted to send a user without an authenticator to setup. 2.4 enforces it on every request. Such a user gets 403 MFA_ENROLLMENT_REQUIRED everywhere except the routes they need to set up two-factor, confirm their address, read who they are, and sign out. That includes users who sign in only with a passkey, and sessions that were open before the upgrade. Impersonation sessions are not held back.
Count who it affects before you deploy:
SELECT a.slug,
count(u.id) FILTER (WHERE m.enrolled_at IS NULL) AS users_without_authenticator
FROM applications a
LEFT JOIN end_users u ON u.application_id = a.id AND u.erased_at IS NULL AND u.banned_at IS NULL
LEFT JOIN mfa_credentials m ON m.end_user_id = u.id
WHERE a.auth_config ->> 'mfa' = 'required'
GROUP BY a.id, a.slug;Then pick one, per Application:
- If your users manage their account in the hosted portal, it sends them through setup before anything else.
- If they use your own app, send them to your setup screen when sign-in returns
mfaEnrollmentRequiredand when any call answersMFA_ENROLLMENT_REQUIRED. - Or set
mfatooptional, upgrade, let people enrol, and set it back.
import { RekeyError } from '@rekey.dev/react';
try {
await client.getEntitlements(accessToken);
} catch (err) {
if (err instanceof RekeyError && err.code === 'MFA_ENROLLMENT_REQUIRED') {
redirectTo('/settings/two-factor');
}
throw err;
}If your server verifies access tokens offline against the JWKS, it cannot see this: a token does not say whether its user has enrolled.

Smaller code changes
Organization invitations are emailed
Rekey now emails the invited address itself, and the response gains emailSent. If your app mailed the link, the invitee gets two emails. Stop sending yours, or switch off the Organization invitation email in the panel.
// 2.3: create the invitation, then mail `token` yourself.
// 2.4:
const { emailSent, token } = await rekey.organizations.invite(accessToken, orgId, {
email,
inviteUrl: 'https://app.acme.example/join?token={token}',
});
if (!emailSent) show(`Send this link yourself: https://app.acme.example/join?token=${token}`);The plan list type
getPlans in the Node and React SDKs returns PublicPlanPage, which adds defaultPlanSlug. Code that reads the result compiles as before. A test fake typed as the old page needs the new field.
// 2.3
const fake: Paged<PublicPlanDto> = { items: [], page };
// 2.4
const fake: PublicPlanPage = { items: [], page, defaultPlanSlug: null };Deleting an account with no password
From a browser, an account with no password and no two-factor used to get 400 STEP_UP_UNAVAILABLE and could not delete itself. It now gets 401 EMAIL_CODE_REQUIRED: request a code and send it as emailCode.
// 2.4
await client.requestEmailCode(accessToken, { purpose: 'delete_account' });
await client.deleteAccount(accessToken, { emailCode });The guide lists the rest: a billing:write scope for freeing a licence seat, releasedBy: "end_user" on license.deactivated, hasPassword on the current user, and current on the session list. Apps that act as an OpenID Connect provider or MCP server now send authorization to the portal unless they set hostedAuthorizeUrl.
How to check it worked
curl -fsS https://api.acme.example/healthanswers{"status":"ok","service":"rekey-api","db":"ok","redis":"ok"}, and/health/livereports2.4.0.GET /api/v1/portal/config/acmeincludes anauthobject. Without it, the request reached an old API.- Sign in to the portal as a test user and land on the overview.
- For an Application with an origin allowlist, a sign-in sent with the portal's
OriginanswersINVALID_CREDENTIALSfor an unknown address, notORIGIN_NOT_ALLOWED.
If you need to roll back
The migration only drops an index and adds columns, and 2.3.1's migration step accepts the migrated database. The 2.3.1 API has not been run end to end against it, so keep the backup. Roll the portal and panel back with the API, clear any custom domain first, and expect portal sessions on device-bound Applications to sign in again.
Where to go next
- Work through the full upgrade guide in order.
- See what your customers get in what's new in Rekey 2.4.
Was this page helpful?
