CodeSampleX

샘플

jose 6.2.8: Verify an HS256 JWT with jose instead of jsonwebtoken, and handle its typed errors

검증된 샘플 — npm jose 6.2.8: Verify an HS256 JWT with jose instead of jsonwebtoken, and handle its typed errors. node 22 · linux alpine/x64 · docker에서 contract를…

sha256:4fdfb16090032500adac0a6c3479120970ee468326a4e237aa65cf091c30cc7d

이 네트워크가 제공하는 것은 하나입니다. 빌드되는 샘플. 샌드박스에서 돌리고 서명된 영수증을 보관합니다. 등급을 매기지 않고 무엇도 보증하지 않습니다 — 같은 코드가 당신 환경에서 빌드되는지는 측정한 적이 없습니다. 통과한 계약 영수증을 낸 서로 다른 서명 키의 수입니다. 하나면 작성자 혼자이고, 둘 이상이면 다른 사람도 빌드했다는 뜻입니다. 키는 스스로 만드는 것이고 뒤에 등록된 신원이 없으므로, 세는 것은 사람이 아니라 키입니다. MIT-0

실행 증거

선언된 환경과 서명된 실행을 분리해 두었습니다. 이 샘플이 무엇을 어디서 실행했는지 그대로 볼 수 있습니다.

증거 기준
서명된 컨트랙트 통과
검증 영수증
3
빌드한 서명 키
3
선언된 환경 node 22 linux x64 node 22 javascript npm

검증 실행 환경

환경 컨트랙트 단계 실행일
node 22 · linux alpine/x64 · docker ed25519:a2ec939a4c60e243 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1
2026-08-14
node 22 · linux alpine/x64 · docker ed25519:2175b912ea1c23b1 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1
2026-08-18
node 22 · linux alpine/x64 · docker ed25519:c1973797be207ac4 PASS compile:SKIPPED · contract:PASS · load:PASS · resolve:PASS
CONTAINER_RUN · node-typescript@1node:22-alpine@sha256:c610fcdfb1d5…
2026-09-08

케이스

HOW
목표
Verify an HS256 JWT with jose instead of jsonwebtoken, and handle its typed errors
패키지
심벌
  • SignJWT
  • jwtVerify
  • jose.errors
  • UnsecuredJWT
  • decodeJwt
환경
node 22
생성일
2026-08-14T08:50:12Z

컨트랙트

  1. sign HS256 with SignJWT and verify with jwtVerify, asserting it resolves to {payload, protectedHeader} rather than to the claims
  2. assert an HMAC key must be bytes: a plain string secret fails with a TypeError that is not a JOSEError and carries no .code, while all four forms the message names - Uint8Array, node:crypto KeyObject, oct JWK, WebCrypto CryptoKey - verify the same token
  3. assert sign() without setProtectedHeader rejects with errors.JWSInvalid and code ERR_JWS_INVALID
  4. assert an expired token rejects with errors.JWTExpired, code ERR_JWT_EXPIRED, claim exp, reason check_failed and the decoded payload attached, and that clockTolerance lets the same token through
  5. assert JWTExpired is a sibling of JWTClaimValidationFailed and not a subclass, so catching claim-validation failures misses expiry
  6. assert nbf is checked without being asked and fails as a plain JWTClaimValidationFailed with claim nbf, on the other side of that split from expiry
  7. assert a wrong key and a tampered payload both reject with errors.JWSSignatureVerificationFailed and code ERR_JWS_SIGNATURE_VERIFICATION_FAILED, while decodeJwt still reports the forged claims
  8. assert issuer and audience are validated only when passed as options, failing with ERR_JWT_CLAIM_VALIDATION_FAILED and reason check_failed for a mismatch and reason missing for an absent claim
  9. assert a token minted with no exp verifies forever unless requiredClaims names exp
  10. assert alg is taken from the token header unless algorithms is pinned: a real HS512 token verifies unpinned but is refused with ERR_JOSE_ALG_NOT_ALLOWED when pinned
  11. assert an UnsecuredJWT alg:none token is refused by jwtVerify even unpinned, with ERR_JOSE_NOT_SUPPORTED, and that an RS256 header with a symmetric key is refused by the alg check when pinned and by a TypeError when not
  12. assert jose does not enforce the RFC 7518 minimum HMAC key size: a five-byte HS256 key signs and verifies, and WebCrypto underneath imports the same 40-bit HMAC key without complaint
  13. assert jwtVerify never throws synchronously, so an unawaited call yields a truthy Promise and its failure reaches process.unhandledRejection, and measure in a child process that the same orphaned rejection exits node with status 1
  14. assert jose ships no CJS export condition and no default export, that importing a default binding from it fails at link time with a SyntaxError, and measure that require('jose') nonetheless succeeds on Node 22

