Skip to content

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

FixDevs ·

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):

  • 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.

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();
});

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.

For related auth issues, see Fix: Auth.js Not Working and Fix: Lucia Auth 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