Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
d282239
feat(backend): Add M2M JWT token verification support
wobsoriano Feb 19, 2026
bdf4613
add changeset
wobsoriano Feb 19, 2026
32c1a7a
Merge branch 'release/core-2' into rob/USER-4704-m2m-jwts
wobsoriano Feb 19, 2026
450ffdc
feat(backend): Add tokenFormat parameter to M2M token creation API
wobsoriano Feb 19, 2026
a7ff348
chore: update changeset
wobsoriano Feb 19, 2026
a85cd5e
test(backend): Update M2M JWT test tokens to match production format
wobsoriano Feb 19, 2026
98b4733
Delete .changeset/cyan-wings-turn.md
wobsoriano Feb 19, 2026
b833f2b
chore: add default value to tokenFormat
wobsoriano Feb 19, 2026
848e5d5
Merge branch 'release/core-2' into rob/USER-4704-m2m-jwts
wobsoriano Feb 20, 2026
9740d2c
chore: improve jwt routing, prevent double decoding
wobsoriano Feb 20, 2026
c6d8547
Merge branch 'release/core-2' into rob/USER-4704-m2m-jwts
wobsoriano Feb 20, 2026
c92fe90
Merge branch 'release/core-2' into rob/USER-4704-m2m-jwts
wobsoriano Feb 23, 2026
858a501
chore: clean up
wobsoriano Feb 23, 2026
2f27712
refactor(backend): extract verifyDecodedJwtMachineToken to shared jwt…
wobsoriano Feb 23, 2026
86383d4
feat(backend): support JWT format in m2m.verify()
wobsoriano Feb 23, 2026
ce5bcbe
test(integration): use m2m.verify() for JWT format M2M test
wobsoriano Feb 23, 2026
dbec863
fix(backend): restore createToken behavior and update internal export…
wobsoriano Feb 23, 2026
6e96d46
chore: clean up verification functions
wobsoriano Feb 23, 2026
5dff71b
delete unused file
wobsoriano Feb 23, 2026
5fe771f
fix unit test
wobsoriano Feb 23, 2026
c00bec4
chore: clean up lint issues
wobsoriano Feb 23, 2026
3aeafdb
Merge branch 'release/core-2' into rob/USER-4704-m2m-jwts
wobsoriano Feb 23, 2026
7416624
fix: add missing jwtKey
wobsoriano Feb 23, 2026
6363564
fix: use correct subject in integration
wobsoriano Feb 23, 2026
3c8e1e3
chore: extend ClerkError
wobsoriano Feb 24, 2026
b5dacac
chore: use correct M2M ids
wobsoriano Feb 24, 2026
1615d5b
fix: build error
wobsoriano Feb 24, 2026
81c1a28
chore: update changeset
wobsoriano Feb 24, 2026
3e6bef2
chore: unit test scopes
wobsoriano Feb 24, 2026
ca124f6
chore: fix nextjs jwt locking for machine auth
wobsoriano Feb 24, 2026
3d8b68e
chore: add more tests
wobsoriano Feb 24, 2026
2f01baf
chore: apply coderabbit suggestions
wobsoriano Feb 24, 2026
f884e95
chore: fix protect machine auth handling
wobsoriano Feb 24, 2026
866d47b
fix e2e test
wobsoriano Feb 24, 2026
da9e999
chore: update changeset
wobsoriano Feb 24, 2026
f0522f3
Merge branch 'release/core-2' into rob/USER-4704-m2m-jwts
wobsoriano Feb 27, 2026
38d60fe
chore: fix merge conflicts
wobsoriano Feb 27, 2026
7888d87
fix tests
wobsoriano Feb 27, 2026
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
50 changes: 50 additions & 0 deletions .changeset/clever-ways-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@clerk/backend": minor
"@clerk/nextjs": minor
---

Added support for JWT token format when creating and verifying machine-to-machine (M2M) tokens. This enables fully **networkless verification** when using the public JWT key.

**Creating a JWT-format M2M token**

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

const m2mToken = await clerkClient.m2m.createToken({
tokenFormat: 'jwt',
})

console.log('M2M token created:', m2mToken.token)
```

**Verifying a token**

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

const authHeader = req.headers.get('Authorization')
const token = authHeader.slice(7)

const verified = await clerkClient.m2m.verify(token)

console.log('Verified M2M token:', verified)
```

**Networkless verification**

