Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/deprecate-update-user-metadata.md
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's split this into separate changesets so each package changelog stays focused 👍🏼

  1. for backend
  2. for clerk-js and shared

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@clerk/backend': minor
'@clerk/clerk-js': patch
'@clerk/shared': patch
---

Deprecate metadata updates via `clerkClient.users.updateUser` and `user.update()` to mirror the deprecation of `publicMetadata`, `privateMetadata`, and `unsafeMetadata` on `PATCH /v1/users/{userId}` and `PATCH /v1/me`.

`@clerk/backend`: add `clerkClient.users.replaceUserMetadata(userId, params)` for full-replacement updates. Deprecate the `publicMetadata`, `privateMetadata`, and `unsafeMetadata` parameters on `clerkClient.users.updateUser` — use `updateUserMetadata` for partial updates (deep merge) or `replaceUserMetadata` for full replacement.

`@clerk/clerk-js`: deprecate `unsafeMetadata` on `user.update()`. Use `user.updateMetadata({ unsafeMetadata })` for partial updates (deep merge) instead.
179 changes: 179 additions & 0 deletions packages/backend/src/api/__tests__/UserApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('UserAPI', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockUserResponse = {
object: 'user',
id: 'user_123',
public_metadata: {},
private_metadata: {},
unsafe_metadata: {},
};

describe('updateUser', () => {
it('calls PATCH /users/{id} when no metadata fields are provided', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)));

const response = await apiClient.users.updateUser('user_123', { firstName: 'Jane' });

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(response.id).toBe('user_123');
});

it('routes metadata to PUT /users/{id}/metadata when only metadata is provided', async () => {
const patchHandler = vi.fn(() => HttpResponse.json(mockUserResponse));
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { foo: 'bar' },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { foo: 'bar' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
publicMetadata: { foo: 'bar' },
});

expect(patchHandler).not.toHaveBeenCalled();
expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ foo: 'bar' });
});

it('splits mixed calls: PATCH for non-metadata, then PUT for metadata', async () => {
const calls: string[] = [];

const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('patch');
const body = await request.json();
expect(body).toEqual({ first_name: 'Jane' });
return HttpResponse.json(mockUserResponse);
});

const putHandler = vi.fn(async ({ request }: { request: Request }) => {
calls.push('put');
const body = await request.json();
expect(body).toEqual({
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
return HttpResponse.json({
...mockUserResponse,
first_name: 'Jane',
public_metadata: { plan: 'pro' },
private_metadata: { invoice: 'inv_1' },
});
});

server.use(
http.patch('https://api.clerk.test/v1/users/user_123', validateHeaders(patchHandler)),
http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)),
);

const response = await apiClient.users.updateUser('user_123', {
firstName: 'Jane',
publicMetadata: { plan: 'pro' },
privateMetadata: { invoice: 'inv_1' },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
expect(putHandler).toHaveBeenCalledTimes(1);
// PATCH must run before PUT so the user state from PUT is the latest.
expect(calls).toEqual(['patch', 'put']);
expect(response.firstName).toBe('Jane');
expect(response.publicMetadata).toEqual({ plan: 'pro' });
});

it('passes only metadata fields that were explicitly provided to PUT', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = (await request.json()) as Record<string, unknown>;
// Only unsafe_metadata was provided. The other two should be undefined,
// which serializes to "field omitted" on the wire — leaving those
// columns untouched server-side.
expect(body.unsafe_metadata).toEqual({ device: 'mobile' });
expect(body).not.toHaveProperty('public_metadata');
expect(body).not.toHaveProperty('private_metadata');
return HttpResponse.json({
...mockUserResponse,
unsafe_metadata: { device: 'mobile' },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

await apiClient.users.updateUser('user_123', {
unsafeMetadata: { device: 'mobile' },
});

expect(putHandler).toHaveBeenCalledTimes(1);
});
});

describe('updateUserMetadata', () => {
it('still hits PATCH /users/{id}/metadata (unchanged)', async () => {
const patchHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { merge: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { merge: true },
});
});

server.use(http.patch('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(patchHandler)));

await apiClient.users.updateUserMetadata('user_123', {
publicMetadata: { merge: true },
});

expect(patchHandler).toHaveBeenCalledTimes(1);
});
});