파일

  • csx.json
  • package-lock.json
  • package.json
  • src/index.mjs
  • test/contract.mjs

소스 아티팩트 내려받기 (tar.gz)

소스

csx.json
{"case":{"caseId":"case:sha256:adba96f3defffb083f6e8205c656687e22669de72e1e6890f8c494ce38ec5b2b","constraints":{"moduleSystem":"esm","runtime":"node"},"contract":["sign HS256 with SignJWT and verify with jwtVerify, asserting it resolves to {payload, protectedHeader} rather than to the claims","assert an HMAC key must be bytes: a plain string secret fails with a TypeError that is not a JOSEError and carries no .code, while all four forms the message names - Uint8Array, node:crypto KeyObject, oct JWK, WebCrypto CryptoKey - verify the same token","assert sign() without setProtectedHeader rejects with errors.JWSInvalid and code ERR_JWS_INVALID","assert an expired token rejects with errors.JWTExpired, code ERR_JWT_EXPIRED, claim exp, reason check_failed and the decoded payload attached, and that clockTolerance lets the same token through","assert JWTExpired is a sibling of JWTClaimValidationFailed and not a subclass, so catching claim-validation failures misses expiry","assert nbf is checked without being asked and fails as a plain JWTClaimValidationFailed with claim nbf, on the other side of that split from expiry","assert a wrong key and a tampered payload both reject with errors.JWSSignatureVerificationFailed and code ERR_JWS_SIGNATURE_VERIFICATION_FAILED, while decodeJwt still reports the forged claims","assert issuer and audience are validated only when passed as options, failing with ERR_JWT_CLAIM_VALIDATION_FAILED and reason check_failed for a mismatch and reason missing for an absent claim","assert a token minted with no exp verifies forever unless requiredClaims names exp","assert alg is taken from the token header unless algorithms is pinned: a real HS512 token verifies unpinned but is refused with ERR_JOSE_ALG_NOT_ALLOWED when pinned","assert an UnsecuredJWT alg:none token is refused by jwtVerify even unpinned, with ERR_JOSE_NOT_SUPPORTED, and that an RS256 header with a symmetric key is refused by the alg check when pinned and by a TypeError when not","assert jose does not enforce the RFC 7518 minimum HMAC key size: a five-byte HS256 key signs and verifies, and WebCrypto underneath imports the same 40-bit HMAC key without complaint","assert jwtVerify never throws synchronously, so an unawaited call yields a truthy Promise and its failure reaches process.unhandledRejection, and measure in a child process that the same orphaned rejection exits node with status 1","assert jose ships no CJS export condition and no default export, that importing a default binding from it fails at link time with a SyntaxError, and measure that require('jose') nonetheless succeeds on Node 22"],"goal":"Verify an HS256 JWT with jose instead of jsonwebtoken, and handle its typed errors","kind":"HOW","packages":["pkg:npm/jose@6.2.8"],"schemaVersion":1,"symbols":["SignJWT","jwtVerify","jose.errors","UnsecuredJWT","decodeJwt"]},"contractCommand":["node","test/contract.mjs"],"environment":{"arch":"x64","ecosystem":"npm","executionContext":"node","language":"javascript","moduleSystem":"esm","os":"linux","packageManager":"npm","runtime":"node","runtimeVersion":"22","schemaVersion":1},"license":"MIT-0","packages":["pkg:npm/jose@6.2.8"],"schemaVersion":1,"symbols":["SignJWT","jwtVerify","jose.errors","UnsecuredJWT","decodeJwt"],"verifierAdapter":"node-typescript@1"}
package-lock.json
{
  "name": "csx-sample-npm-jose-hs256-verify",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "csx-sample-npm-jose-hs256-verify",
      "version": "1.0.0",
      "license": "MIT-0",
      "dependencies": {
        "jose": "6.2.8"
      }
    },
    "node_modules/jose": {
      "version": "6.2.8",
      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz",
      "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==",
      "license": "MIT",
      "funding": {
        "url": "https://github.com/sponsors/panva"
      }
    }
  }
}
package.json
{
  "name": "csx-sample-npm-jose-hs256-verify",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "license": "MIT-0",
  "dependencies": {
    "jose": "6.2.8"
  }
}
src/index.mjs
import { SignJWT, jwtVerify, errors } from 'jose';

