6 min read

This 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

The API, panel and portal are upgraded for you. Read required two-factor and the code changes, then upgrade your @rekey.dev/* packages.

Before you start

  1. 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
  2. Note your version: curl -fsS https://api.acme.example/health/live prints it.
  3. 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 with deviceBinding: 'required' cannot sign in to the portal: the API answers DEVICE_FINGERPRINT_REQUIRED and the portal tells them to use the installed app. Compose has required it since 2.2.
  • PUBLIC_PORTAL_URL on the API and PORTAL_BASE_URL on the portal must name the same origin. When they differ, portal emails fail with AUTH_URL_NOT_ALLOWED, social sign-in with OAUTH_REDIRECT_URI_NOT_ALLOWED, and apps with an origin allowlist with ORIGIN_NOT_ALLOWED. Compose derives both from PORTAL_HOST.
  • PORTAL_EXTRA_HOST_RULES, only if you want a portal on a custom domain: Traefik rule text that follows the portal's own Host(), 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 mfaEnrollmentRequired and when any call answers MFA_ENROLLMENT_REQUIRED.
  • Or set mfa to optional, 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.

The Security page of a portal for an app called Northwind, with the two-factor card open: a QR code, the setup key, a field for the 6-digit code from the authenticator app and a Turn on two-factor button.
Two-factor setup in the portal: scan the code, then enter a code from the app.

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

  1. curl -fsS https://api.acme.example/health answers {"status":"ok","service":"rekey-api","db":"ok","redis":"ok"}, and /health/live reports 2.4.0.
  2. GET /api/v1/portal/config/acme includes an auth object. Without it, the request reached an old API.
  3. Sign in to the portal as a test user and land on the overview.
  4. For an Application with an origin allowlist, a sign-in sent with the portal's Origin answers INVALID_CREDENTIALS for an unknown address, not ORIGIN_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

  1. Work through the full upgrade guide in order.
  2. See what your customers get in what's new in Rekey 2.4.

Was this page helpful?

Upgrading a self-hosted Rekey from 2.3.x to 2.4.0 | Rekey