~/ 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