describe('replaceUserMetadata', () => {
it('hits PUT /users/{id}/metadata', async () => {
const putHandler = vi.fn(async ({ request }: { request: Request }) => {
const body = await request.json();
expect(body).toEqual({
public_metadata: { replaced: true },
});
return HttpResponse.json({
...mockUserResponse,
public_metadata: { replaced: true },
});
});

server.use(http.put('https://api.clerk.test/v1/users/user_123/metadata', validateHeaders(putHandler)));

const response = await apiClient.users.replaceUserMetadata('user_123', {
publicMetadata: { replaced: true },
});

expect(putHandler).toHaveBeenCalledTimes(1);
expect(response.publicMetadata).toEqual({ replaced: true });
});
});
});
79 changes: 74 additions & 5 deletions packages/backend/src/api/endpoints/UserApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,34 @@ type UpdateUserParams = {

/** The maximum number of Organizations the user can create. 0 means unlimited. */
createOrganizationsLimit?: number;
} & UserMetadataParams &
(UserPasswordHashingParams | object);

/**
* Metadata visible to your Frontend and Backend APIs.
*
* @deprecated Updating metadata via `updateUser` is deprecated. Use
* `updateUserMetadata` for partial updates (deep merge) or
* `replaceUserMetadata` for full replacement.
*/
publicMetadata?: UserPublicMetadata;

/**
* Metadata visible only to your Backend API.
*
* @deprecated Updating metadata via `updateUser` is deprecated. Use
* `updateUserMetadata` for partial updates (deep merge) or
* `replaceUserMetadata` for full replacement.
*/
privateMetadata?: UserPrivateMetadata;

/**
* Metadata writeable from both the Frontend and Backend APIs.
*
* @deprecated Updating metadata via `updateUser` is deprecated. Use
* `updateUserMetadata` for partial updates (deep merge) or
* `replaceUserMetadata` for full replacement.
*/
unsafeMetadata?: UserUnsafeMetadata;
} & (UserPasswordHashingParams | object);

type GetOrganizationMembershipListParams = ClerkPaginationRequest<{
userId: string;
Expand Down Expand Up @@ -252,10 +278,38 @@ export class UserAPI extends AbstractAPI {
public async updateUser(userId: string, params: UpdateUserParams = {}) {
this.requireId(userId);

const { publicMetadata, privateMetadata, unsafeMetadata, ...rest } = params as UpdateUserParams &
UserMetadataParams;
const hasMetadata = publicMetadata !== undefined || privateMetadata !== undefined || unsafeMetadata !== undefined;
const hasRest = Object.keys(rest).length > 0;

if (hasMetadata) {
deprecated(
'updateUser(userId, { publicMetadata | privateMetadata | unsafeMetadata })',
'Use updateUserMetadata for partial updates (merge) or replaceUserMetadata for full replacement.',
);
}

if (!hasMetadata) {
return this.request<User>({
method: 'PATCH',
path: joinPaths(basePath, userId),
bodyParams: rest,
});
}

if (hasRest) {
await this.request<User>({
method: 'PATCH',
path: joinPaths(basePath, userId),
bodyParams: rest,
});
}

return this.request<User>({
method: 'PATCH',
path: joinPaths(basePath, userId),
bodyParams: params,
method: 'PUT',
path: joinPaths(basePath, userId, 'metadata'),
bodyParams: { publicMetadata, privateMetadata, unsafeMetadata },
});
}

Expand All @@ -282,6 +336,21 @@ export class UserAPI extends AbstractAPI {
});
}

/**
* Replace a user's metadata. Supplied fields are overwritten in full; fields
* omitted from `params` are left unchanged. Prefer `updateUserMetadata` for
* partial updates with deep-merge semantics.
*/
public async replaceUserMetadata(userId: string, params: UserMetadataParams) {
this.requireId(userId);

return this.request<User>({
method: 'PUT',
path: joinPaths(basePath, userId, 'metadata'),
bodyParams: params,
});
}

