Skip to content

feat(backend): Introduce M2M endpoints authentication using machine secret keys #6229

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 61 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
05d6c8e
chore(backend): Introduce machine token secrets as authorization header
wobsoriano Jul 1, 2025
ca7a8be
chore: clean up
wobsoriano Jul 1, 2025
af6a27b
chore: use a more readable option for bapi proxy methods
wobsoriano Jul 1, 2025
fa94227
chore: add initial changeset
wobsoriano Jul 1, 2025
8dcd607
chore: add machine_secret_key type to api keys api
wobsoriano Jul 1, 2025
5d78030
Merge remote-tracking branch 'origin/main' into rob/user-2264-m2m
wobsoriano Jul 1, 2025
7bb3eb8
chore: reuse header consts
wobsoriano Jul 1, 2025
424a5a4
chore: rename to machine secret
wobsoriano Jul 1, 2025
1dbd41b
chore: clean up
wobsoriano Jul 1, 2025
7c3063c
chore: add secret property to create method
wobsoriano Jul 1, 2025
9dab708
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 1, 2025
db38ca5
chore: remove machine secret type from api key creation
wobsoriano Jul 1, 2025
5ce88ee
chore: make secret property optional
wobsoriano Jul 2, 2025
c33e3fd
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 2, 2025
f9526af
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 3, 2025
cb6c822
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 7, 2025
68bcb7e
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 8, 2025
e900e13
chore: add machines BAPI endpoints
wobsoriano Jul 8, 2025
6c0fc64
chore: trigger rebuild
wobsoriano Jul 8, 2025
c1d1ae2
chore: remove unnecessary params
wobsoriano Jul 8, 2025
d53115d
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 19, 2025
d87f937
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 25, 2025
7ff0538
chore: remove unused properties
wobsoriano Jul 25, 2025
e844565
chore: improve machine secret check
wobsoriano Jul 25, 2025
017bb4b
fix required secrets
wobsoriano Jul 25, 2025
e26660e
fix required secrets
wobsoriano Jul 25, 2025
0f7387d
fix required secrets
wobsoriano Jul 25, 2025
f78ddcc
chore: remove removed properties
wobsoriano Jul 25, 2025
1492a1e
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Jul 28, 2025
a8c66e1
chore: remove name and claims from m2m tokens
wobsoriano Jul 28, 2025
201cb23
fix tests
wobsoriano Jul 28, 2025
64afde6
fix incorrect method in tests
wobsoriano Jul 28, 2025
b38465b
chore: update tests
wobsoriano Jul 29, 2025
18b76da
chore: update test descriptions
wobsoriano Jul 29, 2025
d91404c
chore: improve tests
wobsoriano Jul 29, 2025
37a3d65
chore: update changeset
wobsoriano Jul 29, 2025
1d69db8
chore: skip pub key init for machine tokens
wobsoriano Jul 29, 2025
b26bd76
chore: skip pub and secret key check for authenticate request with ma…
wobsoriano Jul 29, 2025
d609285
fix error handling
wobsoriano Jul 29, 2025
2e080db
chore: allow machine secrets in authenticateRequest
wobsoriano Jul 29, 2025
7055b8a
chore: remove unused export keyword
wobsoriano Jul 29, 2025
051dd85
chore: more tests
wobsoriano Jul 29, 2025
7371a32
chore: add missing secret key or machine secret error test
wobsoriano Jul 29, 2025
d96d436
Merge branch 'main' into rob/user-2264-m2m
wobsoriano Aug 4, 2025
6642321
chore: run dedupe
wobsoriano Aug 4, 2025
239b6ba
chore: add secret key
wobsoriano Aug 4, 2025
a8d310f
chore: do not destructure body params in m2m endpoints
wobsoriano Aug 4, 2025
2a74cf9
chore: do not destructure body params in machine endpoints
wobsoriano Aug 4, 2025
60b139a
chore: update tests
wobsoriano Aug 4, 2025
ad9a0ec
chore: Use machine secret key from created Clerk client
wobsoriano Aug 4, 2025
8ca8009
chore: update missing clerk instance key or machine secret key error
wobsoriano Aug 4, 2025
7569d95
formatting
wobsoriano Aug 4, 2025
ad5a0f9
fix authenticate request option types
wobsoriano Aug 4, 2025
b72e891
fix assertion
wobsoriano Aug 4, 2025
2b2ce8e
Add machine secret key to merged options
wobsoriano Aug 4, 2025
afb5923
allow custom machine secret key per method
wobsoriano Aug 5, 2025
01653ae
add jsdoc
wobsoriano Aug 5, 2025
e05d414
chore: separate backend api client machine secret key and options sec…
wobsoriano Aug 5, 2025
85e47f1
chore: clean up authorizationHeader
wobsoriano Aug 5, 2025
e2ede5f
clean up authenticate context
wobsoriano Aug 6, 2025
c98d1ed
clean up authenticate context
wobsoriano Aug 6, 2025
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
73 changes: 73 additions & 0 deletions .changeset/hot-tables-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
"@clerk/backend": minor
---

