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

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 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
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
72 changes: 72 additions & 0 deletions .changeset/hot-tables-worry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
"@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()

clerkClient.machineTokens.create({
machineSecret: 'ak_xxxxx',
})
```

### Revoke M2M Tokens

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

```ts
// Using machine secret
const clerkClient = createClerkClient()
clerkClient.machineTokens.revoke({
machineSecret: 'ak_xxxxx',
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()
clerkClient.machineTokens.verifySecret({
machineSecret: 'ak_xxxxx',
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()

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

if (authReq.isAuthenticated) {
// ... do something
}
```
255 changes: 255 additions & 0 deletions packages/backend/src/api/__tests__/MachineTokenApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
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', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
});

const createParams = {
machineSecret: 'ak_xxxxx',
secondsUntilExpiration: 3600,
};

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(createParams);

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(mockM2MToken);
}),
),
);

// @ts-expect-error - machineSecret is required
const response = await apiClient.machineTokens.create({}).catch(err => err);

expect(response.message).toBe('Missing machine secret.');
});
});

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',
});

const revokeParams = {
machineSecret: 'ak_xxxxx',
m2mTokenId: m2mId,
revocationReason: 'revoked by test',
};

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(revokeParams);

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',
});

const revokeParams = {
m2mTokenId: m2mId,
revocationReason: 'revoked by test',
};

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(revokeParams);

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',
});

const revokeParams = {
m2mTokenId: m2mId,
revocationReason: 'revoked by test',
};

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

const errResponse = await apiClient.machineTokens.revoke(revokeParams).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',
});

const verifyParams = {
machineSecret: 'ak_xxxxx',
secret: m2mSecret,
};

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(verifyParams);

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',
});

const verifyParams = {
secret: m2mSecret,
};

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(verifyParams);

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',
});

const verifyParams = {
secret: m2mSecret,
};

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

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

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