From HMAC Signing to Web Crypto API: Building a Browser-Based JWT Generator
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:
- No built-in HMAC: Browsers don’t natively support HMAC signing
- Node.js libraries won’t work: They depend on Node.js
cryptomodule - 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:
- Requires PEM format keys: Not convenient for users
- Web Crypto format requirements: Needs JWK or SPKI format import
- 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:
- Construct test tokens: Quickly modify payload fields like user ID, permissions
- Debug expiration logic: Manually set
expto test edge cases - 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