Skip to content

Commit 1c45f37

Browse files
luist18Bekacru
authored andcommitted
feat(jwt): allow custom jwks endpoint (#6269)
1 parent 53a742d commit 1c45f37

File tree

5 files changed

+134
-2
lines changed

5 files changed

+134
-2
lines changed

docs/content/docs/plugins/jwt.mdx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,57 @@ jwt({
239239
})
240240
```
241241

242+
### Custom JWKS Path
243+
244+
By default, the JWKS endpoint is available at `/jwks`. You can customize this path using the `jwksPath` option.
245+
246+
This is useful when you need to:
247+
- Follow OAuth 2.0/OIDC conventions (e.g., `/.well-known/jwks.json`)
248+
- Match existing API conventions in your application
249+
- Avoid path conflicts with other endpoints
250+
251+
**Server Configuration:**
252+
253+
```ts title="auth.ts"
254+
jwt({
255+
jwks: {
256+
jwksPath: "/.well-known/jwks.json"
257+
}
258+
})
259+
```
260+
261+
**Client Configuration:**
262+
263+
When using a custom `jwksPath` on the server, you **MUST** configure the client with the same path:
264+
265+
```ts title="auth-client.ts"
266+
import { createAuthClient } from "better-auth/client"
267+
import { jwtClient } from "better-auth/client/plugins"
268+
269+
export const authClient = createAuthClient({
270+
plugins: [
271+
jwtClient({
272+
jwks: {
273+
jwksPath: "/.well-known/jwks.json" // Must match server configuration
274+
}
275+
})
276+
]
277+
})
278+
```
279+
280+
Then you can use the `jwks()` method as usual:
281+
282+
```ts
283+
const { data, error } = await authClient.jwks()
284+
if (data) {
285+
// Use data.keys to verify JWT tokens
286+
}
287+
```
288+
289+
<Callout type="warning">
290+
The `jwksPath` configured on the client **MUST** match the server configuration. If they don't match, the client will not be able to fetch the JWKS.
291+
</Callout>
292+
242293
### Custom Signing
243294

244295
This is an advanced feature. Configuration outside of this plugin **MUST** be provided.
Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,35 @@
11
import type { BetterAuthClientPlugin } from "@better-auth/core";
2+
import type { JSONWebKeySet } from "jose";
23
import type { jwt } from "./index";
34

4-
export const jwtClient = () => {
5+
interface JwtClientOptions {
6+
jwks?: {
7+
/**
8+
* The path of the endpoint exposing the JWKS.
9+
* Must match the server configuration.
10+
*
11+
* @default /jwks
12+
*/
13+
jwksPath?: string;
14+
};
15+
}
16+
17+
export const jwtClient = (options?: JwtClientOptions) => {
18+
const jwksPath = options?.jwks?.jwksPath ?? "/jwks";
19+
520
return {
621
id: "better-auth-client",
722
$InferServerPlugin: {} as ReturnType<typeof jwt>,
23+
pathMethods: {
24+
[jwksPath]: "GET",
25+
},
26+
getActions: ($fetch) => ({
27+
jwks: async (fetchOptions?: any) => {
28+
return await $fetch<JSONWebKeySet>(jwksPath, {
29+
method: "GET",
30+
...fetchOptions,
31+
});
32+
},
33+
}),
834
} satisfies BetterAuthClientPlugin;
935
};

packages/better-auth/src/plugins/jwt/index.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,25 @@ export const jwt = (options?: JwtOptions | undefined) => {
3636
);
3737
}
3838

39+
const jwksPath = options?.jwks?.jwksPath ?? "/jwks";
40+
if (
41+
typeof jwksPath !== "string" ||
42+
jwksPath.length === 0 ||
43+
!jwksPath.startsWith("/") ||
44+
jwksPath.includes("..")
45+
) {
46+
throw new BetterAuthError(
47+
"jwks_config",
48+
"jwksPath must be a non-empty string starting with '/' and not contain '..'",
49+
);
50+
}
51+
3952
return {
4053
id: "jwt",
4154
options,
4255
endpoints: {
4356
getJwks: createAuthEndpoint(
44-
"/jwks",
57+
jwksPath,
4558
{
4659
method: "GET",
4760
metadata: {

packages/better-auth/src/plugins/jwt/jwt.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,3 +743,36 @@ describe("jwt - custom adapter", async () => {
743743
expect(storage.length).toBe(1);
744744
});
745745
});
746+
747+
describe("jwt - custom jwksPath", async () => {
748+
it("should use custom jwksPath when specified", async () => {
749+
const { auth } = await getTestInstance({
750+
plugins: [
751+
jwt({
752+
jwks: {
753+
jwksPath: "/.well-known/jwks.json",
754+
},
755+
}),
756+
],
757+
});
758+
759+
const client = createAuthClient({
760+
plugins: [jwtClient({ jwks: { jwksPath: "/.well-known/jwks.json" } })],
761+
baseURL: "http://localhost:3000/api/auth",
762+
fetchOptions: {
763+
customFetchImpl: async (url, init) => {
764+
return auth.handler(new Request(url, init));
765+
},
766+
},
767+
});
768+
769+
const jwks = await client.jwks();
770+
expect(jwks.error).toBeNull();
771+
expect(jwks.data?.keys).toBeDefined();
772+
expect(jwks.data?.keys.length).toBeGreaterThan(0);
773+
774+
// Verify old /jwks endpoint is not found
775+
const oldJwks = await client.$fetch<JSONWebKeySet>("/jwks");
776+
expect(oldJwks.error?.status).toBe(404);
777+
});
778+
});

packages/better-auth/src/plugins/jwt/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ export interface JwtOptions {
4242
* @default 2592000 (30 days)
4343
*/
4444
gracePeriod?: number;
45+
/**
46+
* The path of the endpoint exposing the JWKS.
47+
* When set, this replaces the default /jwks endpoint.
48+
* The old endpoint will return 404.
49+
*
50+
* @default /jwks
51+
* @example "/.well-known/jwks.json"
52+
*/
53+
jwksPath?: string;
4554
}
4655
| undefined;
4756

0 commit comments

Comments
 (0)