From HMAC Signing to Web Crypto API: Building a Browser-Based JWT Generator#

When working on frontend-backend separation projects, I often need to construct JWT tokens for API debugging. Asking backend colleagues every time is inconvenient, so I decided to implement a browser-based JWT generator and document the technical details.

JWT’s Three-Part Structure#

JWT consists of three parts, separated by dots:

header.payload.signature

Each part is Base64Url-encoded JSON:

// Header: algorithm and type
{ "alg": "HS256", "typ": "JWT" }

// Payload: user data and claims
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }

// Signature: tamper-proof signature
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

Base64Url differs from standard Base64 by replacing +/= with -_ and removing padding, making the generated token URL-safe.

Browser-Side Signing Challenges#

In Node.js, generating JWT is straightforward with the jsonwebtoken library:

const jwt = require('jsonwebtoken')
const token = jwt.sign({ userId: 123 }, 'secret', { algorithm: 'HS256' })

But in the browser, we face several issues:

  1. No built-in HMAC: Browsers don’t natively support HMAC signing
  2. Node.js libraries won’t work: They depend on Node.js crypto module
  3. Security concerns: Is exposing the secret key in frontend safe?

For the third point, JWT generation inherently requires the secret key. Frontend generation is mainly for development and testing; production environments should generate tokens server-side.

Web Crypto API for HMAC Signing#

Browsers provide the Web Crypto API, supporting HMAC-SHA256/384/512. Complete implementation:

async function signJWT(header: object, payload: object, secret: string): Promise<string> {
  // 1. Base64Url encoding
  const base64UrlEncode = (str: string): string => {
    return btoa(str)
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '')
  }
  
  const headerB64 = base64UrlEncode(JSON.stringify(header))
  const payloadB64 = base64UrlEncode(JSON.stringify(payload))
  const signingInput = `${headerB64}.${payloadB64}`
  
  // 2. Import key
  const encoder = new TextEncoder()
  const keyData = encoder.encode(secret)
  const cryptoKey = await crypto.subtle.importKey(
    'raw',
    keyData,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )
  
  // 3. Calculate signature
  const messageData = encoder.encode(signingInput)
  const signatureBuffer = await crypto.subtle.sign('HMAC', cryptoKey, messageData)
  
  // 4. Convert signature and concatenate
  const signatureArray = new Uint8Array(signatureBuffer)
  let binary = ''
  for (let i = 0; i < signatureArray.length; i++) {
    binary += String.fromCharCode(signatureArray[i])
  }
  const signatureB64 = base64UrlEncode(binary)
  
  return `${signingInput}.${signatureB64}`
}

Key technical points:

1. Key Import#

crypto.subtle.importKey requires algorithm parameters:

{
  name: 'HMAC',     // Algorithm name
  hash: 'SHA-256'   // Hash algorithm, supports SHA-256/384/512
}

The last parameter ['sign'] specifies key usage - this key can only be used for signing.

2. ArrayBuffer to Base64Url#

crypto.subtle.sign returns ArrayBuffer, which needs conversion to binary string before encoding:

const signatureArray = new Uint8Array(signatureBuffer)
let binary = ''
for (let i = 0; i < signatureArray.length; i++) {
  binary += String.fromCharCode(signatureArray[i])
}

Can’t use String.fromCharCode.apply(null, signatureArray) - large arrays trigger “Maximum call stack size exceeded” error.

3. Supporting Multiple Algorithms#

HS256/HS384/HS512 differ in hash algorithm:

const ALGO_MAP = {
  HS256: 'SHA-256',
  HS384: 'SHA-384', 
  HS512: 'SHA-512'
}

const cryptoKey = await crypto.subtle.importKey(
  'raw',
  keyData,
  { name: 'HMAC', hash: ALGO_MAP[algorithm] },
  false,
  ['sign']
)

Payload Field Design#

JWT standard defines reserved fields (Registered Claims):

Field Meaning Example
iss Issuer "auth.example.com"
sub Subject (User ID) "1234567890"
aud Audience "api.example.com"
exp Expiration Time 1516239022
iat Issued At 1516239022
nbf Not Before 1516239022

Implementation can provide quick-add buttons:

const handleAddCommonField = (key: string) => {
  let value = ''
  if (key === 'iat') value = String(Math.floor(Date.now() / 1000))
  else if (key === 'exp') value = String(Math.floor(Date.now() / 1000) + 3600) // 1 hour later
  else if (key === 'nbf') value = String(Math.floor(Date.now() / 1000))
  
  setPayloadFields(prev => [...prev, { key, value }])
}

Timestamps must be in seconds. JavaScript’s Date.now() returns milliseconds, requiring division by 1000.

RS256 Browser Limitations#

RS256 uses RSA private key for signing, public key for verification. Browser implementation has challenges:

  1. Requires PEM format keys: Not convenient for users
  2. Web Crypto format requirements: Needs JWK or SPKI format import
  3. Higher security risk: Private key exposure is worse than shared secret

In the tool, I made a restriction:

if (algorithm === 'RS256') {
  setGenerateError('RS256 requires PEM keys - not supported in browser-only mode')
  return
}

For frontend RS256 token generation, consider:

  • Allow users to upload PKCS#8 format private key files
  • Use WebAssembly to port OpenSSL

Decode and Verify Implementation#

The generator also provides decoding:

function base64UrlDecode(str: string): string {
  let base64 = str.replace(/-/g, '+').replace(/_/g, '/')
  while (base64.length % 4) base64 += '='  // Pad
  return atob(base64)
}

function decodeJWT(token: string) {
  const parts = token.split('.')
  if (parts.length !== 3) throw new Error('Invalid JWT format')
  
  const header = JSON.parse(base64UrlDecode(parts[0]))
  const payload = JSON.parse(base64UrlDecode(parts[1]))
  
  return { header, payload, signature: parts[2] }
}

Note this is decoding, not verification. Verification requires recalculating the signature with the same secret and comparing.

Expiration Status Detection#

After decoding, check if token is expired:

function getExpirationStatus(payload: Record<string, unknown>): string {
  const now = Math.floor(Date.now() / 1000)
  
  if (payload.exp && typeof payload.exp === 'number') {
    if (payload.exp < now) return 'expired'
  }
  if (payload.nbf && typeof payload.nbf === 'number') {
    if (payload.nbf > now) return 'notYetValid'
  }
  if (payload.exp || payload.nbf) return 'valid'
  return 'none'
}

Users can immediately see status after pasting: valid, expired, or not yet valid.

Practical Use Cases#

During development, this tool is quite useful:

  1. Construct test tokens: Quickly modify payload fields like user ID, permissions
  2. Debug expiration logic: Manually set exp to test edge cases
  3. Parse unknown tokens: See token contents to understand backend implementation

Based on these principles, I built an online tool: JWT Generator

Key features:

  • HS256/HS384/HS512 signature generation
  • Real-time decoding and expiration detection
  • Quick-add for common fields
  • Colorful syntax highlighting

Related tools: JWT Debugger | Base64 Encoder/Decoder