Skip to content

Fix: jose JWT Not Working — Token Verification Failing, Invalid Signature, or Key Import Errors

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

Quick Answer

How to fix jose JWT issues — signing and verifying tokens with HS256 and RS256, JWK and JWKS key handling, token expiration, claims validation, and edge runtime compatibility.

The Problem

Token verification fails with “signature verification failed”:

import { jwtVerify } from 'jose';

const { payload } = await jwtVerify(token, secret);
// JWSSignatureVerificationFailed: signature verification failed

Or key import throws:

import { importSPKI } from 'jose';

const key = await importSPKI(publicKeyPem, 'RS256');
// TypeError: Key import failed — invalid format

Or a token that was just signed can’t be verified:

const token = await new SignJWT({ sub: '123' })
  .setProtectedHeader({ alg: 'HS256' })
  .sign(secret);

await jwtVerify(token, secret);
// Error: "alg" (Algorithm) Header Parameter value not allowed

Or jose doesn’t work in Cloudflare Workers:

Error: crypto.createHmac is not a function

Why This Happens

jose is a JavaScript implementation of JSON Web Tokens, JSON Web Signatures, and JSON Web Encryption using the Web Crypto API. Unlike jsonwebtoken, it works in all JavaScript runtimes (Node.js, Deno, Bun, browsers, edge workers) — but that universality requires you to think in Web Crypto primitives instead of Node Buffers.

  • Keys must be CryptoKey objects or Uint8Arrayjose uses the Web Crypto API, not Node.js crypto. Passing a raw string as a key doesn’t work. For HMAC (HS256), the secret must be a Uint8Array or imported via new TextEncoder().encode(). For RSA/EC, keys must be imported via importSPKI, importPKCS8, or importJWK.
  • Algorithm must match between signing and verificationjwtVerify checks the alg header by default. If you sign with HS256 but the verifier expects RS256, verification fails even if the key is correct.
  • PEM format is strict — RSA public keys must start with -----BEGIN PUBLIC KEY----- (SPKI format). Private keys must start with -----BEGIN PRIVATE KEY----- (PKCS8 format). Using the wrong format (e.g., BEGIN RSA PRIVATE KEY which is PKCS1) causes import failures.
  • jose never uses Node.js-specific crypto — it uses the standard Web Crypto API, which is why it works in edge runtimes. But this also means you can’t pass Node.js Buffer objects or use crypto.createHmac. Everything goes through SubtleCrypto.

A second cluster of failures comes from algorithm support per runtime. Web Crypto in Node 18+ supports HS256/HS384/HS512, RS256/RS384/RS512, PS256/PS384/PS512, ES256/ES384, and Ed25519 — but older Node versions and some edge runtimes are incomplete. Cloudflare Workers supports the full set as of late 2023. Vercel Edge supports HS*, RS*, ES256, ES384, but the support for Ed25519 was added in 2024 and you need a recent runtime version. Deno and Bun match Node 22 behavior. Browser support depends on the browser but RS256 and HS256 are universal. If a token signed in Node verifies fine in Node but throws NotSupportedError in a Worker, the algorithm is the culprit, not the key.

A third issue is JWKS caching and key rotation. createRemoteJWKSet fetches the JWKS from a URL once and caches it for 30 seconds by default. If you rotate keys on the issuer side, in-flight requests with the new kid will fail until the cache expires. In serverless platforms with cold starts, each new instance refetches the JWKS, which can hammer the issuer’s endpoint if you have a high cold-start rate. The fix is either a longer cacheMaxAge plus a stale-while-revalidate strategy, or sharing the JWKS across requests using a global cache.

Fix 1: Sign and Verify with HMAC (HS256)

npm install jose
import { SignJWT, jwtVerify } from 'jose';

// Secret must be a Uint8Array — encode a string
const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
// Or generate a proper 256-bit key:
// const secret = crypto.getRandomValues(new Uint8Array(32));

// Sign a token
async function signToken(userId: string, role: string): Promise<string> {
  return new SignJWT({
    sub: userId,
    role,
  })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()                              // iat claim
    .setExpirationTime('24h')                   // exp claim — 24 hours from now
    .setIssuer('https://myapp.com')             // iss claim
    .setAudience('https://myapp.com')           // aud claim
    .setJti(crypto.randomUUID())                // unique token ID
    .sign(secret);
}