```ts
const clerkClient = createClerkClient({
jwtKey: process.env.CLERK_JWT_KEY
})

const authHeader = req.headers.get('Authorization')
const token = authHeader.slice(7)

const verified = await clerkClient.m2m.verify(token)

console.log('Verified M2M token:', verified)
```
30 changes: 25 additions & 5 deletions integration/tests/machine-auth/m2m.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,9 @@ test.describe('machine-to-machine auth @machine', () => {

app.get('/api/protected', async (req, res) => {
const token = req.get('Authorization')?.split(' ')[1];

try {
const m2mToken = await clerkClient.m2m.verifyToken({ token });
res.send('Protected response ' + m2mToken.id);
const m2mToken = await clerkClient.m2m.verify({ token });
res.send('Protected response ' + m2mToken.subject);
} catch {
res.status(401).send('Unauthorized');
}
Expand Down Expand Up @@ -150,7 +149,7 @@ test.describe('machine-to-machine auth @machine', () => {
},
});
expect(res.status()).toBe(200);
expect(await res.text()).toBe('Protected response ' + emailServerM2MToken.id);
expect(await res.text()).toBe('Protected response ' + emailServer.id);

// Analytics server can access primary API server after adding scope
await u.services.clerk.machines.createScope(analyticsServer.id, primaryApiServer.id);
Expand All @@ -165,9 +164,30 @@ test.describe('machine-to-machine auth @machine', () => {
},
});
expect(res2.status()).toBe(200);
expect(await res2.text()).toBe('Protected response ' + m2mToken.id);
expect(await res2.text()).toBe('Protected response ' + analyticsServer.id);
await u.services.clerk.m2m.revokeToken({
m2mTokenId: m2mToken.id,
});
});

test('verifies JWT format M2M token via local verification', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });

const client = createClerkClient({
secretKey: instanceKeys.get('with-api-keys').sk,
});
const jwtToken = await client.m2m.createToken({
machineSecretKey: emailServer.secretKey,
secondsUntilExpiration: 60 * 30,
tokenFormat: 'jwt',
});

const res = await u.page.request.get(app.serverUrl + '/api/protected', {
headers: { Authorization: `Bearer ${jwtToken.token}` },
});
expect(res.status()).toBe(200);
expect(await res.text()).toBe('Protected response ' + emailServer.id);
// JWT-format tokens are self-contained and not stored in BAPI, so revocation
// is not applicable — they expire naturally via the exp claim.
});
});
2 changes: 0 additions & 2 deletions packages/backend/src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ describe('subpath /internal exports', () => {
"decorateObjectWithResources",
"getAuthObjectForAcceptedToken",
"getAuthObjectFromJwt",
"getMachineTokenType",
"invalidTokenAuthObject",
"isMachineToken",
"isMachineTokenByPrefix",
"isMachineTokenType",
"isTokenTypeAccepted",
"makeAuthObjectSerializable",
"reverificationError",
Expand Down
196 changes: 195 additions & 1 deletion packages/backend/src/api/__tests__/M2MTokenApi.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { mockJwks, mockJwtPayload, mockM2MJwtPayload, signingJwks } from '../../fixtures';
import { signJwt } from '../../jwt/signJwt';
import { server, validateHeaders } from '../../mock-server';
import { M2MTokenApi } from '../endpoints/M2MTokenApi';
import { createBackendApiClient } from '../factory';
import { buildRequest } from '../request';

describe('M2MToken', () => {
const m2mId = 'mt_1xxxxxxxxxxxxx';
Expand Down Expand Up @@ -110,6 +114,130 @@ describe('M2MToken', () => {
'The provided Machine Secret Key is invalid. Make sure that your Machine Secret Key is correct.',
);
});

it('creates a jwt format m2m token', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
expect(body.token_format).toBe('jwt');
return HttpResponse.json({
...mockM2MToken,
token:
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Imluc194eHh4eCJ9.eyJqdGkiOiJtdF94eHh4eCIsInN1YiI6Im1jaF94eHh4eCIsImF1ZCI6WyJtY2hfMXh4eHh4IiwibWNoXzJ4eHh4eCJdLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgiLCJpYXQiOjE3NTM3NDMzMTYsImV4cCI6MTc1Mzc0NjkxNn0.signature',
});
}),
),
);

const response = await apiClient.m2m.createToken({
tokenFormat: 'jwt',
});
expect(response.id).toBe(m2mId);
expect(response.token).toMatch(/^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/);
expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']);
});