/**
 * jose is the modern replacement for jsonwebtoken, and the parts that break a
 * migration are not cryptographic. Three of them, in the order people hit them:
 *
 * 1. Every operation is async. jwt.verify() threw synchronously, so a
 *    try/catch around it worked. Around a jose call it catches nothing unless
 *    the call is awaited: the failure arrives as a rejected promise, the
 *    "verified claims" you assigned are a Promise object (truthy, so an
 *    `if (claims)` guard passes), and the rejection escapes to
 *    process.unhandledRejection, which terminates the process on Node.
 *
 * 2. The key is bytes, never a string. jwt.verify(token, 'secret') is the
 *    normal jsonwebtoken call; jose rejects it with a plain TypeError. Rejects,
 *    not throws, because of 1. Plain, as in not a JOSEError and with no .code,
 *    so error handling written around `err instanceof jose.errors.JOSEError`
 *    silently misses the one mistake every migration makes. Encode the secret:
 *    new TextEncoder().encode(s).
 *
 * 3. jwtVerify resolves to { payload, protectedHeader }, not to the claims.
 *    `const claims = await jwtVerify(...)` leaves claims.sub undefined.
 *
 * What jose does not change is the thing that matters: the token's own header
 * chooses the algorithm unless you pin `algorithms`. jose is safe against
 * alg:none regardless — "none" is not an algorithm it can implement, so an
 * unsecured token fails even unpinned — but an unpinned verifier will happily
 * follow the header from HS256 to HS512, and pinning is also what turns a token
 * with a rewritten alg into one clear ERR_JOSE_ALG_NOT_ALLOWED instead of a
 * TypeError about key types.
 */

/** HMAC keys are bytes. jose rejects a string, and this is the whole fix. */
export function hmacKey(secret) {
  return new TextEncoder().encode(secret);
}

export async function signHS256(payload, key, { issuer, audience, expiresIn = '1h' } = {}) {
  // setProtectedHeader is mandatory. Without it sign() rejects with JWSInvalid
  // rather than defaulting to anything, because the header is signed data and
  // jose will not invent signed data for you.
  const jwt = new SignJWT(payload).setProtectedHeader({ alg: 'HS256' }).setIssuedAt();
  if (issuer) jwt.setIssuer(issuer);
  if (audience) jwt.setAudience(audience);
  return jwt.setExpirationTime(expiresIn).sign(key);
}

/**
 * jwtVerify checks the signature, and checks exp/nbf when they are present.
 * Everything else is opt-in: issuer and audience are validated only if you
 * pass them, and a token with no exp at all verifies forever. requiredClaims
 * is what closes that hole, so it belongs in the default and not in a comment.
 */
export async function verifyHS256(token, key, { issuer, audience } = {}) {
  return jwtVerify(token, key, {
    algorithms: ['HS256'],
    requiredClaims: ['exp'],
    ...(issuer === undefined ? {} : { issuer }),
    ...(audience === undefined ? {} : { audience }),
  });
}

/**
 * Every failure jose raises on purpose descends from errors.JOSEError and
 * carries a stable .code. Anything else — a TypeError about key types, above
 * all — is a bug in the calling code, not a rejected token, and should not be
 * reported to a client as "invalid token".
 */
export function isTokenRejection(err) {
  return err instanceof errors.JOSEError;
}
test/contract.mjs
import { strict as assert } from 'node:assert';
import { createRequire } from 'node:module';
import { createSecretKey } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { SignJWT, jwtVerify, decodeJwt, UnsecuredJWT, errors } from 'jose';

import { hmacKey, signHS256, verifyHS256, isTokenRejection } from '../src/index.mjs';

const require = createRequire(import.meta.url);
const SECRET = 'a-32-byte-or-longer-test-secret-value';
const key = hmacKey(SECRET);

/** Captures the rejection so each assertion can name the exact error. */
async function rejection(promiseFn) {
  try {
    await promiseFn();
  } catch (err) {
    return err;
  }
  throw new Error('expected a rejection, got a resolved value');
}

// --- the happy path, and the return shape that trips migrations -------------
const token = await signHS256(
  { sub: 'peer-1', role: 'seeder' },
  key,
  { issuer: 'https://issuer.example', audience: 'csx-api' },
);
assert.equal(token.split('.').length, 3);

const result = await verifyHS256(token, key);
// jsonwebtoken returned the claims. jose returns a two-key envelope, so the
// claims live one level down and the signed header is handed back separately.
assert.deepEqual(Object.keys(result).sort(), ['payload', 'protectedHeader']);
assert.deepEqual(result.protectedHeader, { alg: 'HS256' });
assert.equal(result.payload.sub, 'peer-1');
assert.equal(result.payload.role, 'seeder');
assert.ok(result.payload.exp > result.payload.iat);
assert.equal(result.sub, undefined);

// --- the key is bytes, and the error for a string is outside the JOSE tree --
const stringKey = await rejection(() => jwtVerify(token, SECRET, { algorithms: ['HS256'] }));
assert.equal(stringKey.constructor, TypeError);
assert.match(
  stringKey.message,
  /^Key for the HS256 algorithm must be one of type CryptoKey, KeyObject, JSON Web Key, or Uint8Array\./,
);
// Measured, and the reason this assertion exists: there is no .code and it is
// not a JOSEError, so the catch block that classifies jose failures by code or
// by `instanceof JOSEError` treats the most common migration mistake as an
// unrelated crash.
assert.equal(stringKey.code, undefined);
assert.equal(isTokenRejection(stringKey), false);
assert.equal(stringKey instanceof errors.JOSEError, false);

// Signing refuses the same string, so the mistake cannot even produce a token.
const stringSign = await rejection(() => new SignJWT({}).setProtectedHeader({ alg: 'HS256' }).sign(SECRET));
assert.equal(stringSign.constructor, TypeError);

// All four forms that message names are real in v6, and all four verify the
// token that was signed with the Uint8Array. The webapi build did not drop
// node:crypto secret keys, which is the one people assume went away.
const secretBytes = Buffer.from(SECRET, 'utf8');
const viaKeyObject = await verifyHS256(token, createSecretKey(secretBytes));
assert.equal(viaKeyObject.payload.sub, 'peer-1');
const viaJwk = await verifyHS256(token, { kty: 'oct', k: secretBytes.toString('base64url') });
assert.equal(viaJwk.payload.sub, 'peer-1');
const viaCryptoKey = await verifyHS256(
  token,
  await crypto.subtle.importKey('raw', key, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']),
);
assert.equal(viaCryptoKey.payload.sub, 'peer-1');

// setProtectedHeader is not optional: no header, no signature.
const noHeader = await rejection(() => new SignJWT({ sub: 'x' }).sign(key));
assert.ok(noHeader instanceof errors.JWSInvalid);
assert.equal(noHeader.code, 'ERR_JWS_INVALID');

// --- expiry ------------------------------------------------------------------
const now = Math.floor(Date.now() / 1000);
const stale = await new SignJWT({ sub: 'x' })
  .setProtectedHeader({ alg: 'HS256' })
  .setIssuedAt(now - 600)
  .setExpirationTime(now - 300)
  .sign(key);

const expired = await rejection(() => verifyHS256(stale, key));
assert.ok(expired instanceof errors.JWTExpired);
assert.equal(expired.code, 'ERR_JWT_EXPIRED');
assert.equal(errors.JWTExpired.code, 'ERR_JWT_EXPIRED');
assert.equal(expired.claim, 'exp');
assert.equal(expired.reason, 'check_failed');
// jose attaches the decoded claims to the expiry error, which is how you tell
// *whose* session went stale without re-decoding the token yourself.
assert.equal(expired.payload.sub, 'x');
// The trap: JWTExpired is a sibling of JWTClaimValidationFailed, not a subclass
// of it, even though both describe a failed claim check and both carry .claim
// and .reason. A handler that catches JWTClaimValidationFailed to render "bad
// token" lets every expired token fall through to the generic 500 branch.
assert.equal(expired instanceof errors.JWTClaimValidationFailed, false);
assert.ok(errors.JWTExpired.prototype instanceof errors.JOSEError);
assert.ok(errors.JWTClaimValidationFailed.prototype instanceof errors.JOSEError);
// Expiry is a check against the clock, so a tolerance can wave it through.
const tolerated = await jwtVerify(stale, key, { algorithms: ['HS256'], clockTolerance: '10 minutes' });
assert.equal(tolerated.payload.sub, 'x');

// nbf is the other claim jwtVerify checks without being asked, and it lands on
// the other side of that split: a not-yet-valid token is a plain
// JWTClaimValidationFailed, the same class as a bad iss, while expiry is not.
const notYet = await new SignJWT({ sub: 'x' })
  .setProtectedHeader({ alg: 'HS256' })
  .setNotBefore(now + 600)
  .setExpirationTime(now + 1200)
  .sign(key);
const early = await rejection(() => verifyHS256(notYet, key));
assert.ok(early instanceof errors.JWTClaimValidationFailed);
assert.equal(early.code, 'ERR_JWT_CLAIM_VALIDATION_FAILED');
assert.equal(early.claim, 'nbf');
assert.equal(early.reason, 'check_failed');

// --- wrong key, and a tampered payload --------------------------------------
const wrongKey = await rejection(() => verifyHS256(token, hmacKey('a-32-byte-or-longer-WRONG-key-value!')));
assert.ok(wrongKey instanceof errors.JWSSignatureVerificationFailed);
assert.equal(wrongKey.code, 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED');
assert.equal(wrongKey.message, 'signature verification failed');
assert.equal(isTokenRejection(wrongKey), true);

const [header, , signature] = token.split('.');
const forged = [header, Buffer.from(JSON.stringify({ sub: 'admin' })).toString('base64url'), signature].join('.');
const tampered = await rejection(() => verifyHS256(forged, key));
assert.ok(tampered instanceof errors.JWSSignatureVerificationFailed);
// decodeJwt is jose's jwt.decode: it reads the claims and verifies nothing, so
// it happily reports the forged subject. It is not a security check.
assert.equal(decodeJwt(forged).sub, 'admin');

// --- issuer and audience are checked only when you ask ----------------------
// Same token, same key, no options: the mismatched issuer and audience below
// are inside this payload and nothing complains.
const unchecked = await jwtVerify(token, key, { algorithms: ['HS256'] });
assert.equal(unchecked.payload.iss, 'https://issuer.example');
assert.equal(unchecked.payload.aud, 'csx-api');

const badIssuer = await rejection(() => verifyHS256(token, key, { issuer: 'https://attacker.example' }));
assert.ok(badIssuer instanceof errors.JWTClaimValidationFailed);
assert.equal(badIssuer.code, 'ERR_JWT_CLAIM_VALIDATION_FAILED');
assert.equal(badIssuer.claim, 'iss');
assert.equal(badIssuer.reason, 'check_failed');

const badAudience = await rejection(() => verifyHS256(token, key, { audience: 'other-api' }));
assert.equal(badAudience.claim, 'aud');
assert.equal(badAudience.reason, 'check_failed');

// A claim that is absent fails the same option with a different reason, which
// is the difference between a token for someone else and a token minted by
// something that never set the field.
const anonymous = await signHS256({ sub: 'x' }, key);
const missingIssuer = await rejection(() => verifyHS256(anonymous, key, { issuer: 'https://issuer.example' }));
assert.equal(missingIssuer.claim, 'iss');
assert.equal(missingIssuer.reason, 'missing');

// exp is in the same category. A token minted with no exp verifies forever;
// requiredClaims is the option that makes its absence an error, and it is why
// verifyHS256 sets it.
const immortal = await new SignJWT({ sub: 'forever' })
  .setProtectedHeader({ alg: 'HS256' })
  .setIssuedAt()
  .sign(key);
const acceptedForever = await jwtVerify(immortal, key, { algorithms: ['HS256'] });
assert.equal(acceptedForever.payload.sub, 'forever');
assert.equal(acceptedForever.payload.exp, undefined);
const missingExp = await rejection(() => verifyHS256(immortal, key));
assert.equal(missingExp.claim, 'exp');
assert.equal(missingExp.reason, 'missing');

// --- alg comes from the header unless you pin it -----------------------------
// A real HS512 token, signed with the same secret. Pinned, it is refused for
// the algorithm alone; unpinned, the header talks the verifier into HS512.
const hs512 = await new SignJWT({ sub: 'x' })
  .setProtectedHeader({ alg: 'HS512' })
  .setExpirationTime('1h')
  .sign(key);
const downgrade = await rejection(() => verifyHS256(hs512, key));
assert.ok(downgrade instanceof errors.JOSEAlgNotAllowed);
assert.equal(downgrade.code, 'ERR_JOSE_ALG_NOT_ALLOWED');
assert.equal(errors.JOSEAlgNotAllowed.code, 'ERR_JOSE_ALG_NOT_ALLOWED');
const followedHeader = await jwtVerify(hs512, key);
assert.deepEqual(followedHeader.protectedHeader, { alg: 'HS512' });

// alg:none. UnsecuredJWT is jose's only API for an unsigned token, and it is
// kept apart on purpose: its own encode, its own decode, and jwtVerify never
// opens it.
const unsecured = new UnsecuredJWT({ sub: 'admin' }).setIssuedAt().encode();
assert.equal(JSON.parse(Buffer.from(unsecured.split('.')[0], 'base64url')).alg, 'none');
assert.equal(unsecured.endsWith('.'), true);
assert.equal(UnsecuredJWT.decode(unsecured).payload.sub, 'admin');

const nonePinned = await rejection(() => verifyHS256(unsecured, key));
assert.ok(nonePinned instanceof errors.JOSEAlgNotAllowed);
assert.equal(nonePinned.code, 'ERR_JOSE_ALG_NOT_ALLOWED');
// Unpinned it still fails, with a different error: "none" is not an algorithm
// jose can implement, so an unsigned token has no path through jwtVerify even
// when the caller forgot to pin. Pinning is not what saves you from alg:none
// here — the HS512 case above is what an unpinned verifier actually gives away.
const noneUnpinned = await rejection(() => jwtVerify(unsecured, key));
assert.ok(noneUnpinned instanceof errors.JOSENotSupported);
assert.equal(noneUnpinned.code, 'ERR_JOSE_NOT_SUPPORTED');

// A header rewritten to claim RS256, handed to a verifier holding a symmetric
// key: the mirror of the RS256-to-HS256 confusion attack, and the shape an
// HS256 service actually receives. Pinned, the algorithm check fires. Unpinned,
// the only backstop left is the key type, and it is a TypeError — a rejected
// token arriving as a programming error, one more reason to pin.
const claimsRS256 = [
  Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url'),
  token.split('.')[1],
  signature,
].join('.');
const confusedPinned = await rejection(() => verifyHS256(claimsRS256, key));
assert.ok(confusedPinned instanceof errors.JOSEAlgNotAllowed);
const confusedUnpinned = await rejection(() => jwtVerify(claimsRS256, key));
assert.equal(confusedUnpinned.constructor, TypeError);
assert.match(confusedUnpinned.message, /^Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key\./);

// --- jose does not enforce a minimum HMAC key size --------------------------
// RFC 7518 says an HS256 key must be at least as long as the hash output.
// Measured: jose signs and verifies with five bytes without complaint. The
// webapi build hands the key to crypto.subtle, and WebCrypto imports a 40-bit
// HMAC key just as willingly, so nothing in the stack below jose is going to
// catch a weak secret either. Key strength is the caller's job.
const weak = hmacKey('short');
const weakToken = await signHS256({ sub: 'x' }, weak);
assert.equal((await verifyHS256(weakToken, weak)).payload.sub, 'x');
const weakImported = await crypto.subtle.importKey('raw', weak, { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
assert.equal(weakImported.algorithm.length, 40);

// --- async is the migration wall, measured ----------------------------------
// Not awaited: no throw, and the value is a truthy Promise rather than claims.
const floating = jwtVerify(forged, key, { algorithms: ['HS256'] });
assert.ok(floating instanceof Promise);
assert.ok(floating);
assert.equal(floating.payload, undefined);
// Given a handler in this same tick, so this one never counts as unhandled and
// cannot contaminate the measurement below.
floating.catch(() => {});

let syncThrow = null;
try {
  jwtVerify(forged, key, { algorithms: ['HS256'] }).catch(() => {});
} catch (err) {
  syncThrow = err;
}
assert.equal(syncThrow, null, 'jwtVerify never throws synchronously');

// And the rejection does not vanish: it reaches unhandledRejection, which on
// Node terminates the process unless something is listening. This is what a
// try/catch without await actually produces — the listener here is the only
// reason this contract survives its own demonstration, and the promise is
// deliberately never given a handler.
const unhandled = await new Promise((resolve) => {
  process.once('unhandledRejection', resolve);
  try {
    jwtVerify(forged, key, { algorithms: ['HS256'] });
  } catch {
    resolve(new Error('unreachable: it did not throw here'));
  }
});
assert.ok(unhandled instanceof errors.JWSSignatureVerificationFailed);
assert.equal(unhandled.code, 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED');

// "Terminates the process" is the part that costs a service its uptime, so
// measure it instead of asserting it in a comment: the same unawaited verify in
// a child with no listener exits 1 and the pending timer never fires.
const orphan = [
  "import { SignJWT, jwtVerify } from 'jose';",
  "const k = new TextEncoder().encode('a-32-byte-or-longer-test-secret-value');",
  "const t = await new SignJWT({ sub: 'x' }).setProtectedHeader({ alg: 'HS256' }).setExpirationTime('1h').sign(k);",
  "jwtVerify(t, new TextEncoder().encode('the-wrong-key'), { algorithms: ['HS256'] });",
  "setTimeout(() => console.log('STILL_ALIVE'), 50);",
].join('\n');
const child = spawnSync(process.execPath, ['--input-type=module', '-e', orphan], {
  cwd: fileURLToPath(new URL('..', import.meta.url)),
  encoding: 'utf8',
});
assert.equal(child.status, 1);
assert.equal(child.stdout.includes('STILL_ALIVE'), false);
assert.match(child.stderr, /ERR_JWS_SIGNATURE_VERIFICATION_FAILED/);

// --- ESM-only, and what that does and does not mean on Node 22 --------------
const pkg = require('jose/package.json');
assert.equal(pkg.type, 'module');
// No CJS entry is declared at all: the "." export condition map has no
// "require" entry, only types and default.
assert.deepEqual(Object.keys(pkg.exports['.']).sort(), ['default', 'types']);
assert.equal(pkg.exports['.'].default, './dist/webapi/index.js');

// Refuted hypothesis, kept as the measurement: require('jose') does NOT throw
// ERR_REQUIRE_ESM here. Node unflagged require() of an ESM graph with no
// top-level await in 22.12, and "default" is the condition require falls back
// to, so on this runtime the CJS wall is not where the release notes put it.
// Older Node throws ERR_REQUIRE_ESM; the wall that never moves is the default
// export, below.
assert.ok(process.version.startsWith('v22.'));
const viaRequire = require('jose');
assert.equal(typeof viaRequire.jwtVerify, 'function');
assert.equal(typeof viaRequire.SignJWT, 'function');

const namespace = await import('jose');
assert.equal(namespace.default, undefined);
assert.equal(typeof namespace.jwtVerify, 'function');
assert.equal(Object.hasOwn(namespace, 'default'), false);

// `import jwt from 'jsonwebtoken'` is the shape everyone copies over, and the
// same line for jose fails at link time — before a single statement runs, so no
// try/catch in the module can see it. Bare specifiers do not resolve from a
// data: URL, so the entry is resolved first; the export being asked for, and
// the failure, are the ones `import jose from 'jose'` produces.
const entry = pathToFileURL(require.resolve('jose')).href;
const defaultImport = await rejection(() =>
  import('data:text/javascript,' + encodeURIComponent('import jose from ' + JSON.stringify(entry) + '; export default jose;')),
);
assert.ok(defaultImport instanceof SyntaxError);
assert.match(defaultImport.message, /does not provide an export named 'default'/);

console.log('CONTRACT PASS: jose HS256 sign/verify, typed error codes, pinned alg, opt-in claim checks');

오리진 시더

anonymous