Adds machine-to-machine endpoints to the Backend SDK:

### Create M2M Tokens

A machine secret is required when creating M2M tokens.

```ts
const clerkClient = createClerkClient({
machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY
})

clerkClient.machineTokens.create({
secondsUntilExpiration: 3600,
})
```

### Revoke M2M Tokens

You can revoke tokens using either a machine secret or instance secret:

```ts
// Using machine secret
const clerkClient = createClerkClient({ machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY })
clerkClient.machineTokens.revoke({
m2mTokenId: 'mt_xxxxx',
revocationReason: 'Revoked by user',
})

// Using instance secret (default)
const clerkClient = createClerkClient({ secretKey: 'sk_xxxx' })
clerkClient.machineTokens.revoke({
m2mTokenId: 'mt_xxxxx',
revocationReason: 'Revoked by user',
})
```

### Verify M2M Tokens

You can verify tokens using either a machine secret or instance secret:

```ts
// Using machine secret
const clerkClient = createClerkClient({ machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY })
clerkClient.machineTokens.verifySecret({
secret: 'mt_secret_xxxxx',
})

// Using instance secret (default)
const clerkClient = createClerkClient({ secretKey: 'sk_xxxx' })
clerkClient.machineTokens.verifySecret({
secret: 'mt_secret_xxxxx',
})
```

To verify machine-to-machine tokens using when using `authenticateRequest()` with a machine secret, use the `machineSecret` option:

```ts
const clerkClient = createClerkClient({
machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY
})

const authReq = await clerkClient.authenticateRequest(c.req.raw, {
acceptsToken: 'machine_token',
})

if (authReq.isAuthenticated) {
// ... do something
}
```
7 changes: 5 additions & 2 deletions packages/backend/src/api/__tests__/MachineApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,18 @@ describe('MachineAPI', () => {
validateHeaders(async ({ request }) => {
const body = await request.json();
expect(body).toEqual({ name: 'Updated Machine' });
return HttpResponse.json(mockMachine);
return HttpResponse.json({
...mockMachine,
name: 'Updated Machine',
});
}),
),
);

const response = await apiClient.machines.update(updateParams);

expect(response.id).toBe(machineId);
expect(response.name).toBe('Test Machine');
expect(response.name).toBe('Updated Machine');
});