it('creates a jwt m2m token with custom claims and scopes', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

const customClaims = {
role: 'service',
tier: 'gold',
};

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
expect(body.token_format).toBe('jwt');
expect(body.claims).toEqual(customClaims);
return HttpResponse.json({
...mockM2MToken,
claims: customClaims,
token:
'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Imluc194eHh4eCJ9.eyJqdGkiOiJtdF94eHh4eCIsInN1YiI6Im1jaF94eHh4eCIsImF1ZCI6WyJtY2hfMXh4eHh4IiwibWNoXzJ4eHh4eCJdLCJzY29wZXMiOiJtY2hfMXh4eHh4IG1jaF8yeHh4eHgiLCJyb2xlIjoic2VydmljZSIsInRpZXIiOiJnb2xkIiwiaWF0IjoxNzUzNzQzMzE2LCJleHAiOjE3NTM3NDY5MTZ9.signature',
});
}),
),
);

const response = await apiClient.m2m.createToken({
tokenFormat: 'jwt',
claims: customClaims,
});

expect(response.id).toBe(m2mId);
expect(response.token).toMatch(/^[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/);
expect(response.claims).toEqual(customClaims);
expect(response.scopes).toEqual(['mch_1xxxxxxxxxxxxx', 'mch_2xxxxxxxxxxxxx']);
});

it('creates an opaque format m2m token when explicitly specified', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
expect(body.token_format).toBe('opaque');
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.m2m.createToken({
tokenFormat: 'opaque',
});

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
expect(response.token).toMatch(/^mt_.+$/);
});

it('creates an opaque m2m token by default when tokenFormat is omitted', async () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
machineSecretKey: 'ak_xxxxx',
});

server.use(
http.post(
'https://api.clerk.test/m2m_tokens',
validateHeaders(async ({ request }) => {
expect(request.headers.get('Authorization')).toBe('Bearer ak_xxxxx');
const body = (await request.json()) as Record<string, unknown>;
// tokenFormat defaults to 'opaque' when omitted
expect(body.token_format).toBe('opaque');
return HttpResponse.json(mockM2MToken);
}),
),
);

const response = await apiClient.m2m.createToken({
secondsUntilExpiration: 3600,
});

expect(response.id).toBe(m2mId);
expect(response.token).toBe(m2mSecret);
});
});

describe('revoke', () => {
Expand Down Expand Up @@ -283,6 +411,72 @@ describe('M2MToken', () => {
});
});

async function createSignedM2MJwt(payload = mockM2MJwtPayload) {
const { data } = await signJwt(payload, signingJwks, {
algorithm: 'RS256',
header: { typ: 'JWT', kid: 'ins_2GIoQhbUpy0hX7B2cVkuTMinXoD' },
});
return data!;
}

describe('verify — JWT format', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(mockJwtPayload.iat * 1000));
});
afterEach(() => {
vi.useRealTimers();
});

it('verifies a JWT M2M token using secretKey (JWKS lookup)', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
{ secretKey: 'sk_test_xxxxx', apiUrl: 'https://api.clerk.test', skipJwksCache: true },
);

server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => HttpResponse.json(mockJwks)),
),
);

const jwtToken = await createSignedM2MJwt();
const result = await m2mApi.verify({ token: jwtToken });

expect(result.id).toBe('mt_2xKa9Bgv7NxMRDFyQw8LpZ3cTmU1vHjE');
expect(result.subject).toBe('mch_2vYVtestTESTtestTESTtestTESTtest');
expect(result.scopes).toEqual(['mch_1xxxxx', 'mch_2xxxxx']);
});

it('throws when JWT signature cannot be verified', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
{ secretKey: 'sk_test_xxxxx', apiUrl: 'https://api.clerk.test', skipJwksCache: true },
);

server.use(
http.get(
'https://api.clerk.test/v1/jwks',
validateHeaders(() => HttpResponse.json({ keys: [] })),
),
);

const jwtToken = await createSignedM2MJwt();
await expect(m2mApi.verify({ token: jwtToken })).rejects.toThrow();
});

it('throws when no secretKey or jwtKey is provided', async () => {
const m2mApi = new M2MTokenApi(
buildRequest({ apiUrl: 'https://api.clerk.test', skipApiVersionInUrl: true, requireSecretKey: false }),
{ apiUrl: 'https://api.clerk.test' },
);

const jwtToken = await createSignedM2MJwt();
await expect(m2mApi.verify({ token: jwtToken })).rejects.toThrow('Failed to resolve JWK during verification');
});
});

describe('list', () => {
const machineId = 'mch_1xxxxxxxxxxxxx';
const mockM2MTokenList = {
Expand Down
Loading
Loading