Skip to main content

Manually verify the JWT

There are three steps in doing session verification using JWTs:

  • Verify the JWT signature and expiry using a JWT verification library
  • Check for custom claim values for authorization.
  • Preventing CSRF attacks in case you are using cookies to store the JWT.

Verifying a JWT using a jwt verification library#

Method 1) Using JWKS endpoint (recommended)#

Some libraries let you provide a JWKS endpoint to verify a JWT. The JWKS endpoint exposed by SuperTokens is available at the following URL:

curl --location --request GET '<YOUR_API_DOMAIN>/auth/jwt/jwks.json'

Below is an example for NodeJS showing how you can use jsonwebtoken and jwks-rsa together to achieve JWT verification using the jwks.json endpoint.

import JsonWebToken, { JwtHeader, SigningKeyCallback } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

var client = jwksClient({
jwksUri: '<YOUR_API_DOMAIN>/auth/jwt/jwks.json'
});

function getKey(header: JwtHeader, callback: SigningKeyCallback) {
client.getSigningKey(header.kid, function (err, key) {
var signingKey = key!.getPublicKey();
callback(err, signingKey);
});
}

let jwt = "..."; // fetch the JWT from sAccessToken cookie or Authorization Bearer header
JsonWebToken.verify(jwt, getKey, {}, function (err, decoded) {
let decodedJWT = decoded;
// Use JWT
});

Method 2) Using public key string#

caution

This method is less secure compared to Method 1 because it disables key rotation of the access token signing key. In this case, if the private key is stolen somehow, it can be used indinitely to forge access tokens (Unless you manually change the key in the database).

Some JWT verification libraries require you to provide the JWT secret / public key for verification. You can obtain the JWT secret from SuperTokens in the following way:

  • First, we query the JWKS.json endpoint:

    curl --location --request GET '<YOUR_API_DOMAIN>/auth/jwt/jwks.json'

    {
    "keys": [
    {
    "kty": "RSA",
    "kid": "s-2de612a5-a5ba-413e-9216-4c43e2e78c86",
    "n": "AMZruthvYz7Ft-Dp0BC_SEEJaWK91s_YA-RR81iLJ6BTT6gJp0CcV4DfBynFU_59dRGOZyVQpAW6Drnc_6LyZpVWHROzqt-Fjh8TAqodayhPJVuZt25eQiYrqcaK_dnuHrm8qwUq-hko6q1o1o9NIIZWNfUBEVWmNhyAJFk5bi3pLwtKPYrUQzVLcTdDUe4SIltvvfpYHbVFnYtxkBVmqO68j7sI8ktmTXM_heals-W6WmozabDkC9_ITCeRat2f7A2l0t4QzO0ZCzZcJfhusF4X1niKgY6yYXpbX6is4HCfhYfdabcE52xYMNl-gw9XDjsIxfBMUDvOFRHWlx0rU8c=",
    "e": "AQAB",
    "alg": "RS256",
    "use": "sig"
    },
    {
    "kty": "RSA",
    "kid": "d-230...802340",
    "n": "AMZruthvYz7...lx0rU8c=",
    "e": "...",
    "alg": "RS256",
    "use": "sig"
    }
    ]
    }
    important

    The above shows an example output which returns two keys. There could be more keys returned based on the configured key rotation setting in the core. If you notice, each key's kid starts with a s-.. or a d-... The s-.. key is a static key that will never change, whereas d-... keys are dynamic keys that keep changing. So if you are hardcoding public keys somewhere, you always want to pick the s-.. key.

    One exception is that if you see a key with kid that doesn't start with s- or with d-, then you should treat that as a static key (This will only happen if you used to run an older SuperTokens core that was lesser than version 5.0).

  • Next, we run the NodeJS script below to convert the above output to a PEM file format.

    import jwkToPem from 'jwk-to-pem';

    // This JWK is copied from the result of the above SuperTokens core request
    let jwk = {
    "kty": "RSA",
    "kid": "s-2de612a5-a5ba-413e-9216-4c43e2e78c86",
    "n": "AMZruthvYz7Ft-Dp0BC_SEEJaWK91s_YA-RR81iLJ6BTT6gJp0CcV4DfBynFU_59dRGOZyVQpAW6Drnc_6LyZpVWHROzqt-Fjh8TAqodayhPJVuZt25eQiYrqcaK_dnuHrm8qwUq-hko6q1o1o9NIIZWNfUBEVWmNhyAJFk5bi3pLwtKPYrUQzVLcTdDUe4SIltvvfpYHbVFnYtxkBVmqO68j7sI8ktmTXM_heals-W6WmozabDkC9_ITCeRat2f7A2l0t4QzO0ZCzZcJfhusF4X1niKgY6yYXpbX6is4HCfhYfdabcE52xYMNl-gw9XDjsIxfBMUDvOFRHWlx0rU8c=",
    "e": "AQAB",
    "alg": "RS256",
    "use": "sig"
    };

    let certString = jwkToPem(jwk);

    The above snippet would generate the following certificate string:

    -----BEGIN PUBLIC KEY-----
    MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxmu62G9jPsW34OnQEL9I
    QQlpYr3Wz9gD5FHzWIsnoFNPqAmnQJxXgN8HKcVT/n11EY5nJVCkBboOudz/ovJm
    ... (truncated for display)
    XhfWeIqBjrJheltfqKzgcJ+Fh91ptwTnbFgw2X6DD1cOOwjF8ExQO84VEdaXHStT
    xwIDAQAB
    -----END PUBLIC KEY-----
  • Now you can use the generated PEM string in your code like shown below:

    import JsonWebToken from 'jsonwebtoken';

    // Truncated for display
    let certificate = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----";
    let jwt = "..."; // fetch the JWT from sAccessToken cookie or Authorization Bearer header
    JsonWebToken.verify(jwt, certificate, function (err, decoded) {
    let decodedJWT = decoded;
    // Use JWT
    });
  • The final step is to tell SuperTokens to always only use the static key when creating a new session. This can be done by setting the below config in the backend SDK:

    import SuperTokens from "supertokens-node";
    import Session from "supertokens-node/recipe/session";

    SuperTokens.init({
    supertokens: {
    connectionURI: "...",
    },
    appInfo: {
    apiDomain: "...",
    appName: "...",
    websiteDomain: "..."
    },
    recipeList: [
    Session.init({
    useDynamicAccessTokenSigningKey: false,
    })
    ]
    });
caution

Updating this value will cause a spike in the session refresh API, as and when users visit your application.

Check for custom claim values for authorization#

Once you have verified the access token, you can fetch the payload and do authorization checks based on the values of the custom claims. For examlpe, if you want to do check for if the user's email is verified, you should check the st-ev claim in the payload as shown below:

import JsonWebToken, { JwtHeader, SigningKeyCallback } from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

var client = jwksClient({
jwksUri: '<YOUR_API_DOMAIN>/auth/jwt/jwks.json'
});

function getKey(header: JwtHeader, callback: SigningKeyCallback) {
client.getSigningKey(header.kid, function (err, key) {
var signingKey = key!.getPublicKey();
callback(err, signingKey);
});
}

let jwt = "..."; // fetch the JWT from sAccessToken cookie or Authorization Bearer header
JsonWebToken.verify(jwt, getKey, {}, function (err, decoded) {
if (err) {
// send a 401 to the frontend..
}
if (decoded !== undefined && typeof decoded !== "string") {
let isEmailVerified = (decoded as any)["st-ev"].v
if (!isEmailVerified) {
// send a 403 to the frontend..
}
}
});

Claims like email verification and user roles claims are added to the access token by our backend SDK automatically. You can even add your own custom claims to the access token payload and those claims will be in the JWT as expected.

important

On claim validation failure, you must send a 403 to the frontend which will cause our frontend SDK (pre built UI SDK) to recheck the claims added on the frontend and navigate to the right screen.

Check for anti-csrf during authorization#

important

You will need to check for anti-csrf for NON GET requests when cookie based authentication is enabled.

There are two methods for configuring CSRF protection:

  • VIA_CUSTOM_HEADER
  • VIA_TOKEN

Checking for anti-csrf when VIA_CUSTOM_HEADER is set#

VIA_CUSTOM_HEADER is automatically set if sameSite is none or if your apiDomain and websiteDomain do not share the same top level domain. In this case you will need to check for the presence of the rid header from incoming requests.

Checking for anti-csrf when VIA_TOKEN is set#

When configured with VIA_TOKEN, an explicit anti-csrf token will be attached as a header to requests with anti-csrf as the key. To verify the anti-csrf token you will need to compare it the to value of the antiCsrfToken key from the payload of the decoded JWT.