4

I'm trying to make a JWT generator in JavaScript for educational purposes. There is a jwt.io tool to create and/or validate JWT.

I'm struggling to get my results match the results from the validator. The problem is the signature.

Here's my code:

function base64url(input) {
    return btoa(typeof input === 'string' ? input : JSON.stringify(input))
        .replace(/=+$/, '')
        .replace(/\+/g, '-')
        .replace(/\//g, '_');
}

const JWT = {
    encode(header, payload, secret) {
        const unsigned = [base64url(header), base64url(payload)].join('.');

        return [unsigned, base64url(sha256.hmac(secret, unsigned))].join('.');
    }
};

To encrypt HMAC SHA256 I'm using js-sha256 library with sha256.hmac(key, value) prototype. I compared it with online tools and it works fine.

Now, I test it with the following code:

const jwt = JWT.encode(
    {
        alg: 'HS256',
        typ: 'JWT'
    },
    123,
    'xxx'
);

The result I get is:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.MTIz.NzhlNTFmYzUxOGQ2YjNlZDFiOTM0ZGRhOTUwNDFmMzEwMzdlNmZkZWRhNGFlMjdlNDU3ZTZhNWRhYjQ1YzFiMQ

On the other hand, the result from jwt.io is:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.MTIz.eOUfxRjWs-0bk03alQQfMQN-b97aSuJ-RX5qXatFwbE

As you can see, the two out of three chunks of JWT are identical in my result and jwt.io result. The signature is different and if you ask me, the signature generated by it is surprisingly short. That tool also marks my own JWT as invalid.

I checked with online HMAC SHA256 generators and it looks like my code creates a valid signature, so:

base64url(sha256.hmac('xxx', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.MTIz')) ===
'NzhlNTFmYzUxOGQ2YjNlZDFiOTM0ZGRhOTUwNDFmMzEwMzdlNmZkZWRhNGFlMjdlNDU3ZTZhNWRhYjQ1YzFiMQ'

Is jwt.io just broken or does it do it some other way?

Robo Robok
  • 21,132
  • 17
  • 68
  • 126

1 Answers1

6

I wouldn't say you're doing it wrong, but you missed a small but important detail. The result from jwt.io is correct and the hash you calculate is also correct. But the signature you create with your hash is not correct.

The hash you calculate with sha256.hmac(secret, unsigned) is a large number but the return value of the function is a hexadecimal string representation of that large number. For the signature you need to base64url encode the original number instead of it's string representation.

I modified your code, so that it encodes the hash value directly to base64url (node.js version):

const JWT = {
    encode(header, payload, secret) {
        const unsigned = [base64url(header), base64url(payload)].join('.');
        const hash  = sha256.hmac(secret, unsigned);
        console.log(hash);        
        var signature = new Buffer.from(hash, 'hex').toString('base64').replace(/\+/g,'-').replace(/\=+$/m,'');

        return [unsigned, signature].join('.');
    }
};

or, if you don't use node.js, you can use this instead (as suggested by Robo Robok):

const JWT = {
    encode(header, payload, secret) {
        const unsigned = [base64url(header), base64url(payload)].join('.');

        return [unsigned, base64url(sha256.hmac(secret, unsigned).replace(/\w{2}/g, byte => String.fromCharCode(parseInt(byte, 16))))].join('.');
    }
};

The result is a token, which is identical to the one created with jwt.io:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.MTIz.eOUfxRjWs-0bk03alQQfMQN-b97aSuJ-RX5qXatFwbE

See also my answer here, in which I explained the steps to compare the results from different tools.

jps
  • 20,041
  • 15
  • 75
  • 79