iKit
Tutorial · 9 min read ·

Build Your Own JWT in 5 Lines of JavaScript (2026)

Sign a JWT in the browser with HS256 and the Web Crypto API — five lines of JavaScript, no library, no server. The 2026 walkthrough.

Build Your Own JWT in 5 Lines of JavaScript (2026)

Build Your Own JWT in 5 Lines of JavaScript

JWT libraries are clever, audited, and 40 KB. They also hide what a JWT actually is: three base64url strings joined by dots, with one of them being an HMAC. Once you see that, the whole token becomes five lines of JavaScript and one Web Crypto call. This post walks through the smallest possible HS256 JWT generator that still produces a token Auth0, Firebase, and jwt.io will accept.

TL;DR

  • A JWT is base64url(header).base64url(payload).base64url(signature).
  • HS256 means the signature is HMAC-SHA256 of the first two parts joined by ..
  • The Web Crypto API does the HMAC in one crypto.subtle.sign('HMAC', key, data) call.
  • The "5 lines" assume two helpers: a TextEncoder (built in) and a base64url converter (5 more lines, also written from scratch below).
  • Use HS256 only when signer and verifier are the same system; otherwise pick RS256 or ES256.

What HS256 actually does in a JWT

The three parts of a JWT

RFC 7519 defines a JWT as a sequence of "URL-safe parts separated by period (.) characters", where each part is base64url-encoded. The first part is the JOSE header, a tiny JSON object describing the algorithm. The second is the payload — your claims, also JSON. The third is the signature, which proves that the first two parts have not been tampered with.

A real HS256 token looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiIxMjMiLCJleHAiOjE3OTQ4ODAwMDB9
.K2L4hH0pV1q1n0Zo7ZqJq9jdPv3vG6V7QWb5y8cE8jA

Decode the first two with iKit's JWT Decoder and you see plain JSON. The third part is the raw HMAC output, base64url-encoded.

Why HMAC, not RSA

HS256 is HMAC with SHA-256, specified in FIPS 198-1 and referenced directly by the JWS spec. The single shared secret signs and verifies — there is no public key. That makes HS256 the wrong choice when the verifier is someone else (a third party can mint tokens that look identical to yours), but the right choice when the same service issues and consumes the token. We covered the trade-off in depth in HS256 vs RS256.

What the secret actually signs

This is the part most tutorials skip. HS256 does not sign the payload object. It signs the ASCII string BASE64URL(UTF8(header)) || '.' || BASE64URL(payload) — the two pieces of the token as they already appear in the wire format. That is why you must encode first, then sign. Reverse the order and your signature is for bytes nobody else will reconstruct.

How to build a JWT in JavaScript

The five-line core

Here is the whole thing. Assume b64url(bytes) and enc(str) helpers (we will write them in 60 seconds):

const head = b64url(enc(JSON.stringify({alg:'HS256',typ:'JWT'})));
const body = b64url(enc(JSON.stringify(payload)));
const key  = await crypto.subtle.importKey('raw', enc(secret),
             {name:'HMAC', hash:'SHA-256'}, false, ['sign']);
const sig  = b64url(new Uint8Array(
             await crypto.subtle.sign('HMAC', key, enc(`${head}.${body}`))));
const jwt  = `${head}.${body}.${sig}`;

Five statements, one HMAC call, one string concatenation. No library, no server, no dependencies. Run this on any modern browser tab and you get a valid HS256 JWT.

What each line does

Line 1 builds the header: a fixed JSON object, serialised, UTF-8 encoded into bytes, then base64url-encoded into the ASCII string that goes on the wire. Line 2 does the same for the payload — your claims, like {sub: "user-123", exp: 1794880000}.

Line 3 turns the shared secret (a regular string) into a CryptoKey the Web Crypto API can use. The 'raw' format means "the bytes are the key" — no parsing, no PEM. We mark it non-extractable (false) and restrict it to ['sign'], which is a defence-in-depth habit worth keeping.

Line 4 is the signature. We feed crypto.subtle.sign the algorithm name, the key, and the ASCII bytes of header.payload. It returns an ArrayBuffer of 32 raw bytes (HMAC-SHA256 is always 256 bits). We wrap it in a Uint8Array so our base64url helper can index it, then base64url-encode.

Line 5 joins everything with a ..

Running it end-to-end

The two missing helpers:

