~/ diy jwt signatures

I recently had a need to create a jwt with a valid signature in a browser without using a library.

Creating a JWT

The header and the payload

const header = {
"typ": "JWT",
"alg": "ES256"
}

const payload = {
"iss": "joe",
"exp": 1300819380,
"http://example.com/is_root": true
}

are base 64 encoded

const base64UrlEncode = (object) => {
return btoa(JSON.stringify(object))
}

to

base64UrlEncode(header)
// eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9

and

base64UrlEncode(payload)
// eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ==

but the padding should be removed (rfc7515)

const base64UrlEncode = (object) => {
return btoa(JSON.stringify(object))
+ .replace(/=/g,"")
+ .replace(/\//g,"_")
+ .replace(/\+/g,"-")
}

and joined with "." to form a jwt

const jwt = [
base64UrlEncode(header),
base64UrlEncode(payload)
].join(".")
// eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiJ9.eyJpc3MiOiJqb2UiLCJleHAiOjEzMDA4MTkzODAsImh0dHA6Ly9leGFtcGxlLmNvbS9pc19yb290Ijp0cnVlfQ

Signing the JWT

Generate an asymmetric keypair

const {publicKey, privateKey} = await crypto.subtle.generateKey(
{
name: "ECDSA",
namedCurve: "P-256",
},
false, // non-extractable private key
["sign", "verify"]
)

I picked ECDSA using P-256 because of the Recommended+ label in rfc7518

Sign the jwt with the private key

const signature = await crypto.subtle.sign({
name: "ECDSA",
hash: { name: "SHA-256" }
},
privateKey,
jwt
)

but that throws an error

Uncaught (in promise) TypeError: SubtleCrypto.sign: 
Argument 3 could not be converted to any of:
ArrayBufferView, ArrayBuffer.

so the data must be encoded as a Uint8Array

const encodedJwt = new TextEncoder().encode(jwt)

and re-signed

const signature = await crypto.subtle.sign({
name: "ECDSA",
hash: { name: "SHA-256" }
},
privateKey,
encodedJwt
)

convert the signature's ArrayBuffer to a Uint8Array

const encodedSignature = new Uint8Array(signature)

and base64 encode the signature as a binary string

const base64UrlEncode = (object) => {
+ let string
+ if( object instanceof Uint8Array ) {
+ string = String.fromCharCode(...object)
+ } else {
+ string = JSON.stringify(object)
+ }
- return btoa(JSON.stringify(object))
+ return btoa(string)
.replace(/=/g,"")
.replace(/\//g,"_")
.replace(/\+/g,"-")
}
const base64UrlEncodedSignature = base64UrlEncode(encodedSignature)
// rF6SuHTRcYWeGOkkgQ0QMGHomW_5e4s7dYKtU5Gvteiy_qmy8fEnpLFuIRfP0J4hIiYWCEftfWOFMUeEpXl_Qw

and add it to the existing jwt

const signedJwt = `${jwt}.${base64UrlEncodedSignature}`

and now I can see that it is verifiable in the jwt.io debugger 🥳 but also 🔐

Verifying the JWT Signature

split up the signed jwt

const [encodedHeader, encodedPayload, encodedSignature] = 
signedJwt.split(".")

decode the signature

const decodedSignatureFromJwt = atob(encodedSignature)
// Uncaught (in promise) DOMException: String contains an invalid character

put back the url unsafe characters

const decodedSignatureFromJwt = atob(
encodedSignature.replace(/-/g,"+").replace(/_/g,"/")
)

but this is a binary string

console.log(decodedSignatureFromJwt)
// ¬^�¸tÑq���é$��0aè�où{�;u�­S�¯µè²þ©²ññ'¤±n!�ÏÐ�!"&��Gí}c�1G�¥y�C

convert it to a Uint8Array

const decodedSignatureFromJwtAsUint8Array = Uint8Array
.from(decodedSignatureFromJwt, c => c.charCodeAt(0))
// new Uint8Array([172,94,146,184,116,209,113,133,158,24,233,36,129,13,16,48,97,232,153,111,249,123,139,59,117,130,173,83,145,175,181,232,178,254,169,178,241,241,39,164,177,110,33,23,207,208,158,33,34,38,22,8,71,237,125,99,133,49,71,132,165,121,127,67])

and verify the signature

const verified = await crypto.subtle.verify({
name: "ECDSA",
hash: { name: "SHA-256" },
},
publicKey,
decodedSignatureFromJwtAsUint8Array,
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)
)
// true

Pretty cool. Here's all the code I used in copy-paste format:

(async () => {

const header = {
"typ": "JWT",
"alg": "ES256"
}

const payload = {
"iss": "joe",
"exp": 1300819380,
"http://example.com/is_root": true
}

const base64UrlEncode = (object) => {
let string
if (object instanceof Uint8Array) {
string = String.fromCharCode(...object)
} else {
string = JSON.stringify(object)
}

return btoa(string)
.replace(/=/g, "")
.replace(/\//g, "_")
.replace(/\+/g, "-")
}

const jwt = [
base64UrlEncode(header),
base64UrlEncode(payload)
].join(".")

// sign
const encodedJwt = new TextEncoder().encode(jwt)
const { publicKey, privateKey } = await crypto.subtle.generateKey(
{
name: "ECDSA",
namedCurve: "P-256",
},
false,
["sign", "verify"]
)

const signature = await crypto.subtle.sign({
name: "ECDSA",
hash: { name: "SHA-256" }
},
privateKey,
new TextEncoder().encode(jwt)
)
const encodedSignature = new Uint8Array(signature)
const jwtWithSignature = `${jwt}.${base64UrlEncode(encodedSignature)}`

const jwk = await crypto.subtle.exportKey("jwk", publicKey)

console.log("jwt.io debugger url:")
console.log(`https://jwt.io/#debugger-io?token=${jwtWithSignature}&publicKey=${encodeURIComponent(JSON.stringify(jwk))}`)

// verify
const importedPublicKey = await crypto.subtle.importKey("jwk", jwk, {
name: "ECDSA",
namedCurve: "P-256",
}, false, ["verify"])
const [encodedHeaderFromJwt, encodedPayloadFromJwt, encodedSignatureFromJwt] = jwtWithSignature.split(".")
const decodedHeaderFromJwt = atob(encodedHeaderFromJwt)
const decodedPayloadFromJwt = atob(encodedPayloadFromJwt)
const decodedSignatureFromJwt = atob(encodedSignatureFromJwt.replace(/-/g, "+").replace(/_/g, "/"))
const decodedSignatureFromJwtAsUint8Array = Uint8Array.from(decodedSignatureFromJwt, c => c.charCodeAt(0))
const verified = await crypto.subtle.verify({
name: "ECDSA",
hash: { name: "SHA-256" },
},
publicKey,
decodedSignatureFromJwtAsUint8Array,
new TextEncoder().encode(`${encodedHeaderFromJwt}.${encodedPayloadFromJwt}`)
)
})()

Further Reading


~/ Posted by Jesse Shawl on 2024-01-26