[PR #4748] [CLOSED] feat(jwt) - refactor and plugin completeness #5559

Closed
opened 2026-03-13 12:27:33 -05:00 by GiteaMirror · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/better-auth/better-auth/pull/4748
Author: @LightTab2
Created: 9/18/2025
Status: Closed

Base: canaryHead: jwt/feat


📝 Commits (10+)

  • 8186e5e Incomplete draft
  • 21c8190 createJwk uses createJwkInternal
  • 7916629 adds Awaitable whenever possible
  • 5289af1 fixes allowNoKeyId option
  • a17c5ff allows {JSONWebKeySets} as a remote JWKS fetch result
  • 9ff1fe3 oidcProvider uses signJwt in place of getJwtToken
  • f376eaf feat: create internal versions of verifyJwt functions that include custom clock skew tolerance
  • 4244e7e fix: allow custom iat to be set when signing
  • 865224f fix: add alg to the exported JOSE key pairs
  • 5fb4174 feat: more tests and more rigour to the previous tests

📊 Changes

9 files changed (+5152 additions, -1031 deletions)

View changed files

📝 packages/better-auth/src/plugins/jwt/adapter.ts (+127 -7)
📝 packages/better-auth/src/plugins/jwt/index.ts (+642 -66)
packages/better-auth/src/plugins/jwt/jwk.ts (+675 -0)
📝 packages/better-auth/src/plugins/jwt/jwt.test.ts (+2212 -595)
📝 packages/better-auth/src/plugins/jwt/sign.ts (+301 -94)
📝 packages/better-auth/src/plugins/jwt/types.ts (+660 -117)
📝 packages/better-auth/src/plugins/jwt/utils.ts (+228 -137)
packages/better-auth/src/plugins/jwt/verify.ts (+293 -0)
📝 packages/better-auth/src/plugins/oidc-provider/index.ts (+14 -15)

📄 Description

Quick glossary:

JWT - JSON Web Token; contains Data, Signature and Claims to verify its authenticity.
JWK - JSON Web Key; a key from an asymmetric key pair used to sign JWT Payloads (Data + Claims) or to verify and decode JWT.
JWKS - JSON Web Key Set; a set of all known JWKs, might contain only public keys or private keys too. JWT might contain a Protected Header with fields that allow to identify which key should be used to verify the token.

Reasons

JWT plugin is very lacking, and dealing with more complex use cases requires writing your own implementations.

There are some more flaws:

  • Lack of helper functions for hooks or endpoints in custom plugins. One might want to have easy access to, for example, JWT verification.
  • There is no endpoint to create a JWK.
  • The database is queried every time a JWT is generated.
  • Can't use both the current JWKS from the database and multiple remote JWKS. Also, there is no need for a custom remote sign function if the keys are fetched instead. I have talked to @dvanmali, who implemented this, to ensure my solution can substitute the current system.
  • Inconsistent and misleading function/type naming.
  • defineCustomPayload and getSubject lack testing.
  • Various options can be passed to functions/endpoints that could easily sow confusion or invalid configurations; some fields left unprotected might impose a security risk in some very rare use cases.
  • There is no way to change the key that is used to sign session JWT other than creating a new one, as the latest is always used. Furthermore, if a new key is created, previous tokens are invalid because tokens are verified against the latest key, not the entire JWKS.
  • There is no server-only endpoint to import external JWKs into the database.
  • Private keys can have their encryption disabled, but changing this option does not reencrypt all previously created keys.
  • JWT cannot be revoked.
  • You cannot have stricter or more lenient JWT verification without writing your own implementation. Can't add maxTokenAge or modify clockTolerance.
  • Extremely careless users might create endpoints that allow for malicious JWT crafting without realising.

This is an attempt to refactor the JWT plugin and provide it with a complete feature set for most common uses. The idea is that you won't ever need to use JOSE and can write your own custom Better Auth plugin instead for more complex usages like creating subsystems operating on JWT signed data. I have aimed at a good tradeoff of ease of use and enforcement of good practices; more about that in the details section.

Features

  • Refactor names for clearer intent and consistency. See breaking changes section

  • Helper functions to use in hooks/custom plugins:

    • createJwk
    • getJwk - fetch JWK from the database by id
    • importJwk - import JOSE's CryptoKey into the database
    • revokeJwk - marks JWK as revoked, and it cannot be used to sign/verify tokens anymore
    • getSessionJwt - renamed getJwtToken
    • verifyJwt - verify JWT with key from JWKS matching the JWT's Protected Header
    • verifyJwtWithKey - verify JWT with explicitly provided Key ID or CryptoKey
    • decodeJwt - return the JWT Payload without verification
    • revokeJwt - marks JWT as revoked, and its verification will fail
  • Caching JWKS

  • Remote JWKS

  • Optional Key Rotation

  • Optional JWT revocation system

  • Optional CRON for checking remote JWKs revocations

  • Functions refactor for options argument minimalization

  • Encrypt/Decrypt private keys in the database on plugin init according to plugin configuration

  • An option to change the default key for Session JWT signing by setting its Key ID.

  • Docs:

    • How to use helper functions in hooks/custom plugin endpoints
    • New options and changes to exiting ones
    • Remote JWKS fetching and warning about very rare id conflict
    • Warning about claims removal from data when signing
    • New server-only endpoints:
      • /verify-jwt
      • /sign-jwt - API changed a little
      • /revoke-jwt
      • /create-jwk
      • /decode-jwt
      • /import-jwk
      • /revoke-jwk
    • New endpoints:
      • /jwksAll - returns a JWKS with all local and remote non-revoked JWKs
      • /revoked - returns a JWKS with all the local and remote revoked JWKs
  • Tests:

    • Every test is now a standalone test case
    • defineSessionJwtData and defineSessionJwtSubject missing test cases
    • Reencryption ⚠️Only partially tested. Full test requires a DB instance that survives across two test instances
    • Endpoints
    • Custom plugins
    • Key rotation
    • Remote JWKS
    • JWKS cache
    • JWT revocation

Almost every function now contains a JSDoc description, even if it's only used internally. This is to hopefully speed up this PR merge and future collaborations.

Breaking Changes

Types:

  • {JwtOptions} -> {JwtPluginOptions} [renamed]:
    • remoteUrl: string -> remoteJwks: (() => Awaitable<Jwk[] | JSONWebKeySet>)[] [renamed, changed].
    • definePayload -> defineSessionJwtData [renamed].
    • getSubject -> defineSessionJwtSubject [renamed].
    • sign -> sign [removed].
  • {JWKOptions} -> {JwkOptions} [renamed].
  • {JWSAlgorithms} -> {JwkAlgorithm} [renamed].
  • {Jwk} has optional fields alg and crv removed, these can be deduced from publicKey [changed].

Functions:

  • getJwtToken -> getSessionJwt [renamed].
  • signJWT(ctx, config: {options?, payload}) -> signJwt(ctx, data, options?: {jwk?, claims?}) [renamed, changed].
  • createJwk's parameter options -> jwkOpts, its type {JwtPluginOptions} ->{JwkOptions} [changed].
  • generateExportedKeyPair is no longer exported.

Endpoints:

  • sign-jwt is expecting body: { payload: JWTPayload; overrideOptions?: JwtOptions; } -> body: { data: Record<string, any>; jwk?: string | JWK; claims?: CustomJwtClaims; } [changed].

Functionality:

  • Remote signing changed to remote key fetching; the signing function should be universal.
  • Data is signed instead of JWT Payloads. Changing JWT Claims is done by another argument. iss ("Issuer") Claims cannot be changed.

Details

New signing flow

Currently somebody can pass data to /sign-jwt that contains JWT Claims and be completely unaware of this. This could lead to no consequences, data loss, an invalid JWT, or a dangerous JWT. Consider somebody adding a custom additional field exp to session that is set to 1800000000. Now the getJwtToken returns a token that is valid for almost 2 years without a warning. It could get even worse if someone carelessly created an endpoint that allows clients to sign any JWT Payload, thinking they are allowed to only sign any Data...

To address this, I propose that Data and Claims are separate arguments for the signing function/endpoint. In my approach, if someone called getSessionJwt (getJwtToken after rename) with a session that has added an exp field, they would have their JWT Payload curated and receive this message:

WARN [Better Auth]: Signing JWT: Removing "exp" field from the data to be signed (affects original record!). This is a reserved field. If you need to edit this **JWT Claim**, provide its override in "signJwt" function's "options.claims" argument.

If somebody wants to change a JWT Claim, they need to state their intent explicitly by providing claims argument to signJwt like this:

const jwt = await signJwt(ctx, data, { claims: { exp: 1800000000 } });

It is also possible to remove default JWT Claims from the payload:

const jwt = await signJwt(ctx, data, { claims: { aud: null, iat: null, exp: null, iss: null } });

This is valid in signJwt, however default plugin endpoints /sign-jwt and /verify-jwt will respond with a "BAD_REQUEST" if you try to create/verify infinite JWT, which is missing the exp claim.

Per-case verifying

It is possible to provide {JwtVerifyOptions} to:

async function verifyJwt(ctx, jwt, options);
async function verifyJwtWithKey(ctx, jwt, jwk, options);

They allow you to set expected JWT Claims, for example maxExpirationTime, so one has a way to expect different JWTs in different places.

Minimal options

Current createJwk takes {JwtOptions} (now {JwtPluginOptions}), which have many options that are not used there. Someone has set disableKeyEncryption in their plugin configuration. Now they pass custom options to createJwk, so a key pair with a different algorithm is created, so they change only these settings, and it comes out encrypted.

Not a big deal? Consider somebody having set an algorithm for JWK generation in plugin configuration. They are passing definePayload and no other options to getJwtToken in one place and having "normal" getJwtToken elsewhere. They have just purged their jwks table, for reasons. Now if they have called first getJwtToken, because there is no key, a JWK pair with default configuration will be generated. If they call the the second one, a JWK pair with current plugin configuration will be created.

This all comes from carelessness, but I believe giving an open flame to children is careless in itself.

However, there is a reason for this. We cannot access plugin configuration in these functions before the plugin has been initialized. JWT Plugin endpoints are defined before the plugin is initialized. Therefore, we must pass full plugin configuration if we want to access disableKeyEncryption setting. This is why I opted for a rather ugly solution like this:

export async function createJwkInternal(
	ctx: GenericEndpointContext,
	jwksOpts?: JwksOptions,
): Promise<Jwk> {
	//...logic
}

export async function createJwk(
	ctx: GenericEndpointContext,
	jwkOpts?: JwkOptions,
): Promise<Jwk> {
	const jwksOpts = getJwtPluginOptions(ctx.context)?.jwks || { keyPairConfig: jwkOpts };

	return createJwkInternal(ctx, jwksOpts);
}

There are internal functions to be called in the plugin's endpoints that are not exported in index.ts and exported versions with minimal options needed.

[functionName]Internal why do they exist?

You didn't read the paragraph above. They're analogous to createJwkInternal example.

Custom Plugins

The idea is to be able to create custom plugins instead of custom JOSE implementations like this:

const customPlugin = () => {
	return {
		id: "customJwt",
		endpoints: {
			customSignJwt: createAuthEndpoint(
				"/custom-sign",
				{
					method: "POST",
					metadata: {
						SERVER_ONLY: true,
						$Infer: {
							body: {} as {
								data: Record<string, any>;
							},
						},
					},
					body: z.object({
						data: z.record(z.string(), z.any()),
					}),
				},
				async (ctx) => {
					const body = ctx.body;
					return ctx.json({
						token: await signJwt(ctx, body.data),
					});
				},
			),
			customVerifyJwt: createAuthEndpoint(
				"/custom-verify",
				{
					method: "POST",
					metadata: {
					        SERVER_ONLY: true,
						$Infer: {
							body: {} as {
								token: string;
							},
						},
					},
					body: z.object({
						token: z.string(),
					}),
				},
				async (ctx) => {
					const body = ctx.body;
					return ctx.json({
						data: await verifyJwt(ctx, body.token),
					});
				},
			),
		},
	} satisfies BetterAuthPlugin;
};

Alternative subsolutions to consider

Cache as module-level variable

The plugin can theoretically be created multiple times, and I can't assume no one ever will. This is why the current cache implementation idea is to store it in options, but this solution exposes it pointlessly to the user. There might be unknown unknowns to this as well. Went with the module-defined variable after all. Since all the Internal versions of functions were created to make options "more immutable", this would contradict this effort. My intuition also says it's the better approach, but if you know why, I'd like to know.

Issuer Claim should be mutable

Changing iss ("Issuer") JWT Claim is not permitted, but should it be? What's the use case? Is it a good practice to do so?

JWT Data should be Deep Cloned

I am issuing warnings if the data will be modified in a possibly unexpected way, that is, removing JWT Claims. I think this is sufficient. The data is also modified when setting/changing custom JWT Claims without a warning. Cloning probably would not have a significant performance impact in most cases. But isn't the data change to reflect the JWT content desired? Need an opinion. After some thinking, it seems like a bad idea to modify any data in place. It is deep cloned by JOSE later, so I went with a simple:

const { aud, exp, iat, iss, jti, nbf, sub, ...sanitizedData } = data;
return sanitizedData;

{JwtVerifyOptions} should not be part of /verify-jwt endpoint

One might believe /verify-jwt endpoint should only adhere to current plugin configuration, and other use cases should be covered by custom plugin creation. This is how I believe getSessionJwt should act, as it's basically a helper function that wraps signJwt, so if one needs to change options for this, they should create a custom endpoint instead. I think the same rule for /verify-jwt would be overstrict, but indeed could prevent some carelessness.


No one cares

Your PR is too big and its scope is unclear and it doesn't concern real life use cases and it has too many breaking changes and it differs from our vision and its benefits are insignificant. And at the moment we have Better Things To Do™ and nobody ever Started Screaming in need of your "fixes". Oh, and your code is just bad.

This is fine. If you ever require any features from this PR, feel free to grab it and author it as your own; don't even bother asking me for permission. I don't do this for the glory.

This PR is not divided into smaller ones because the current jwt has started to become a mess, and fixing it requires some breaking changes, so I think it's better to get it over with in one fell swoop.


Summary by cubic

Refactors the JWT plugin with complete key management, safer signing/verification, JWKS caching with remote keys, and controlled private key encryption. Removes remote signing and improves test coverage.

  • New Features

    • Server-only endpoints: /verify-jwt, /sign-jwt, /create-jwk, /import-jwk
    • Exported helpers: createJwk, getJwk, importJwk, getSessionJwt, signJwt, verifyJwt, verifyJwtWithKey
    • Safer signing: strips JWT claims from data; explicit overrides via claims; endpoints block infinite tokens without exp
    • Flexible verify options: allowed issuers/audiences, expected subject/type, max token age, kid checks, clock skew tolerance
    • Private key encryption synced on plugin init; new encrypt/decrypt utilities
    • JWKS: adapter adds getKeyById/importKey/updateKeysEncryption/revoke; caching and remote JWKS support; responses include alg/crv
  • Migration

    • Renames: JwtOptions→JwtPluginOptions, JWKOptions→JwkOptions, JWSAlgorithms→JwkAlgorithm, VerifyJwtOptions→JwtVerifyOptions, CustomJwtClaims→JwtCustomClaims
    • API changes: getJwtToken→getSessionJwt, signJWT(ctx,{...})→signJwt(ctx, data, { jwk?, claims? })
    • Session hooks: definePayload→defineSessionJwtData, getSubject→defineSessionJwtSubject
    • Endpoint change: /sign-jwt body now { data, jwk?, claims? }
    • Remote signing removed; remoteUrl→remoteJwks providers for fetching and merging remote keys

Written for commit a18919879e. Summary will update automatically on new commits.


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/better-auth/better-auth/pull/4748 **Author:** [@LightTab2](https://github.com/LightTab2) **Created:** 9/18/2025 **Status:** ❌ Closed **Base:** `canary` ← **Head:** `jwt/feat` --- ### 📝 Commits (10+) - [`8186e5e`](https://github.com/better-auth/better-auth/commit/8186e5eea00546cf8a9db393c443e1e97eada4b2) Incomplete draft - [`21c8190`](https://github.com/better-auth/better-auth/commit/21c8190a1bb759df7304382d6be5dc319465d689) `createJwk` uses `createJwkInternal` - [`7916629`](https://github.com/better-auth/better-auth/commit/7916629e8ebd99d59fdbc5bc552b88838377bc60) adds `Awaitable` whenever possible - [`5289af1`](https://github.com/better-auth/better-auth/commit/5289af1f7d50e2d46870e25cee1f254f7c72ca02) fixes `allowNoKeyId` option - [`a17c5ff`](https://github.com/better-auth/better-auth/commit/a17c5ffb7a69383b10616735d7af128bfbdd7886) allows {`JSONWebKeySets`} as a remote JWKS fetch result - [`9ff1fe3`](https://github.com/better-auth/better-auth/commit/9ff1fe386bc18f5926cb7c0acd0f0349377fc5b5) oidcProvider uses `signJwt` in place of `getJwtToken` - [`f376eaf`](https://github.com/better-auth/better-auth/commit/f376eaf1f3c762083a43c5dbdc1b788de066eb9e) feat: create internal versions of `verifyJwt` functions that include custom clock skew tolerance - [`4244e7e`](https://github.com/better-auth/better-auth/commit/4244e7ea79ae3f3d0ebbf43af94f2e8efd66184a) fix: allow custom `iat` to be set when signing - [`865224f`](https://github.com/better-auth/better-auth/commit/865224f7357ed6c12a0ac69ad9a83bbf3b0b6462) fix: add `alg` to the exported JOSE key pairs - [`5fb4174`](https://github.com/better-auth/better-auth/commit/5fb41743f2b48061d5d98c969c10974b7d5f6895) feat: more tests and more rigour to the previous tests ### 📊 Changes **9 files changed** (+5152 additions, -1031 deletions) <details> <summary>View changed files</summary> 📝 `packages/better-auth/src/plugins/jwt/adapter.ts` (+127 -7) 📝 `packages/better-auth/src/plugins/jwt/index.ts` (+642 -66) ➕ `packages/better-auth/src/plugins/jwt/jwk.ts` (+675 -0) 📝 `packages/better-auth/src/plugins/jwt/jwt.test.ts` (+2212 -595) 📝 `packages/better-auth/src/plugins/jwt/sign.ts` (+301 -94) 📝 `packages/better-auth/src/plugins/jwt/types.ts` (+660 -117) 📝 `packages/better-auth/src/plugins/jwt/utils.ts` (+228 -137) ➕ `packages/better-auth/src/plugins/jwt/verify.ts` (+293 -0) 📝 `packages/better-auth/src/plugins/oidc-provider/index.ts` (+14 -15) </details> ### 📄 Description ### Quick glossary: **JWT** - JSON Web Token; contains **Data**, **Signature** and **Claims** to verify its authenticity. **JWK** - JSON Web Key; a **key** from an **asymmetric key pair** used to sign **JWT Payloads** (**Data** + **Claims**) or to verify and decode **JWT**. **JWKS** - JSON Web Key Set; a set of all known **JWK**s, might contain only **public keys** or **private keys** too. **JWT** might contain a **Protected Header** with fields that allow to identify which **key** should be used to **verify the token**. # Reasons **JWT plugin** is very lacking, and dealing with more complex use cases requires writing your **own implementations**. There are some more flaws: - Lack of **helper functions** for hooks or endpoints in custom plugins. One might want to have easy access to, for example, **JWT verification**. - There is no endpoint to **create** a **JWK**. - The database is **queried** every time a **JWT** is generated. - Can't use both the current **JWKS** from the database and multiple **remote JWKS**. Also, there is no need for a custom remote `sign` function if the keys are fetched instead. I have talked to @dvanmali, who implemented this, to ensure my solution can substitute the current system. - Inconsistent and misleading function/type naming. - `defineCustomPayload` and `getSubject` lack testing. - Various options can be passed to functions/endpoints that could easily sow confusion or invalid configurations; some fields left unprotected might impose a **security risk** in some very rare use cases. - There is no way to change the key that is used to sign **session JWT** other than creating a new one, as the latest is always used. Furthermore, if a new key is created, previous tokens are **invalid** because tokens are verified against the latest key, not the entire **JWKS**. - There is no server-only endpoint to import **external JWK**s into the database. - Private keys can have their **encryption disabled**, but changing this option does **not** reencrypt all previously created keys. - **JWT** cannot be revoked. - You cannot have stricter or more lenient **JWT verification** without writing your own implementation. Can't add `maxTokenAge` or modify `clockTolerance`. - ### **Extremely careless** users might create endpoints that allow for **malicious JWT** crafting without realising. This is an attempt to refactor the **JWT plugin** and provide it with a complete feature set for most common uses. The idea is that you **won't ever need to use *JOSE*** and can write your **own custom Better Auth plugin instead** for more complex usages like creating subsystems operating on **JWT** signed data. I have aimed at a good tradeoff of ease of use and enforcement of good practices; more about that in the **details** section. ## Features - [x] Refactor names for clearer intent and consistency. See **breaking changes** section - [ ] Helper functions to use in hooks/custom plugins: - [x] `createJwk` - [x] `getJwk` - fetch **JWK** from the database by **id** - [x] `importJwk` - import *JOSE*'s `CryptoKey` into the database - [x] `revokeJwk` - marks **JWK** as revoked, and it cannot be used to sign/verify tokens anymore - [x] `getSessionJwt` - renamed `getJwtToken` - [x] `verifyJwt` - verify **JWT** with key from **JWKS** matching the **JWT**'s **Protected Header** - [x] `verifyJwtWithKey` - verify **JWT** with explicitly provided **Key ID** or **CryptoKey** - [ ] `decodeJwt` - return the **JWT Payload** without verification - ~~`revokeJwt` - marks **JWT** as revoked, and its verification will fail~~ - [x] Caching **JWKS** - [x] Remote **JWKS** - ~~Optional Key Rotation~~ - ~~Optional **JWT** revocation system~~ - ~~Optional CRON for checking remote **JWK**s revocations~~ - [x] Functions refactor for options argument minimalization - [x] Encrypt/Decrypt **private keys** in the database on plugin init according to plugin configuration - [x] An option to change the **default key** for **Session JWT** signing by setting its **Key ID**. - [ ] Docs: - [ ] How to use helper functions in hooks/custom plugin endpoints - [ ] New options and changes to exiting ones - [ ] Remote **JWKS** fetching and warning about very rare id conflict - [ ] Warning about claims removal from data when signing - [ ] New **server-only** endpoints: - [ ] `/verify-jwt` - [ ] `/sign-jwt` - API changed a little - ~~`/revoke-jwt`~~ - [ ] `/create-jwk` - [ ] `/decode-jwt` - [ ] `/import-jwk` - [ ] `/revoke-jwk` - [ ] New endpoints: - [ ] `/jwksAll` - returns a **JWKS** with all **local** and **remote** **non-revoked JWK**s - [ ] `/revoked` - returns a **JWKS** with all the **local** and **remote** **revoked JWK**s - [x] Tests: - [x] Every test is now a standalone test case - [x] `defineSessionJwtData` and `defineSessionJwtSubject` missing test cases - [x] Reencryption **⚠️Only partially tested.** Full test requires a DB instance that survives across two test instances - [x] Endpoints - [x] Custom plugins - ~~Key rotation~~ - [x] Remote **JWKS** - [x] **JWKS** cache - ~~**JWT** revocation~~ Almost every function now contains a **JSDoc description**, even if it's only used internally. This is to hopefully speed up this PR merge and future collaborations. ## Breaking Changes Types: - {`JwtOptions`} -> {`JwtPluginOptions`} [renamed]: - `remoteUrl: string` -> `remoteJwks: (() => Awaitable<Jwk[] | JSONWebKeySet>)[]` [renamed, changed]. - `definePayload` -> `defineSessionJwtData` [renamed]. - `getSubject` -> `defineSessionJwtSubject` [renamed]. - `sign` -> ~~`sign`~~ [removed]. - {`JWKOptions`} -> {`JwkOptions`} [renamed]. - {`JWSAlgorithms`} -> {`JwkAlgorithm`} [renamed]. - {`Jwk`} has optional fields `alg` and `crv` removed, these can be deduced from `publicKey` [changed]. Functions: - `getJwtToken` -> `getSessionJwt` [renamed]. - `signJWT(ctx, config: {options?, payload})` -> `signJwt(ctx, data, options?: {jwk?, claims?})` [renamed, changed]. - `createJwk`'s parameter `options` -> `jwkOpts`, its type {`JwtPluginOptions`} ->{`JwkOptions`} [changed]. - `generateExportedKeyPair` is no longer exported. Endpoints: - `sign-jwt` is expecting `body: { payload: JWTPayload; overrideOptions?: JwtOptions; }` -> `body: { data: Record<string, any>; jwk?: string | JWK; claims?: CustomJwtClaims; }` [changed]. Functionality: - **Remote signing** changed to **remote key fetching**; the signing function should be universal. - **Data** is signed instead of **JWT Payloads**. Changing **JWT Claims** is done by another argument. `iss` ("Issuer") **Claims** cannot be changed. ## Details ### New signing flow Currently somebody can pass data to `/sign-jwt` that contains **JWT Claims** and be completely unaware of this. This could lead to no consequences, data loss, an **invalid JWT**, or a **dangerous JWT**. Consider somebody adding a custom additional field `exp` to **session** that is set to `1800000000`. Now the `getJwtToken` returns a token that is valid for almost 2 years without a warning. It could get even worse if someone carelessly created an endpoint that allows clients to sign any **JWT Payload**, thinking they are allowed to only sign any **Data**... To address this, I propose that **Data** and **Claims** are separate arguments for the signing function/endpoint. In my approach, if someone called `getSessionJwt` (`getJwtToken` after rename) with a session that has added an `exp` field, they would have their **JWT Payload** curated and receive this message: ``` WARN [Better Auth]: Signing JWT: Removing "exp" field from the data to be signed (affects original record!). This is a reserved field. If you need to edit this **JWT Claim**, provide its override in "signJwt" function's "options.claims" argument. ``` If somebody wants to change a **JWT Claim**, they need to state their intent explicitly by providing `claims` argument to `signJwt` like this: ```js const jwt = await signJwt(ctx, data, { claims: { exp: 1800000000 } }); ``` It is also possible to remove default **JWT Claims** from the payload: ```js const jwt = await signJwt(ctx, data, { claims: { aud: null, iat: null, exp: null, iss: null } }); ``` This is valid in `signJwt`, however default plugin endpoints `/sign-jwt` and `/verify-jwt` will respond with a `"BAD_REQUEST"` if you try to create/verify infinite JWT, which is missing the `exp` claim. ### Per-case verifying It is possible to provide {`JwtVerifyOptions`} to: ```js async function verifyJwt(ctx, jwt, options); async function verifyJwtWithKey(ctx, jwt, jwk, options); ``` They allow you to set expected **JWT Claims**, for example `maxExpirationTime`, so one has a way to expect different **JWT**s in different places. ### Minimal options Current `createJwk` takes {`JwtOptions`} (now {`JwtPluginOptions`}), which have many options that are not used there. Someone has set `disableKeyEncryption` in their **plugin configuration**. Now they pass custom options to `createJwk`, so a key pair with a different algorithm is created, so they change only these settings, and it comes out encrypted. Not a big deal? Consider somebody having set an **algorithm** for **JWK** generation in **plugin configuration**. They are passing `definePayload` and no other options to `getJwtToken` in one place and having "normal" `getJwtToken` elsewhere. They have just purged their `jwks` table, for reasons. Now if they have called first `getJwtToken`, because there is no key, a **JWK** pair with **default configuration** will be generated. If they call the the second one, a **JWK pair** with **current plugin configuration** will be created. This all comes from carelessness, but I believe giving an open flame to children is careless in itself. However, there **is** a reason for this. We cannot access plugin configuration in these functions before the plugin has been initialized. **JWT Plugin endpoints** are defined before the plugin is initialized. Therefore, we must pass full **plugin configuration** if we want to access `disableKeyEncryption` setting. This is why I opted for a rather ugly solution like this: ```js export async function createJwkInternal( ctx: GenericEndpointContext, jwksOpts?: JwksOptions, ): Promise<Jwk> { //...logic } export async function createJwk( ctx: GenericEndpointContext, jwkOpts?: JwkOptions, ): Promise<Jwk> { const jwksOpts = getJwtPluginOptions(ctx.context)?.jwks || { keyPairConfig: jwkOpts }; return createJwkInternal(ctx, jwksOpts); } ``` There are **internal** functions to be called in the plugin's endpoints that are not exported in `index.ts` and exported versions with minimal options needed. ### `[functionName]Internal` why do they exist? You didn't read the paragraph above. They're analogous to `createJwkInternal` example. ### Custom Plugins The idea is to be able to create **custom plugins** instead of custom *JOSE* implementations like this: ```js const customPlugin = () => { return { id: "customJwt", endpoints: { customSignJwt: createAuthEndpoint( "/custom-sign", { method: "POST", metadata: { SERVER_ONLY: true, $Infer: { body: {} as { data: Record<string, any>; }, }, }, body: z.object({ data: z.record(z.string(), z.any()), }), }, async (ctx) => { const body = ctx.body; return ctx.json({ token: await signJwt(ctx, body.data), }); }, ), customVerifyJwt: createAuthEndpoint( "/custom-verify", { method: "POST", metadata: { SERVER_ONLY: true, $Infer: { body: {} as { token: string; }, }, }, body: z.object({ token: z.string(), }), }, async (ctx) => { const body = ctx.body; return ctx.json({ data: await verifyJwt(ctx, body.token), }); }, ), }, } satisfies BetterAuthPlugin; }; ``` ## Alternative subsolutions to consider ### Cache as module-level variable ~~The plugin can theoretically be created multiple times, and I can't assume no one ever will. This is why the current **cache** implementation idea is to store it in options, but this solution exposes it pointlessly to the user. There might be **unknown unknowns** to this as well.~~ Went with the **module-defined variable** after all. Since all the **Internal** versions of functions were created to make options "more immutable", this would contradict this effort. My intuition also says it's the better approach, but if you know why, I'd like to know. ### Issuer Claim should be mutable Changing `iss` ("Issuer") **JWT Claim** is not permitted, but should it be? What's the use case? Is it a good practice to do so? ### ~~JWT Data should be Deep Cloned~~ ~~I am issuing warnings if the data will be modified in a possibly unexpected way, that is, removing **JWT Claims**. I think this is sufficient. The data is also modified when setting/changing custom **JWT Claims** without a warning. Cloning probably would not have a significant performance impact in most cases. But isn't the data change to reflect the **JWT** content desired? Need an opinion.~~ ___After some thinking, it seems like a bad idea to modify any data in place. It is deep cloned by JOSE later, so I went with a simple:___ ```js const { aud, exp, iat, iss, jti, nbf, sub, ...sanitizedData } = data; return sanitizedData; ``` ### {`JwtVerifyOptions`} should not be part of `/verify-jwt` endpoint One might believe `/verify-jwt` endpoint should only adhere to **current plugin configuration**, and other use cases should be covered by **custom plugin** creation. This is how I believe `getSessionJwt` should act, as it's basically a helper function that wraps `signJwt`, so if one needs to change options for this, they should create a custom endpoint instead. I think the same rule for `/verify-jwt` would be overstrict, but indeed could prevent some carelessness. <hr/> ### No one cares > Your PR is too big and its scope is unclear and it doesn't concern real life use cases and it has too many breaking changes and it differs from our vision and its benefits are insignificant. And at the moment we have Better Things To Do™ and nobody ever Started Screaming in need of your "fixes". Oh, and your code is just bad. This is **fine**. If you ever require any features from this PR, feel free to grab it and author it as your own; don't even bother asking me for permission. I don't do this for the glory. This PR is not divided into smaller ones because the current `jwt` has started to become a mess, and fixing it requires some breaking changes, so I think it's better to get it over with in one fell swoop. <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Refactors the JWT plugin with complete key management, safer signing/verification, JWKS caching with remote keys, and controlled private key encryption. Removes remote signing and improves test coverage. - **New Features** - Server-only endpoints: /verify-jwt, /sign-jwt, /create-jwk, /import-jwk - Exported helpers: createJwk, getJwk, importJwk, getSessionJwt, signJwt, verifyJwt, verifyJwtWithKey - Safer signing: strips JWT claims from data; explicit overrides via claims; endpoints block infinite tokens without exp - Flexible verify options: allowed issuers/audiences, expected subject/type, max token age, kid checks, clock skew tolerance - Private key encryption synced on plugin init; new encrypt/decrypt utilities - JWKS: adapter adds getKeyById/importKey/updateKeysEncryption/revoke; caching and remote JWKS support; responses include alg/crv - **Migration** - Renames: JwtOptions→JwtPluginOptions, JWKOptions→JwkOptions, JWSAlgorithms→JwkAlgorithm, VerifyJwtOptions→JwtVerifyOptions, CustomJwtClaims→JwtCustomClaims - API changes: getJwtToken→getSessionJwt, signJWT(ctx,{...})→signJwt(ctx, data, { jwk?, claims? }) - Session hooks: definePayload→defineSessionJwtData, getSubject→defineSessionJwtSubject - Endpoint change: /sign-jwt body now { data, jwk?, claims? } - Remote signing removed; remoteUrl→remoteJwks providers for fetching and merging remote keys <sup>Written for commit a18919879ed734ac19333f51d028e7cfc8bd6996. Summary will update automatically on new commits.</sup> <!-- End of auto-generated description by cubic. --> --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
GiteaMirror added the pull-request label 2026-03-13 12:27:33 -05:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#5559