const enc = (s) => new TextEncoder().encode(s);
const b64url = (bytes) => btoa(
  String.fromCharCode(...bytes))
  .replace(/\+/g,'-').replace(/\//g,'_')
  .replace(/=+$/,'');

TextEncoder is the standard UTF-8 encoder; MDN documents it on every Web Crypto example. The b64url helper does three things: turns bytes into a binary string, base64-encodes via btoa, then swaps the URL-unsafe characters and trims padding. We will explain why below.

Drop everything into one file:

async function makeJWT(payload, secret) {
  const enc = (s) => new TextEncoder().encode(s);
  const b64url = (b) => btoa(String.fromCharCode(...b))
    .replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');

  const head = b64url(enc(JSON.stringify({alg:'HS256',typ:'JWT'})));
  const body = b64url(enc(JSON.stringify(payload)));
  const key  = await crypto.subtle.importKey('raw', enc(secret),
               {name:'HMAC', hash:'SHA-256'}, false, ['sign']);
  const sig  = b64url(new Uint8Array(await crypto.subtle.sign(
               'HMAC', key, enc(`${head}.${body}`))));
  return `${head}.${body}.${sig}`;
}

const token = await makeJWT(
  { sub: 'alice', exp: Math.floor(Date.now()/1000) + 3600 },
  'a-32-byte-secret-or-longer-please'
);
console.log(token);

Paste the output into iKit's JWT Decoder and the signature checks out. The same token verifies against Auth0's jsonwebtoken, Python's pyjwt, and Go's golang-jwt libraries — they all implement the same RFC 7515 procedure.

How to sign HMAC-SHA256 in the browser

Importing the secret as a CryptoKey

The Web Crypto API will not accept raw strings or Uint8Arrays where it expects a key — it needs a CryptoKey handle. MDN's HMAC example shows the exact import we use:

const key = await crypto.subtle.importKey(
  'raw',
  new TextEncoder().encode(secret),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['sign']
);

The false means the key cannot be re-exported back into JavaScript — useful when you want to load a long-lived secret once and rely on the browser's internal storage. For a one-shot in-tab signing call, it makes no real difference, but it is a sensible default.

Calling crypto.subtle.sign

The signing primitive itself is one line. Per MDN, sign() returns a Promise that fulfils with an ArrayBuffer containing the signature. For HMAC, that buffer is exactly 32 bytes for SHA-256, 48 for SHA-384, and 64 for SHA-512.

const sig = await crypto.subtle.sign('HMAC', key, data);

Converting the ArrayBuffer to base64url

The signature you get back is raw bytes, not a string. Browsers do not ship a built-in base64url encoder, so we build one from btoa and three regex swaps. Internally this is two passes — once to base64, once to URL-safe — which is fine for token-sized inputs.

Step What happens
String.fromCharCode(...bytes) bytes → binary string
btoa(...) binary string → base64
.replace(/\+/g,'-') +-
.replace(/\//g,'_') /_
.replace(/=+$/,'') strip trailing =

For payloads over ~120 KB, the spread operator into String.fromCharCode can hit argument-length limits — chunk the loop or use a Uint8Array → string reducer. JWTs are tiny, so this almost never matters.

Why base64url is not base64

The character swap

Section 5 of RFC 4648 defines a URL- and filename-safe base64 variant. The alphabet is identical for the first 62 characters and differs only on the last two: + becomes -, and / becomes _. The reason is obvious once you remember where JWTs live — URLs use ? and & as separators and % as the escape prefix, and + historically encodes a space in form bodies.

Why padding is dropped

RFC 7515 is explicit: base64url in JWTs is used with all trailing = characters omitted. The number of = you would otherwise add is deterministic — if length mod 4 == 2 you add two, if mod 4 == 3 you add one, if mod 4 == 0 you add none — so a parser can always recover. The spec goes further and says a JWT containing = characters must be rejected before the signature is checked. Servers that ignore this rule have shipped real CVE-able bugs.

A two-line base64url helper

For symmetry, here is the decode side — useful when you write a verifier later:

const b64urlDecode = (s) => Uint8Array.from(
  atob(s.replace(/-/g,'+').replace(/_/g,'/')
        .padEnd(s.length + (4 - s.length % 4) % 4, '=')),
  c => c.charCodeAt(0)
);

Swap the characters back, restore padding to a multiple of four, run through atob, and map each character back to its byte value. Together with the encoder above, the whole base64url toolkit is six lines.

Common mistakes when building JWTs from scratch

A short list of footguns that bite everyone the first time:

  • UTF-8 encoding the wrong thing. Encode the JSON string, not the JavaScript object. JSON.stringify first, TextEncoder second.
  • Signing the object, not the string. HS256 signs head.body as ASCII. If you concatenate the bytes of the unencoded header and payload, your signature is for data nobody else will reconstruct.
  • Using a short or guessable secret. HMAC strength is bounded by key entropy. Anything under ~32 random bytes is brute-forceable. A surprising number of breaches start with secret = 'changeme'.
  • Adding = padding to the output. RFC 7515 rejects tokens containing =. Strip it.
  • Putting secrets in the payload. The payload is base64url — encoded, not encrypted. Anyone can decode it. We covered the field-by-field claim conventions in JWT standard claims explained.
  • Forgetting await. crypto.subtle.sign is async. A common bug is concatenating a Promise into the token string and wondering why the signature is [object Promise].

When to use a library instead of 5 lines

The 5-line generator is great for learning, internal tools, and signed cookies in a self-contained app. Reach for a library when:

  • You need RS256, ES256, or EdDSA. Public-key signing involves key formats (PEM, JWK) and parameter choices that a battle-tested library gets right.
  • You need JWKS rotation. Pulling the right key by kid and caching JWKS responses is non-trivial.
  • You need verification, not just signing. The fast-path is symmetric (run the same HMAC, compare), but constant-time comparison and a hard ban on alg: none need careful coding — the alg: none vulnerability is a 12-year-old footgun that still ships in 2026.
  • You are issuing tokens for third parties. Anything an external service consumes should use asymmetric crypto; the trust model around a shared secret is wrong.

The Web Crypto API itself is solid — it has been Baseline Widely Available across all major browsers since January 2020 — so the five-line approach scales further than you might expect.

References

Related on iKit

Related posts