The other day, Arnav Gupta posted on Twitter regarding the difference between jwt and session based authentication. Having used JWT extensively in my current org, along with measures to prevent csrf and xss, this seemed like a great opportunity to delve deeper into these topics. Please note that this is not an introductory post and I will assume a basic understanding of jwt authentication on the reader’s part. Rest assured, I will also link several resources if you wish to read further.

JWT

JWT (JSON Web Token) based authentication is a popular method for securing web applications and APIs. Here’s a step-by-step explanation of the same:

  1. User Authentication: When a user logs in or is authenticated on a system, the server generates a JWT containing information about the user’s identity and any additional claims (such as permissions or roles).
  2. JWT Creation: The server creates a JWT by signing a JSON object with a secret key. This JSON object typically contains the following parts:
    • Header: Contains information about the type of token and the signing algorithm used. It is base64-encoded and specifies that the token is a JWT.
    • Payload: Contains claims (statements) about the user, such as the user’s ID, username, and any other relevant information. These claims are also base64-encoded.
    • Signature: Created by taking the encoded header and payload, along with the secret key, and running them through the chosen signing algorithm (e.g., HMAC SHA256). The signature is used to verify that the token was not tampered with.
  3. Token Issuance: After creating the JWT, the server sends it back to the client as part of the authentication response. The client typically stores this token locally (e.g., in a cookie or local storage) for later use.
  4. Token Transmission: The client includes the JWT in the headers of each subsequent HTTP request to the server. This is typically done using the Authorization header, like this: Authorization: Bearer <JWT>.
  5. Token Verification:
    • When the server receives an authenticated request with a JWT, it first decodes the token. This involves splitting it into its three parts (header, payload, and signature) and base64-decoding them. jwt.io is an excellent resource to visualize this step.
    • Next, the server checks the signature by re-signing the header and payload with the secret key and comparing the result to the signature provided in the token. If they match, the token is considered valid and has not been tampered with.
    • The server also checks the token’s expiration time (if an expiration claim is present) and any other relevant claims to ensure the user has the necessary permissions for the requested action.
  6. Access Control: Once the server verifies the JWT’s authenticity and checks the user’s claims, it can grant access to protected resources or perform actions on behalf of the user.
  7. Token Refresh (Optional): JWTs can have a relatively short expiration time to enhance security. When a token expires, the client can request a new one using a refresh token if this mechanism is in place.

Local Storage

There are multiple aspects of client storage but for the purpose this post, I specifically want to look at access scope . Data stored in Local Storage is only accessible to JavaScript code running on the same domain from which it was stored. This provides a form of data isolation and security, as different websites cannot access each other’s Local Storage data due to the same-origin policy. Also, it is clear that this leaves us vulnerable to cross-site scripting (XSS) attacks if PUT/POST requests are not properly sanitized and validated.

Cookies

access scope: Cookies can be configured to have different access scopes, including domain-wide access or access restricted to a specific path on a domain. They can also be set to be accessible only over secure connections (HTTPS) or HttpOnly (prevents client-side scripts from accessing data).

automatic transmission: Cookies are automatically included in HTTP requests to the same domain that set them. This allows websites to identify returning users and maintain session state. You can also look to SameSite attribute which specifies whether/when cookies are sent with cross-site requests.

Store JWTs in local storage or cookies

The biggest problem with using local storage is that it is vulnerable to xss attacks. The hacker can store unwanted scripts in your database (for eg as comments), which will be executed once another user loads the page containing these comments. So, if you are going to use local storage to store JWTs, you must follow best practices against XSS including escaping contents.

Setting SameSite attribute as Strict in cookies will provide a level of defense against csrf but they are still vulnerable to same-site attacks. Eg: If you run a system that displays untrusted user content, like a forum, you don’t want requests originating from user posts to be treated as valid. If someone posts a link to http://myforum.com/?action=delete_everything you don’t want that to delete anything just because a user clicks it. If you are using csrf tokens, such requests will not contain the token in the header and thus, preventing such attacks. Although still possible, but it requires additional xss on the part of the hacker to add the custom header.

⚡ If you have an XSS vulnerability, no CSRF protection in this world will save you.

Stateful Approach of dealing with CSRF

CSRF tokens should be generated on the server-side. They can be generated once per user session or for each request. Per-request tokens are more secure than per-session tokens as the time range for an attacker to exploit the stolen tokens is minimal. In per-session token implementations after the initial generation of a token, the value is stored in the session and is used for each subsequent request until the session expires.

When a request is issued by the client, the server-side component must verify the existence and validity of the token in the request compared to the token found in the user session. If the token was not found within the request, or the value provided does not match the value within the user session, then the request should be rejected. The CSRF token can be transmitted to the client as part of a response payload, such as a HTML or JSON response (and not using cookies). It can then be transmitted back to the server as a hidden field on a form submission, or via an AJAX request as a custom header value or part of a JSON payload.

Inserting the CSRF token in a custom HTTP request header via JavaScript is considered more secure than adding the token in the hidden field form parameter because requests with custom headers are automatically subject to the same-origin policy.

Double Submit Cookie approach of dealing with CSRF

If we are going to use cookies, one additional dimension we need to protect against is CSRF. We can either create anti-csrf token or use the existing jwt token as a defense against CSRF. The jwt token is stored in the cookie and while sending a AJAX request, the same is passed under Authorization header to the server. The server verifies that the token in Authorization header is the same as the one present in cookie. In a nutshell, an attacker is unable to access the cookie value during a cross-site request. It is important to highlight that the cookie might be sent depending on SameSite configuration but the information stored is inaccessible. This prevents them from including a matching value in the hidden form value or as a request parameter/header (doesn’t work if backend and frontend are on different domains).

⚡ Same-origin policy: Only JS can be used to add custom headers and only within its origin (unless specified otherwise in preflight requests), Local storage and cookies only accessible to the site that set it.

But, what if the front end and back ends are on different domains, then the front end will not be able to read the cookie set by the backend (essentially a cross-site request). In this case, the backend should set CORS headers (Access-Control-Allow-Origin and Access-Control-Allow-Credentials=true) in the response to preflight requests to ensure that the request can only be sent by authorized websites.

Best Approach in my opinion

Once the user has logged into the system and the server sends back a jwt token for later use, the front end should store the token as a part of the cookie.

If the frontend and backend are on the same domain, setting SameSite attribute of cookie to Strict will prevent the cookie being sent in case of cross-site request (unless the url in the browser explicitly changes). If the browser doesn’t support SameSite attribute, we can use Double Submit Cookie approach of dealing with CSRFs.

In case the frontend and backend are on different domains, then the server must set appropriate access-control headers in the response to preflight requests.

Further reading: Link 1, Link 2, Link 3, Link 4