public async deleteUser(userId: string) {
this.requireId(userId);
return this.request<User>({
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export const API_VERSION = 'v1';

export const USER_AGENT = `${PACKAGE_NAME}@${PACKAGE_VERSION}`;
export const MAX_CACHE_LAST_UPDATED_AT_SECONDS = 5 * 60;
export const SUPPORTED_BAPI_VERSION = '2025-11-10';
export const SUPPORTED_BAPI_VERSION = '2026-05-12';

const Attributes = {
AuthToken: '__clerkAuthToken',
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/tokens/__tests__/handshake.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ describe('HandshakeService', () => {

// Verify all required parameters are present
expect(url.searchParams.get('redirect_url')).toBeDefined();
expect(url.searchParams.get('__clerk_api_version')).toBe('2025-11-10');
expect(url.searchParams.get('__clerk_api_version')).toBe('2026-05-12');
expect(url.searchParams.get(constants.QueryParameters.SuffixedCookies)).toMatch(/^(true|false)$/);
expect(url.searchParams.get(constants.QueryParameters.HandshakeReason)).toBe('test-reason');
});
Expand Down
41 changes: 38 additions & 3 deletions packages/clerk-js/src/core/resources/User.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { deprecated } from '@clerk/shared/deprecated';
import { getFullName } from '@clerk/shared/internal/clerk-js/user';
import type {
BackupCodeJSON,
Expand Down Expand Up @@ -47,6 +48,7 @@ import type {

import { convertPageToOffsetSearchParams } from '../../utils/convertPageToOffsetSearchParams';
import { unixEpochToDate } from '../../utils/date';
import { computeMergePatch } from '../../utils/mergePatch';
import { normalizeUnsafeMetadata } from '../../utils/resourceParams';
import { eventBus, events } from '../events';
import { addPaymentMethod, getPaymentMethods, initializePaymentMethod } from '../modules/billing';
Expand Down Expand Up @@ -235,9 +237,42 @@ export class User extends BaseResource implements UserResource {
return new BackupCode(json);
};

update = (params: UpdateUserParams): Promise<UserResource> => {
return this._basePatch({
body: normalizeUnsafeMetadata(params),
update = async (params: UpdateUserParams): Promise<UserResource> => {
const { unsafeMetadata, ...rest } = params;
const hasMetadata = unsafeMetadata !== undefined;
const hasRest = Object.keys(rest).length > 0;

if (!hasMetadata) {
return this._basePatch({
body: normalizeUnsafeMetadata(params),
});
}

deprecated(
'user.update({ unsafeMetadata })',
'Use user.updateMetadata({ unsafeMetadata }) for partial updates (deep merge) instead.',
);

// The FAPI endpoint deprecates `unsafe_metadata` on PATCH /me. Route
// metadata through PATCH /me/metadata (deep-merge) while preserving the
// *replace* semantics of `user.update({ unsafeMetadata })` by
// diffing the locally-cached value against the desired one and sending
// an RFC 7396 merge patch (null-deletes for removed keys).
if (hasRest) {
await this._basePatch({
body: normalizeUnsafeMetadata(rest as UpdateUserParams),
});
}

const patch = computeMergePatch(this.unsafeMetadata, unsafeMetadata);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this preserve replacement semantics if this.unsafeMetadata is not fresh at the moment of the call? Touch-on-focus can refresh the user in common tab-switching flows, but user.update({ unsafeMetadata }) previously sent the caller-provided value as the full replacement regardless of local cache state. With this client-side diff, a stale local value can produce an incomplete patch, or {} and skip the request entirely.

Correct me if Im wrong 😄

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yes you are right . There are two scenarios:

  1. if we execute the call to PATCH v1 /me endpoint first that returns a fresh user object. No issue here since the diff will be using the latest data.
  2. If the request is a metadata only update, then theres a chance the user object could be stale. In this case, I think we'll need to perform a reload first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wobsoriano I pushed a fix. Let me know what you think!


// An empty patch means current already equals desired — short-circuit.
if (patch !== null && typeof patch === 'object' && Object.keys(patch).length === 0) {
return this;
}

return this.updateMetadata({
unsafeMetadata: patch as UserUnsafeMetadata,
});
};

Expand Down
Loading
Loading