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
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.stringifyfirst,TextEncodersecond. - Signing the object, not the string. HS256 signs
head.bodyas 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.signis async. A common bug is concatenating aPromiseinto 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
kidand 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: noneneed careful coding — thealg: nonevulnerability 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
- RFC 7519 — JSON Web Token (JWT) — JWT structure, claim conventions, and the HS256 worked example.
- RFC 7515 — JSON Web Signature (JWS) — base64url-without-padding rule and the signing-input construction.
- RFC 4648 — The Base16, Base32, and Base64 Data Encodings — Section 5 defines the URL-safe base64 alphabet.
- MDN: SubtleCrypto.sign() — HMAC example, return type, and browser-baseline status.
- MDN: SubtleCrypto.importKey() —
'raw'format and theHmacImportParamsobject. - FIPS 198-1: The Keyed-Hash Message Authentication Code (HMAC) — the cryptographic spec HS256 implements.
Related on iKit
- Decode the token you just built — iKit's JWT Decoder verifies HS256 signatures locally — the natural companion to building your own: paste the token back and see header, payload, and signature parsed, with the same Auth0/Firebase examples used here.
- HS256 vs RS256: which JWT algorithm should you pick — once you can sign with HS256, the next question is whether you should. Walks through the trust-boundary heuristic for choosing.
- Inside a JWT: a field-by-field guide to standard claims — the payload object in the 5-line generator should contain
iss,sub,exp,iat. This is the reference for which RFC 7519 claim does what. - alg: none — the JWT vulnerability that still bites in 2026 — when you write the verifier, this is the bug your code must not have.
- How to debug a 401 Unauthorized by decoding the JWT — your freshly-minted token is failing? Start here.
- JWT Decoder vs jwt.io: privacy and features compared — why we built a browser-only decoder instead of sending tokens to a third-party site.
Related posts
How to Debug a 401 Unauthorized by Decoding the JWT (2026)
When an API returns 401 Unauthorized in 2026, the JWT is usually the smoking gun. Decode the token, read exp, aud, iss — and fix the bug in 90 seconds.
Convert Unix Timestamp to Date Without Timezone Bugs (2026)
Converting a Unix timestamp to a date is one division operation, but the timezone you format it in is the whole bug. Here's how to get it right.
How to Debug a 400 Bad Request With URL Decoding (2026)
A 400 Bad Request usually traces to a URL decoding bug. Here's how to decode the query string, spot the broken character, and ship a fix in five minutes.