Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
71628be
Add strongly-typed support for remote JWKs that doesn't collide with …
jmlow Aug 29, 2025
f1bebfe
Clean up formatting
jmlow Aug 29, 2025
5c54235
Improve collision handling between 'secret' and 'remoteJwks' for plug…
jmlow Aug 29, 2025
74f44f2
Fix error handling and 'key' assignment with optional 'secret'
jmlow Aug 29, 2025
956e3d3
Add explicit any for 'data' (causes build failure when absent)
jmlow Aug 29, 2025
480dfd9
Implement security railguards for asymmetric encryption; Clean up typing
jmlow Aug 29, 2025
fb9fa87
Improve encryption algorithm handling; Strongly type jwtDecoration
jmlow Aug 29, 2025
6ac5421
Refactor remoteJwks configuration to remoteJwksUrl for cleaner seed; …
jmlow Aug 29, 2025
3df6f48
Revert remoteJwksUrl -> remoteJwks to keep plugin interface simpler f…
jmlow Aug 29, 2025
82333fb
Remove unnecessary 'remoteJwks!' assertion
jmlow Aug 29, 2025
0f69510
Clarify documentation/types for remote verify-only config
jmlow Aug 29, 2025
38b8d28
Fix setIat logic issue
jmlow Aug 29, 2025
53d00d6
Set `iat=true` when missing default or specific config to pass tests …
jmlow Aug 29, 2025
c69cae3
Update test to account for conditional 'sign()' decoration
jmlow Aug 29, 2025
7303181
Allow disabling 'iat' when set to 'false'
jmlow Aug 29, 2025
13849fd
Handle JWK with async 'sign()' and alg-aware key
jmlow Aug 29, 2025
a761d46
Improve 'setIat' checking; Remove sensitive data from checksum
jmlow Aug 29, 2025
ceee3dd
Generalize jwks to support local or remote (but still only asymmetric…
jmlow Aug 29, 2025
3c31c1e
Add test for jwks and secret implementations co-existing in plugin
jmlow Aug 29, 2025
1dff1ec
Revert conditional decorations & throw error in 'sign()' if missing '…
jmlow Aug 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
211 changes: 140 additions & 71 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ import {
import {
SignJWT,
jwtVerify,
importJWK,
decodeProtectedHeader,
type CryptoKey,
type JWK,
type KeyObject,
type JoseHeaderParameters,
type JWTVerifyOptions
type JWTVerifyOptions,
type JWTVerifyGetKey
} from 'jose'

import { Type as t } from '@sinclair/typebox'
Expand Down Expand Up @@ -126,7 +129,7 @@ export interface JWTPayloadInput extends Omit<JWTPayloadSpec, NormalizedClaim> {
*
* @see {@link https://www.rfc-editor.org/rfc/rfc7519#section-4.1.6 RFC7519#section-4.1.6}
*/
iat?: boolean
iat?: boolean | number | string | Date
}

/**
Expand Down Expand Up @@ -157,54 +160,101 @@ export interface JWTHeaderParameters extends JoseHeaderParameters {
crit?: string[]
}

export interface JWTOption<
type BaseJWTOption<Name extends string | undefined, Schema extends TSchema | undefined> =
JWTHeaderParameters & JWTPayloadInput & {
/**
* Name to decorate method as
*
* ---
* @example
* For example, `jwt` will decorate Context with `Context.jwt`
*
* ```typescript
* app
* .decorate({
* name: 'myJWTNamespace',
* secret: process.env.JWT_SECRET
* })
* .get('/sign/:name', ({ myJWTNamespace, params }) => {
* return myJWTNamespace.sign(params)
* })
* ```
*/
name?: Name
/**
* Type strict validation for JWT payload
*/
schema?: Schema
}

export type JWTOption<
Name extends string | undefined = 'jwt',
Schema extends TSchema | undefined = undefined
> extends JWTHeaderParameters,
JWTPayloadInput {
/**
* Name to decorate method as
*
* ---
* @example
* For example, `jwt` will decorate Context with `Context.jwt`
*
* ```typescript
* app
* .decorate({
* name: 'myJWTNamespace',
* secret: process.env.JWT_SECRETS
* })
* .get('/sign/:name', ({ myJWTNamespace, params }) => {
* return myJWTNamespace.sign(params)
* })
* ```
*/
name?: Name
/**
* JWT Secret
*/
secret: string | Uint8Array | CryptoKey | JWK | KeyObject
/**
* Type strict validation for JWT payload
*/
schema?: Schema
}
> =
| (BaseJWTOption<Name, Schema> & {
/**
* JWT Secret
* Signing always uses `secret`
*/
secret: string | Uint8Array | CryptoKey | JWK | KeyObject
/**
* Local or Remote JWKS
* Use jose's `createRemoteJWKSet(new URL(...))` to create the remote JWKS function
* Use jose's `createLocalJWKSet(...)` to create the local JWKS function
* If both `secret` and `jwks` are provided, `jwks` will be used for verifying asymmetric algorithms
* and `secret` for verifying symmetric algorithms
*/
jwks?: JWTVerifyGetKey
})
| (BaseJWTOption<Name, Schema> & {
/**
* JWT Secret
* If missing, signing will be disabled
* Also, only asymmetric algorithms will be allowed for verification through jwks
*/
secret?: never
/**
* Local or Remote JWKS
* Use jose's `createRemoteJWKSet(new URL(...))` to create the JWKS function
* Use jose's `createLocalJWKSet(...)` to create the local JWKS function
*/
jwks: JWTVerifyGetKey
})

const ASYMMETRIC_VERIFICATION_ALGS = [
'RS256','RS384','RS512',
'PS256','PS384','PS512',
'ES256','ES384','ES512',
'EdDSA'
] as const

const SYMMETRIC_VERIFICATION_ALGS = ['HS256', 'HS384', 'HS512'] as const
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is unused. Should be removed


Comment on lines +224 to 232
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Enforce symmetric defaults on local HS verification.*

SYMMETRIC_VERIFICATION_ALGS is defined but unused. Use it as the default when verifying with a local secret. Covered by the verify diff above.

Consider adding tests to assert:

  • HS256 verifies locally even with remoteJwks set.
  • RS256 verifies via remoteJwks.
  • HS256 with only remoteJwks throws the intended error.

🏁 Script executed:

#!/usr/bin/env bash
# Locate the constant definitions
rg -n "const SYMMETRIC_VERIFICATION_ALGS" -C2 .
rg -n "const ASYMMETRIC_VERIFICATION_ALGS" -C2 .

# Search for any references to SYMMETRIC_VERIFICATION_ALGS in verification logic
rg -n "SYMMETRIC_VERIFICATION_ALGS" -C2 src

# Inspect the verify function signature and defaults
rg -nP "\bfunction\s+verify\b" -C3 src/index.ts

Length of output: 1435


🏁 Script executed:

#!/usr/bin/env bash
# Extract verify implementation from src/index.ts
sed -n '250,350p' src/index.ts

# Search for jwtVerify import and usage
rg -n "import .*jwtVerify" -C2 src/index.ts
rg -n "jwtVerify" -C5 src/index.ts

# Search for local (secret-based) verification usage (e.g., jws or jwt.verify)
rg -nP "(jws|jwt)\s*\.verify" -C5 src/index.ts

Length of output: 4072


Enforce symmetric defaults for local HS verification*
Use SYMMETRIC_VERIFICATION_ALGS as the default algorithms when calling jwtVerify with a local secret (i.e., in the else branch before payload = …) so HS* tokens can’t be verified with unintended algs. Add tests to confirm:

  • HS256 verifies locally even if remoteJwks is provided.
  • RS256 uses remoteJwks.
  • HS256 with only remoteJwks throws the expected error.

export const jwt = <
const Name extends string = 'jwt',
const Schema extends TSchema | undefined = undefined
>({
name = 'jwt' as Name,
secret,
jwks,
schema,
...defaultValues
}: // End JWT Payload
JWTOption<Name, Schema>) => {
if (!secret) throw new Error("Secret can't be empty")

const key =
typeof secret === 'string' ? new TextEncoder().encode(secret) : secret
if (!secret && !jwks) throw new Error('Either "secret" or "jwks" must be provided')

const getKeyForAlg = (alg: string) => {
return importJWK(secret as JWK, alg)
}

const key = secret
? (typeof secret === 'object'
&& ('kty' in (secret as Record<string, unknown>))
? undefined
: typeof secret === 'string'
? new TextEncoder().encode(secret)
: secret)
: undefined

const validator = schema
? getSchemaValidator(
Expand Down Expand Up @@ -232,15 +282,54 @@ JWTOption<Name, Schema>) => {
name: '@elysiajs/jwt',
seed: {
name,
secret,
schema,
...defaultValues
}
}).decorate(name as Name extends string ? Name : 'jwt', {
sign(
verify: async (
jwt?: string,
options?: JWTVerifyOptions
): Promise<
| (UnwrapSchema<Schema, ClaimType> &
Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>>)
| false
> => {
if (!jwt) return false

try {
const { alg } = decodeProtectedHeader(jwt)
const isSymmetric = typeof alg === 'string' && alg.startsWith('HS')
const asymmetricOnly = jwks && !secret
if (isSymmetric && asymmetricOnly) throw new Error('HS* algorithm requires a local secret')
// Prefer local secret for HS*; prefer remote for asymmetric algs when available
let payload
if (jwks && !isSymmetric) {
const jwksVerifyOptions: JWTVerifyOptions = !options
? { algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] }
: (!options.algorithms
? { ...options, algorithms: [...ASYMMETRIC_VERIFICATION_ALGS] }
: options)
payload = (await jwtVerify(jwt, jwks, jwksVerifyOptions)
).payload
} else {
payload = (await jwtVerify(jwt, key ?? await getKeyForAlg(alg!), options)).payload
}
const data = payload as UnwrapSchema<Schema, ClaimType> &
Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>>

if (validator && !validator.Check(data))
throw new ValidationError('JWT', validator, data)

return data
} catch (_) {
return false
}
},
sign: async (
signValue: Omit<UnwrapSchema<Schema, ClaimType>, NormalizedClaim> &
JWTPayloadInput
) {
) => {
if (!secret) throw new Error('Signing requires a local "secret" to be provided')
const { nbf, exp, iat, ...data } = signValue

/**
Expand Down Expand Up @@ -329,8 +418,8 @@ JWTOption<Name, Schema>) => {
| Record<string, unknown>

let jwt = new SignJWT({ ...JWTPayload }).setProtectedHeader({
alg: JWTHeader.alg!,
...JWTHeader
...JWTHeader,
alg: JWTHeader.alg!
})

/**
Expand All @@ -354,37 +443,17 @@ JWTOption<Name, Schema>) => {

// Define 'iat' (Issued At). If a specific value is provided, use it.
// Otherwise, if the claim is just marked as true, set it to the current time.
const setIat = 'iat' in signValue ? iat : defaultValues.iat
if (setIat !== false) {
jwt = jwt.setIssuedAt(new Date())
const setIat = 'iat' in signValue ? iat : (defaultValues.iat ?? true)
if (setIat === true) {
jwt = jwt.setIssuedAt()
} else if (typeof setIat === 'number'
|| typeof setIat === 'string'
|| setIat instanceof Date)
{
jwt = jwt.setIssuedAt(setIat as string | number | Date)
}

return jwt.sign(key)
},
async verify(
jwt?: string,
options?: JWTVerifyOptions
): Promise<
| (UnwrapSchema<Schema, ClaimType> &
Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>>)
| false
> {
if (!jwt) return false

try {
const data: any = (
await (options
? jwtVerify(jwt, key, options)
: jwtVerify(jwt, key))
).payload

if (validator && !validator.Check(data))
throw new ValidationError('JWT', validator, data)

return data
} catch (_) {
return false
}
return jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)) )
}
Comment on lines +452 to 457
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Fail fast on signing with asymmetric alg + symmetric secret.

If someone sets alg='RS256' but provides a string/Uint8Array secret, jose will throw deep in signing. Add a concise upfront error.

-      return jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)) )
+      const algIsSymmetric = typeof JWTHeader.alg === 'string' && JWTHeader.alg.startsWith('HS')
+      if (!algIsSymmetric && (typeof secret === 'string' || secret instanceof Uint8Array)) {
+        throw new Error(`Signing with ${JWTHeader.alg} requires a KeyObject/CryptoKey or JWK "secret"`)
+      }
+      return jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
jwt = jwt.setIssuedAt(setIat as string | number | Date)
}
return jwt.sign(key)
},
async verify(
jwt?: string,
options?: JWTVerifyOptions
): Promise<
| (UnwrapSchema<Schema, ClaimType> &
Omit<JWTPayloadSpec, keyof UnwrapSchema<Schema, {}>>)
| false
> {
if (!jwt) return false
try {
const data: any = (
await (options
? jwtVerify(jwt, key, options)
: jwtVerify(jwt, key))
).payload
if (validator && !validator.Check(data))
throw new ValidationError('JWT', validator, data)
return data
} catch (_) {
return false
}
return jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)) )
}
{
jwt = jwt.setIssuedAt(setIat as string | number | Date)
}
const algIsSymmetric = typeof JWTHeader.alg === 'string' && JWTHeader.alg.startsWith('HS')
if (!algIsSymmetric && (typeof secret === 'string' || secret instanceof Uint8Array)) {
throw new Error(`Signing with ${JWTHeader.alg} requires a KeyObject/CryptoKey or JWK "secret"`)
}
return jwt.sign((key ?? await getKeyForAlg(JWTHeader.alg!)))
}
🤖 Prompt for AI Agents
In src/index.ts around lines 452 to 457, the signer allows an asymmetric
algorithm (e.g. RS256/ES*/PS*) while accepting a symmetric secret
(string/Uint8Array) which causes a deep error in jose; add an explicit early
validation before signing: detect when JWTHeader.alg indicates an asymmetric
algorithm and the resolved key (the provided key param or the result of await
getKeyForAlg(...)) is a raw symmetric secret (typeof key === 'string' || key
instanceof Uint8Array), and if so throw a clear TypeError (e.g. "Asymmetric
algorithm <alg> requires a private key, not a symmetric secret") so callers fail
fast; place this check immediately before calling jwt.sign and ensure it runs
for both direct key param and keys returned by getKeyForAlg.

})
}
Expand Down
58 changes: 53 additions & 5 deletions test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Elysia, t } from 'elysia'
import { jwt } from '../src'
import { SignJWT } from 'jose'
import { createLocalJWKSet, decodeProtectedHeader, exportJWK, generateKeyPair, SignJWT } from 'jose'

import { describe, expect, it } from 'bun:test'
import { inferBodyReference } from 'elysia/dist/sucrose'

const post = (path: string, body = {}) =>
new Request(`http://localhost${path}`, {
Expand All @@ -15,6 +16,7 @@ const post = (path: string, body = {}) =>

const TEST_SECRET = 'A'


describe('JWT Plugin', () => {
const app = new Elysia()
.use(
Expand All @@ -28,7 +30,7 @@ describe('JWT Plugin', () => {
.post(
'/sign-token',
({ jwt, body }) =>
jwt.sign({
jwt.sign!({
name: body.name,
exp: '30m'
}),
Expand All @@ -41,7 +43,7 @@ describe('JWT Plugin', () => {
.post(
'/sign-token-disable-exp-and-iat',
({ jwt, body }) =>
jwt.sign({
jwt.sign!({
name: body.name,
// nbf: undefined,
exp: undefined,
Expand Down Expand Up @@ -86,14 +88,14 @@ describe('JWT Plugin', () => {
return {
success: false,
data: null,
message: 'exp was not setted on jwt'
message: 'exp was not set on jwt'
}
}
if (!verifiedPayload.iat) {
return {
success: false,
data: null,
message: 'iat was not setted on jwt'
message: 'iat was not set on jwt'
}
}
return { success: true, data: verifiedPayload }
Expand Down Expand Up @@ -195,4 +197,50 @@ describe('JWT Plugin', () => {
expect(verifiedResult.data?.exp).toBeUndefined()
expect(verifiedResult.data?.iat).toBeUndefined()
})

// Basic JWKS test
it('Should verify RS256 via jwks and HS256 via local secret when both are configured',
async () => {
// RS256 key pair + jwks
const { publicKey, privateKey } = await generateKeyPair('RS256')
const pubJwk = await exportJWK(publicKey)
Object.assign(pubJwk, { alg: 'RS256', kid: 'test' })
const getKey = createLocalJWKSet({ keys: [pubJwk] })

const jwksApp = new Elysia()
.use(jwt({ name: 'jwt', secret: TEST_SECRET, jwks: getKey }))
.post('/verify', async ({ jwt, body }) => {
const token = await jwt.verify(body.token)
return {
token,
ok: !!token
}
}, {
body: t.Object({ token: t.String() })
})
.post('/sign', async ({ body, jwt }) => await jwt.sign!({
name: body.name,
exp: undefined,
iat: false,
}), {
body: t.Object({ name: t.String() })
})

// RS256 token -> jwks
const rsToken = await new SignJWT({ role: 'local' })
.setProtectedHeader({ alg: 'RS256', kid: 'test' })
.setExpirationTime('5m')
.sign(privateKey)
const rsResp = await jwksApp.handle(post('/verify', { token: rsToken }))
const rsRespJson = await rsResp.json()
expect((rsRespJson.ok)).toBe(true)

// HS256 token -> local secret
const hsSignResp = await jwksApp.handle(post('/sign', { name: 'test' }))
const hsToken = await hsSignResp.text()
expect(decodeProtectedHeader(hsToken).alg).toBe('HS256')
const hsResp = await jwksApp.handle(post('/verify', { token: hsToken }))
const hsRespJson = await hsResp.json()
expect(hsRespJson.ok).toBe(true)
})
Comment on lines +201 to +245
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Great coverage for JWKS + local secret routing; add a JWK-oct HS256 test.

Add a test to prove HS256 verify works when secret is an oct JWK (and that HS* is rejected in jwks-only mode).

Example to append:

it('Should sign/verify HS256 using an oct JWK as local secret', async () => {
  // Generate an HMAC key and export as JWK (oct)
  const hmacKey = await crypto.subtle.generateKey(
    { name: 'HMAC', hash: 'SHA-256' },
    true,
    ['sign', 'verify']
  )
  const octJwk = await exportJWK(hmacKey)
  octJwk.alg = 'HS256'

  const appJwk = new Elysia()
    .use(jwt({ name: 'jwt', secret: octJwk }))
    .post('/sign', async ({ jwt }) => jwt.sign!({ user: 'oct', exp: '5m', iat: true }))
    .post('/verify', async ({ jwt, body }) => ({ ok: !!(await jwt.verify(body.token)) }), {
      body: t.Object({ token: t.String() })
    })

  const signed = await appJwk.handle(post('/sign', {})).then(r => r.text())
  const verified = await appJwk.handle(post('/verify', { token: signed })).then(r => r.json())
  expect(verified.ok).toBe(true)
})

it('Should reject HS256 when configured jwks-only', async () => {
  const { publicKey } = await generateKeyPair('RS256')
  const pubJwk = await exportJWK(publicKey); Object.assign(pubJwk, { alg: 'RS256' })
  const getKey = createLocalJWKSet({ keys: [pubJwk] })

  const appJwksOnly = new Elysia()
    .use(jwt({ name: 'jwt', jwks: getKey })) // no secret
    .post('/verify', async ({ jwt, body }) => ({ ok: !!(await jwt.verify(body.token)) }), {
      body: t.Object({ token: t.String() })
    })

  const hsToken = await new SignJWT({ n: 1 })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime('5m')
    .sign(new TextEncoder().encode('local-only'))

  const resp = await appJwksOnly.handle(post('/verify', { token: hsToken }))
  const json = await resp.json()
  expect(json.ok).toBe(false)
})
🤖 Prompt for AI Agents
In test/index.test.ts around lines 201 to 245, the reviewer asked to add two
tests: one proving HS256 verify works when the configured secret is an oct JWK,
and another proving HS* tokens are rejected when only jwks is configured. Add a
test that uses crypto.subtle.generateKey to create an HMAC key, exportJWK to
produce an oct JWK with alg='HS256', then mount jwt({ name:'jwt', secret: octJwk
}) with /sign and /verify endpoints and assert a signed HS256 token verifies
successfully. Add a second test that creates an RS256 jwks-only app
(createLocalJWKSet from exported public JWK), craft an HS256 token signed with a
raw TextEncoder secret, call the jwks-only /verify and assert verification fails
(ok === false). Ensure body schemas and async handling match existing tests and
reuse helpers (SignJWT, exportJWK, createLocalJWKSet, generateKeyPair).

})