// Verify a token
async function verifyToken(token: string) {
  try {
    const { payload, protectedHeader } = await jwtVerify(token, secret, {
      issuer: 'https://myapp.com',       // Verify iss claim
      audience: 'https://myapp.com',     // Verify aud claim
      algorithms: ['HS256'],             // Only allow HS256
    });

    return {
      userId: payload.sub!,
      role: payload.role as string,
      expiresAt: new Date(payload.exp! * 1000),
    };
  } catch (error) {
    if (error instanceof errors.JWTExpired) {
      throw new Error('Token has expired');
    }
    if (error instanceof errors.JWSSignatureVerificationFailed) {
      throw new Error('Invalid token signature');
    }
    throw new Error('Invalid token');
  }
}

// Error types
import { errors } from 'jose';
// errors.JWTExpired — token past its exp time
// errors.JWTClaimValidationFailed — iss, aud, or other claim mismatch
// errors.JWSSignatureVerificationFailed — wrong key or tampered token
// errors.JWSInvalid — malformed JWT structure

Fix 2: RSA Keys (RS256) for Asymmetric Signing

import { SignJWT, jwtVerify, importPKCS8, importSPKI, generateKeyPair } from 'jose';

// Option 1: Generate a key pair
const { publicKey, privateKey } = await generateKeyPair('RS256');

// Option 2: Import from PEM strings
const privateKeyPem = `-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqh...
-----END PRIVATE KEY-----`;

