12
votes

From what I can understand, it's a straight forward process to validate a JWT signature. But when I use some online tools to do this for me, it doesn't match. How can I manually validate a JWT signature without using a JWT library? I'm needing a quick method (using available online tools) to demo how this is done.

I created my JWT on https://jwt.io/#debugger-io with the below info:

  • Algorithm: HS256
  • Secret: hONPMX3tHWIp9jwLDtoCUwFAtH0RwSK6
  • Header:
    {
      "alg": "HS256",
      "typ": "JWT"
    }
    
  • Payload:
    {
      "sub": "1234567890",
      "name": "John Doe",
      "iat": 1516239022
    }
    
  • Verify Signature (section):
    • Secret value changed to above
    • "Checked" secret base64 encoded (whether this is checked or not, still get a different value)

JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.wDQ2mU5n89f2HsHm1dluHGNebbXeNr748yJ9kUNDNCA

Manual JWT signature verification attempt:

Using a base64UrlEncode calculator (http://www.simplycalc.com/base64url-encode.php or https://www.base64encode.org/)

If I: (Not actual value on sites, modified to show what the tools would ultimately build for me)

base64UrlEncode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") + "." + base64UrlEncode("eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ")

I get:

ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5.ZXlKemRXSWlPaUl4TWpNME5UWTNPRGt3SWl3aWJtRnRaU0k2SWtwdmFHNGdSRzlsSWl3aWFXRjBJam94TlRFMk1qTTVNREl5ZlE=

NOTE: there's some confusion on my part if I should be encoding the already encoded values, or use the already encoded values as-is.

(i.e. using base64UrlEncode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9") + "." + base64UrlEncode("eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ") vs "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ").

Regardless on which I should do, the end result still doesn't match the signature. I'm leaning towards that I should NOT re-encode the encoded value, whether that's true or not.

Then using a HMAC Generator calculator (https://codebeautify.org/hmac-generator or https://www.freeformatter.com/hmac-generator.html#ad-output)

(Not actual value on sites, modified to show what the tools would ultimately build for me)

HMACSHA256(
 "ZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5.ZXlKemRXSWlPaUl4TWpNME5UWTNPRGt3SWl3aWJtRnRaU0k2SWtwdmFHNGdSRzlsSWl3aWFXRjBJam94TlRFMk1qTTVNREl5ZlE=",
  "hONPMX3tHWIp9jwLDtoCUwFAtH0RwSK6"
)

Which gets me:

a2de322575675ba19ec272e83634755d4c3c2cd74e9e23c8e4c45e1683536e01

And that doesn't match the signature portion of the JWT:

wDQ2mU5n89f2HsHm1dluHGNebbXeNr748yJ9kUNDNCAM != a2de322575675ba19ec272e83634755d4c3c2cd74e9e23c8e4c45e1683536e01


Purpose:

The reason I'm needing to confirm this is to prove the ability to validate that the JWT hasn't been tampered with, without decoding the JWT.

My clients web interface doesn't need to decode the JWT, so there's no need for them to install a jwt package for doing that. They just need to do a simple validation to confirm the JWT hasn't been tampered with (however unlikely that may be) before they store the JWT for future API calls.

2
Maybe your problem is just that you encode an already encoded value : base64UrlEncode("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"). The string "eyJh..." is already the result of encoding {"alg":"HS256","typ":"JWT"}, so to encode it again makes no sense.jps
@jps that's a good point, and one I verified didn't change anything if I encode it again or not, either way the signature is incorrect. Reading up on instructions makes it sound like I need to base64UrlEncode the value already in the JWT, ultimately encoding it twice... though I'm starting to lean towards not needing to encode it the 2nd time. I'll add this note in the question for clarification, thank you.skplunkerin
It's always only one time encoding. In base64UrlEncode(header) + "." + base64UrlEncode(payload) payload and header refer to the readable JSON.jps
I'll agree with you there @jps. I wish this fixed the issue, I added a note about this in the question.skplunkerin
I just tried the online HMAC tools (which I didn't know before) and also had a different result than I got on jwt.io. I'm also surprised and right now can't figure out what's wrong.jps

2 Answers

12
votes

It's all a matter of formats and encoding.

On https://jwt.io you get this token based on your input values and secret:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.3pIaKksiX9Zv8Jg-hWbrD24VhL36hBIFaNpA4fVx29M

We want to prove that the signature:

3pIaKksiX9Zv8Jg-hWbrD24VhL36hBIFaNpA4fVx29M

is correct.

The signature is a HMAC-SHA256 hash that is Base64url encoded. (as described in RFC7515)

When you use the online HMAC generator to calculate a hash for

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ

with the secret

hONPMX3tHWIp9jwLDtoCUwFAtH0RwSK6

you get

de921a2a4b225fd66ff0983e8566eb0f6e1584bdfa84120568da40e1f571dbd3

as result, which is a HMAC-SHA256 value, but not Base64url encoded. This hash is a hexadecimal string representation of a large number.

To compare it with the value from https://jwt.io you need to convert the value from it's hexadecimal string representation back to a number and Base64url encode it.

The following script is doing that and also uses crypto-js to calculate it's own hash. This can also be a way for you to verify without JWT libraries.

var CryptoJS = require("crypto-js");

// the input values
var base64Header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9";
var base64Payload = "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ";
var secret = "hONPMX3tHWIp9jwLDtoCUwFAtH0RwSK6";

// two hashes from different online tools
var signatureJWTIO = "3pIaKksiX9Zv8Jg-hWbrD24VhL36hBIFaNpA4fVx29M";
var onlineCaluclatedHS256 =  "de921a2a4b225fd66ff0983e8566eb0f6e1584bdfa84120568da40e1f571dbd3";

// hash calculation with Crypto-JS. 
// The two replace expressions convert Base64 to Base64url format by replacing 
// '+' with '-', '/' with '_' and stripping the '=' padding
var base64Signature = CryptoJS.HmacSHA256(base64Header + "." + base64Payload , secret).toString(CryptoJS.enc.Base64).replace(/\+/g,'-').replace(/\//g,'_').replace(/\=+$/m,'');

// converting the online calculated value to Base64 representation
var base64hash = new Buffer.from(onlineCaluclatedHS256, 'hex').toString('base64').replace(/\//g,'_').replace(/\+/g,'-').replace(/\=+$/m,'')


// the results:
console.log("Signature from JWT.IO             : " + signatureJWTIO);
console.log("NodeJS calculated hash            : " + base64Signature);
console.log("online calulated hash (converted) : " + base64hash);

The results are:

Signature from JWT.IO             : 3pIaKksiX9Zv8Jg-hWbrD24VhL36hBIFaNpA4fVx29M

NodeJS calculated hash            : 3pIaKksiX9Zv8Jg-hWbrD24VhL36hBIFaNpA4fVx29M

online calulated hash (converted) : 3pIaKksiX9Zv8Jg-hWbrD24VhL36hBIFaNpA4fVx29M

identical!

Conclusion:

The values calculated by the different online tools are all correct but not directly comparable due to different formats and encodings. A little script as shown above might be a better solution.

2
votes

I had the same problem until I figured out that I was using plain base64 encoding instead of base64url. There are also some minor details in between. Here is the step-by-step manual that will, hopefully, make the whole process much more clear.

Notes

Note 1: You must remove all spaces and newlines from your JSON strings (header and payload). It is implicitly done on jwt.io when you generate a JWT token.

Note 2: To convert JSON string to base64url string on cryptii.com create the following configuration:

First view: Text

Second view: Encode
    Encoding: Base64
    Variant: Standard 'base64url' (RFC 4648 §5)

Third view: Text

Note 3: To convert HMAC HEX code (signature) to base64url string on cryptii.com create the following configuration:

First view: Bytes
    Format: Hexadecimal
    Group by: None

Second view: Encode
    Encoding: Base64
    Variant: Standard 'base64url' (RFC 4648 §5)

Third view: Text

Manual

You are going to need only two online tools:

  1. [Tool 1]: cryptii.com - for base64url encoding,
  2. [Tool 2]: codebeautify.org - for HMAC calculation.

On cryptii.com you can do both base64url encoding/decoding and also HMAC calculation, but for HMAC you need to provide a HEX key which is different from the input on jwt.io, so I used a separate service for HMAC calculation.

Input data

In this manual I used the following data:

  • Header:

    {"alg":"HS256","typ":"JWT"}
    
  • Payload:

    {"sub":"1234567890","name":"John Doe","iat":1516239022}
    
  • Secret (key):

    The Earth is flat!
    

The secret is not base64 encoded.

Step 1: Convert header [Tool 1]

  • Header (plain text):

    {"alg":"HS256","typ":"JWT"}
    
  • Header (base64url encoded):

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    

Step 2: Convert payload [Tool 1]

  • Payload (plain text):

    {"sub":"1234567890","name":"John Doe","iat":1516239022}
    
  • Payload (base64url encoded):

    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
    

Step 3: Calculate HMAC code (signature) [Tool 2]

Calculate HMAC using SHA256 algorithm.

  • Input string (base64url encoded header and payload, concatenated with a dot):

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
    
  • Calculated code (HEX number):

    c8a9ae59f3d64564364a864d22490cc666c74c66a3822be04a9a9287a707b352
    

The calculated HMAC code is a HEX representation of the signature. Note: it should not be encoded to base64url as a plain text string but as a sequence of bytes.

Step 4: Encode calculated HMAC code to base64url [Tool 1]:

  • Signature (Bytes):

    c8a9ae59f3d64564364a864d22490cc666c74c66a3822be04a9a9287a707b352
    
  • Signature (base64url encoded):

    yKmuWfPWRWQ2SoZNIkkMxmbHTGajgivgSpqSh6cHs1I
    

Summary

Here are our results (all base64url encoded):

  • Header:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
    
  • Payload:

    eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
    
  • Signature:

    yKmuWfPWRWQ2SoZNIkkMxmbHTGajgivgSpqSh6cHs1I
    

The results from jwt.io:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.yKmuWfPWRWQ2SoZNIkkMxmbHTGajgivgSpqSh6cHs1I

As you can see, all three parts are identical.