it('deletes a machine', async () => {
Expand Down
285 changes: 285 additions & 0 deletions packages/backend/src/api/__tests__/MachineTokenApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

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

describe('MachineTokenAPI', () => {
const m2mId = 'mt_xxxxx';
const m2mSecret = 'mt_secret_xxxxx';

const mockM2MToken = {
object: 'machine_to_machine_token',
id: m2mId,
subject: 'mch_xxxxx',
scopes: ['mch_1xxxxx', 'mch_2xxxxx'],
claims: { foo: 'bar' },
secret: m2mSecret,
revoked: false,
revocation_reason: null,
expired: false,
expiration: 1753746916590,
created_at: 1753743316590,
updated_at: 1753743316590,
};

describe('create', () => {
it('creates a m2m token using machine secret key in backend client', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.machineTokens.create({
secondsUntilExpiration: 3600,
});

expect(response.id).toBe(m2mId);
expect(response.secret).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

it('creates a m2m token using machine secret key option', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.machineTokens.create({
machineSecretKey: 'ak_xxxxx',
secondsUntilExpiration: 3600,
});

expect(response.id).toBe(m2mId);
expect(response.secret).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

it('does not accept an instance secret as authorization header', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'sk_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(() => {
return HttpResponse.json(
{
errors: [
{
message:
'The provided Machine Secret Key is invalid. Make sure that your Machine Secret Key is correct.',
code: 'machine_secret_key_invalid',
},
],
},
{ status: 401 },
);
}),
),
);

const errResponse = await apiClient.machineTokens.create().catch(err => err);

expect(errResponse.status).toBe(401);
expect(errResponse.errors[0].code).toBe('machine_secret_key_invalid');
expect(errResponse.errors[0].message).toBe(
'The provided Machine Secret Key is invalid. Make sure that your Machine Secret Key is correct.',
);
});
});

describe('revoke', () => {
const mockRevokedM2MToken = {
object: 'machine_to_machine_token',
id: m2mId,
subject: 'mch_xxxxx',
scopes: ['mch_1xxxxx', 'mch_2xxxxx'],
claims: { foo: 'bar' },
revoked: true,
revocation_reason: 'revoked by test',
expired: false,
expiration: 1753746916590,
created_at: 1753743316590,
updated_at: 1753743316590,
};

it('revokes a m2m token using machine secret', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
`https://api.clerk.test/m2m_tokens/${m2mId}/revoke`,
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
return HttpResponse.json(mockRevokedM2MToken);
}),
),
);

const response = await apiClient.machineTokens.revoke({
m2mTokenId: m2mId,
revocationReason: 'revoked by test',
});

expect(response.revoked).toBe(true);
expect(response.secret).toBeUndefined();
expect(response.revocationReason).toBe('revoked by test');
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

it('revokes a m2m token using instance secret', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'sk_xxxxx',
});

server.use(
http.post(
`https://api.clerk.test/m2m_tokens/${m2mId}/revoke`,
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx');
return HttpResponse.json(mockRevokedM2MToken);
}),
),
);

const response = await apiClient.machineTokens.revoke({
m2mTokenId: m2mId,
revocationReason: 'revoked using instance secret',
});

expect(response.revoked).toBe(true);
expect(response.secret).toBeUndefined();
expect(response.revocationReason).toBe('revoked by test');
});

it('requires a machine secret or instance secret to revoke a m2m token', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.post(
`https://api.clerk.test/m2m_tokens/${m2mId}/revoke`,
validateHeaders(() => {
return HttpResponse.json(mockRevokedM2MToken);
}),
),
);

const errResponse = await apiClient.machineTokens
.revoke({
m2mTokenId: m2mId,
revocationReason: 'revoked by test',
})
.catch(err => err);

expect(errResponse.status).toBe(401);
});
});

describe('verifySecret', () => {
it('verifies a m2m token using machine secret', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens/verify',
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.machineTokens.verifySecret({
secret: m2mSecret,
});

expect(response.id).toBe(m2mId);
expect(response.secret).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

it('verifies a m2m token using instance secret', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'sk_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens/verify',
validateHeaders(({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer sk_xxxxx');
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.machineTokens.verifySecret({
secret: m2mSecret,
});

expect(response.id).toBe(m2mId);
expect(response.secret).toBe(m2mSecret);
expect(response.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
expect(response.claims).toEqual({ foo: 'bar' });
});

it('requires a machine secret or instance secret to verify a m2m token', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens/verify',
validateHeaders(() => {
return HttpResponse.json(mockM2MToken);
}),
),
);

const errResponse = await apiClient.machineTokens
.verifySecret({
secret: m2mSecret,
})
.catch(err => err);

expect(errResponse.status).toBe(401);
});
});
});
Loading
Loading