const publicKeyPem = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqh...
-----END PUBLIC KEY-----`;

const privateKey = await importPKCS8(privateKeyPem, 'RS256');
const publicKey = await importSPKI(publicKeyPem, 'RS256');

// Sign with private key
const token = await new SignJWT({ sub: '123', role: 'admin' })
  .setProtectedHeader({ alg: 'RS256', kid: 'my-key-id' })
  .setIssuedAt()
  .setExpirationTime('1h')
  .sign(privateKey);

// Verify with public key — different service can verify without the private key
const { payload } = await jwtVerify(token, publicKey, {
  algorithms: ['RS256'],
});

Generate RSA keys from the command line:

# Generate private key (PKCS8 format — required by jose)
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048

# Extract public key (SPKI format — required by jose)
openssl pkey -in private.pem -pubout -out public.pem

Convert PKCS1 to PKCS8 (if your key starts with BEGIN RSA PRIVATE KEY):

openssl pkcs8 -topk8 -inform PEM -outform PEM -in pkcs1-private.pem -out pkcs8-private.pem -nocrypt

Fix 3: JWKS (JSON Web Key Sets) — Verify Tokens from OAuth Providers

import { jwtVerify, createRemoteJWKSet } from 'jose';

// Fetch and cache JWKS from an identity provider
// Works with Auth0, Clerk, Firebase, Cognito, etc.
const JWKS = createRemoteJWKSet(
  new URL('https://your-tenant.auth0.com/.well-known/jwks.json')
);

// Verify a token using the JWKS — jose automatically finds the right key by kid
async function verifyOAuthToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://your-tenant.auth0.com/',
    audience: 'https://myapp.com/api',
    algorithms: ['RS256'],
  });

  return payload;
}

// Local JWKS — useful for testing or self-hosted keys
import { createLocalJWKSet, exportJWK, generateKeyPair } from 'jose';

// Generate and export as JWK
const { publicKey, privateKey } = await generateKeyPair('RS256');
const publicJWK = await exportJWK(publicKey);
publicJWK.kid = 'my-key-2024';
publicJWK.alg = 'RS256';
publicJWK.use = 'sig';

// Create a local JWKS
const localJWKS = createLocalJWKSet({
  keys: [publicJWK],
});

// Verify using local JWKS
const { payload } = await jwtVerify(token, localJWKS);

Fix 4: Refresh Token Pattern

import { SignJWT, jwtVerify, errors } from 'jose';

const accessSecret = new TextEncoder().encode(process.env.ACCESS_TOKEN_SECRET!);
const refreshSecret = new TextEncoder().encode(process.env.REFRESH_TOKEN_SECRET!);

// Generate token pair
async function generateTokens(userId: string) {
  const accessToken = await new SignJWT({ sub: userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m')  // Short-lived
    .sign(accessSecret);

  const refreshToken = await new SignJWT({ sub: userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('7d')   // Long-lived
    .setJti(crypto.randomUUID())  // Unique ID for revocation
    .sign(refreshSecret);

  return { accessToken, refreshToken };
}

// Verify access token — call on every API request
async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, accessSecret);
  return payload;
}

// Refresh — exchange refresh token for new access token
async function refreshAccessToken(refreshToken: string) {
  try {
    const { payload } = await jwtVerify(refreshToken, refreshSecret);

    // Check if refresh token has been revoked (store jti in database)
    const isRevoked = await checkIfRevoked(payload.jti!);
    if (isRevoked) throw new Error('Token revoked');

    // Issue new access token
    const accessToken = await new SignJWT({ sub: payload.sub })
      .setProtectedHeader({ alg: 'HS256' })
      .setIssuedAt()
      .setExpirationTime('15m')
      .sign(accessSecret);

    return { accessToken };
  } catch (error) {
    if (error instanceof errors.JWTExpired) {
      throw new Error('Refresh token expired — user must re-login');
    }
    throw error;
  }
}

Fix 5: Encrypted Tokens (JWE)

import { EncryptJWT, jwtDecrypt, generateSecret } from 'jose';

// Generate an encryption key (A256GCM)
const encryptionKey = await generateSecret('A256GCM');

// Or from a string
const encryptionKey = new TextEncoder().encode(
  process.env.ENCRYPTION_KEY!  // Must be exactly 32 bytes for A256GCM
);

// Create an encrypted JWT
const encryptedToken = await new EncryptJWT({
  sub: '123',
  email: '[email protected]',
  sensitiveData: 'should-not-be-readable',
})
  .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
  .setIssuedAt()
  .setExpirationTime('1h')
  .encrypt(encryptionKey);

// Decrypt
const { payload } = await jwtDecrypt(encryptedToken, encryptionKey);
console.log(payload.sensitiveData);  // 'should-not-be-readable'

// Signed AND encrypted — sign first, then encrypt
import { SignJWT, jwtVerify } from 'jose';

const signKey = new TextEncoder().encode(process.env.SIGN_SECRET!);

// Sign
const signed = await new SignJWT({ sub: '123' })
  .setProtectedHeader({ alg: 'HS256' })
  .setExpirationTime('1h')
  .sign(signKey);

// Encrypt the signed token
const encrypted = await new EncryptJWT({ token: signed })
  .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
  .encrypt(encryptionKey);

// Decrypt, then verify
const { payload: outer } = await jwtDecrypt(encrypted, encryptionKey);
const { payload: inner } = await jwtVerify(outer.token as string, signKey);

Fix 6: Edge Runtime and Framework Integration

jose works in all runtimes without polyfills:

// Cloudflare Workers
export default {
  async fetch(request: Request, env: Env) {
    const token = request.headers.get('Authorization')?.replace('Bearer ', '');
    if (!token) return new Response('Unauthorized', { status: 401 });

    const secret = new TextEncoder().encode(env.JWT_SECRET);

    try {
      const { payload } = await jwtVerify(token, secret);
      return new Response(JSON.stringify({ user: payload.sub }));
    } catch {
      return new Response('Invalid token', { status: 401 });
    }
  },
};

// Next.js Middleware (Edge Runtime)
// middleware.ts
import { jwtVerify } from 'jose';
import { NextResponse, type NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value;
  if (!token) return NextResponse.redirect(new URL('/login', request.url));

  try {
    const secret = new TextEncoder().encode(process.env.JWT_SECRET!);
    await jwtVerify(token, secret);
    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};

// Hono middleware
import { Hono } from 'hono';
import { jwtVerify } from 'jose';

const app = new Hono();

app.use('/api/*', async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '');
  if (!token) return c.json({ error: 'Unauthorized' }, 401);

  const secret = new TextEncoder().encode(c.env.JWT_SECRET);
  const { payload } = await jwtVerify(token, secret);
  c.set('userId', payload.sub);
  await next();
});

Fix 7: Platform Differences — Web Crypto, Node, Algorithm Support, JWK Rotation

The same jose call can succeed in one runtime and throw NotSupportedError in another. The differences come down to which algorithms Web Crypto supports on each platform and how the runtime exposes secrets.

Web Crypto vs Node crypto. jose always uses Web Crypto (crypto.subtle). Node 18+ provides Web Crypto as globalThis.crypto.subtle, so on modern Node the API is identical to a browser or Worker. Older Node (16 and below) only has node:crypto, and jose v5 dropped fallback support for those versions — running jose on Node 16 fails at import time with crypto.subtle is not defined. If you must support Node 16, pin to jose@4. The trade-off is missing Ed25519 and PS512 support in v4.

Algorithm support per runtime.

AlgorithmNode 18+Cloudflare WorkersVercel EdgeDenoBun
HS256/384/512yesyesyesyesyes
RS256/384/512yesyesyesyesyes
PS256/384/512yesyesyesyesyes
ES256/384yesyesyesyesyes
ES512yesyes (2024+)yes (2024+)yesyes
EdDSA (Ed25519)yes (19+)yes (2024+)yes (2024+)yesyes

If your issuer signs with EdDSA but your Edge middleware verifies on a runtime predating 2024, you’ll get a NotSupportedError. Switching to RS256 fixes it without code changes; long-term, pin a runtime version that supports the algorithm.

Secret storage per platform.

  • Cloudflare Workers — secrets come from env.JWT_SECRET (set via wrangler secret put). They are strings, so wrap with TextEncoder to get a Uint8Array.
  • Vercel — secrets are environment variables on the function. Same TextEncoder pattern. Edge functions and Node functions both read from process.env.
  • Next.js Middleware — runs on Edge, reads process.env, but the env value is inlined into the bundle at build time for non-Vercel hosts. Rotating a secret needs a redeploy.
  • AWS Lambda — Lambda secrets normally come from Secrets Manager or Parameter Store. Don’t hard-code them in environment variables for anything beyond development.
  • Browser — never embed an HMAC secret. Browsers can verify with a public RSA/EC key fetched from a JWKS endpoint, but signing in the browser is only safe for tokens never trusted by the server.

JWK rotation in serverless. A rotation strategy needs three pieces: a stable kid per key, a JWKS endpoint that exposes both old and new keys during the overlap, and a JWKS cache short enough that consumers pick up the new key within minutes. createRemoteJWKSet defaults to 30s cache and 5s timeout — both can be tuned via the options object:

const JWKS = createRemoteJWKSet(new URL('https://issuer/.well-known/jwks.json'), {
  cacheMaxAge: 10 * 60 * 1000,   // 10 minutes
  cooldownDuration: 30 * 1000,    // Don't refetch for 30s after a miss
  timeoutDuration: 5 * 1000,
});

In a serverless runtime, every cold start refetches the JWKS unless you cache it in module scope. The fetch itself counts against your issuer’s rate limit. Move the createRemoteJWKSet call to module scope so the cache is reused within a warm instance, and consider an HTTP-cached CDN proxy in front of the JWKS endpoint.

Browser sub-cases. Verifying a JWT in the browser is fine for RS256 or ES256 — the public key is, well, public. The pitfall is signing in the browser: any HMAC secret in client code is leaked. If you need browser-issued tokens, use ES256 with a key that lives in IndexedDB and never leaves the device. For authentication patterns that avoid embedded secrets entirely, passkey/WebAuthn flows are usually a better fit than browser-held JWT signing keys.

Still Not Working?

“signature verification failed” with a correct secret — the secret must be encoded identically for signing and verification. new TextEncoder().encode('my-secret') must use the exact same string on both sides. If the secret comes from an environment variable, check for trailing newlines or whitespace: process.env.JWT_SECRET!.trim().

“alg not allowed” errorjwtVerify defaults to allowing all algorithms. But if you pass an algorithms option, the token’s alg header must match. Check the token’s header: JSON.parse(atob(token.split('.')[0])) shows the alg value.

Token works in Node.js but fails in the browserjose works in browsers, but make sure you’re not accidentally exposing your signing secret to the client. The browser should only verify tokens (with a public key) or send tokens to the server. Never embed HMAC secrets in client-side code.

importPKCS8 fails with “invalid format” — the PEM key must be in PKCS8 format (BEGIN PRIVATE KEY), not PKCS1 (BEGIN RSA PRIVATE KEY). Convert with: openssl pkcs8 -topk8 -inform PEM -in old.pem -out new.pem -nocrypt.

NotSupportedError in Cloudflare Workers or Vercel Edge for EdDSA/Ed25519 — the runtime is older than the EdDSA support date. Pin your worker compatibility_date to a 2024 value or later, or switch the issuer to RS256. The same fix applies to ES512 on older Edge runtimes.

JWKS fetch latency causes login timeouts during cold starts — extend cacheMaxAge on createRemoteJWKSet, move the call to module scope so warm instances reuse the cache, and put a short-lived CDN cache in front of the issuer’s JWKS endpoint. The default 30-second cache is too short for a serverless function with infrequent traffic.

Token verifies in Node but throws Unrecognized key usage in Workers — the imported key’s usages array doesn’t include verify. This happens when importing a JWK that was exported for signing only. Re-import with importJWK(jwk, alg, { extractable: true }) and ensure the JWK’s key_ops is empty or includes the operation you need.

For related auth issues, see Fix: Auth.js Not Working, Fix: Lucia Auth Not Working, Fix: Hono Not Working, and Fix: Vercel Edge Function Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles