JWT - How website authentication works

Arematics
Last Change Feb 20, 2024
JWT - How website authentication works Image

JSON Web Token (JWT) has become a cornerstone in securing web applications, offering a compact and self-contained way for securely transmitting information between parties as a JSON object. This mechanism allows for information to be verified and trusted because it is digitally signed. JWTs can be signed using a secret (with the HMAC algorithm) or a public/private key pair using RSA or ECDSA.

This blog post delves into the nuts and bolts of JWT, tracing its history, highlighting its advantages and disadvantages, discussing security improvements and a usage example with Spring and Angular. Whether you're a developer, a security enthusiast, or simply curious, this exploration aims to equip you with a balanced understanding of JWT and its place in the web security landscape.

 

1. Understanding JWT: The Basics

JWT, or JSON Web Token, is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity-protected with a Message Authentication Code (MAC) and/or encrypted. JWTs consist of three parts: a header, a payload, and a signature.

(Image is from JWT IO Page)

  1. Header: The header typically consists of two parts: the type of the token (JWT) and the signing algorithm being used, such as HMAC SHA256 or RSA.
  2. Payload: The payload contains claims, which are statements about an user, for example that can include the username, user id, mail address and mostly also the roles the users owns for specific resources. These claims can be categorized as registered, public, or private claims.
  3. Signature: To create the signature part, the encoded header, encoded payload, a secret, and the algorithm specified in the header are combined. This signature is used to verify that the sender of the JWT is who it says it is and to ensure that the message wasn't changed along the way.

The payload contains some claims that are reserved so it can be ensured that a JWT token always uses these fields for there specific case. There we have first of all the sub(subject) claim which holds the identifier for the user the JWT token is created for. Mostly this is a uuid. Another important reserved claim worth to mention here is the jti(JWT ID) claim. It is providing a unique identifier for the JWT token so it can be assured that this JWT token can't be replicated.

 

2. How JWT Authentication Works

1. User Authentication

When a user logs into a website, the server validates their credentials. Once validated, the server generates a JWT containing user information (claims) and signs it using a secret key. This JWT is then sent back to the client.

 2. Token Storage

The client typically stores the JWT, often in a secure manner such as in an HTTP-only cookie. This token will be sent with each subsequent request to the server, allowing the server to identify and authorize the user.

 3. Request Authorization

When a user makes a request to a protected resource on the server, the JWT is included in the request headers. The server then verifies the JWT's signature using the secret key. If the signature is valid, the server extracts the claims from the payload.

 4. Token Expiry and Refresh

JWTs often have a limited lifetime, such as 5 to 10 minutes, to increase security. When a token expires, the user must re-authenticate. To avoid repeated logins, a refresh token can be used, which can be retrieved when the user first logs in. When the access token expires, the client can use the refresh token to request a new one.

 

3. Advantages

JWT offers several advantages that make it an attractive choice for managing authentication and information exchange in web applications:

 1. Stateless Nature

One significant advantage of JWTs is their stateless nature. Traditional session-based authentication systems store user sessions on the server, introducing scalability challenges. In contrast, JWTs carry all the necessary information within the token itself, allowing for easy scalability.

2. Performance

Since JWTs can be easily transmitted and processed, they can contribute to better performance compared to traditional session-based authentication, where the server needs to look up session information for each request.

3. Cross-Domain Authentication

Because of their simplicity and compact nature, JWTs can easily be sent over cross-domain requests. This is particularly useful in microservices architectures and when dealing with Single Page Applications (SPAs) that may communicate with multiple backend services.

4. Decentralization of Authentication

JWTs enable decentralised authentication, meaning that the authentication process can be performed independently on different services without the need for a centralised authentication server. This is particularly useful in microservices architectures.

5. Standard

JWTs follow a standardized format defined by the RFC 7519 specification. This standardization promotes interoperability between different systems and allows developers to choose from a variety of libraries and tools that support JWT.

 

4. Disadvantages

Now we have a overview about the advantages of JWT tokens but like any technology, JWTs have some disadvantages. Lets have a look about some of these disadvantages, that are not part of the standard for JWT but some disadvantages are taken into account by authorization servers providing JWT authentications.

1.Size and Overhead

JWTs can be larger than other token formats, especially when containing a significant amount of claims or information. This can increase the size of HTTP headers and potentially impact performance, especially in scenarios where bandwidth is a concern.

2. Statelessness

While statelessness is also in the advantages, it can also be a disadvantage in certain scenarios. Since JWTs store all necessary information within the token itself, if any changes need to be made to the user's permissions or data, the changes may not take effect until the token expires. This contrasts with traditional session-based authentication systems, where changes are immediately reflected.

3. Security Risks with Storage

Since the information in a JWT is encoded, not encrypted, anyone with access to the token can read its contents. While the token should not contain sensitive information, there might be scenarios where the information stored in the token is not adequately protected. If sensitive data is required, encryption should be applied to the token.

4. Limited Token Revocation

Once a JWT is issued, it is challenging to revoke or invalidate it before it expires. If a user's permissions change or their account is compromised, the existing JWTs will still be valid until their expiration. This issue can be mitigated by short-lived tokens and proper handling of token revocation.

5.No Built-in Mechanism for Token Expiry Notifications

There is no built-in mechanism in JWT for notifying the client or the server when a token is about to expire. Refresh tokens or external mechanisms are typically used to handle token renewal.

 

5. Enhancing JWT Security

1. Use HTTPS

Simple, but very important. Not just for JWT, but for all connections on your website, it's a good idea to ensure that data is transmitted securely. This means that the website should use HTTPS all the way to the final resource. If data is sent over an insecure channel, it can be exposed to various attacks. Without HTTPS, authentication in particular is a dangerous thing, as attackers can read the data transfer between the user and your applications.

2. Keep Secrets Secure

The secret key used to sign JWTs must be kept confidential. If an attacker gains access to the key, they can create fake tokens and impersonate users. Do not store secrets in your application code or in configurations that might be anywere else then your authorization server.

3. Token Validation

Accepting the data in a token on the resource that should validate access is not a good pratice, the JWT token could be corrupted. Therefor always validate incoming tokens on the server side. Check the token's signature, issuer, expiration time, and any other relevant claims to be shure the token is provided from your authorization server and hasn't been modified in between.

 

6. Coding example

(Please do not use this example for production)

To get the full point let us now build a small application infrastructur to see how it actually works. For that we need to use three different applications. Be sure you have a IDEA installed, some knowledge about Gradle and NodeJS installed on your machine. First of all we need a issuer that can grant JWT tokens.

JWT Issuer System

We use the Spring Authorization Server to setup a small authenticator application. You can find a tutorial for the first steps here. We like to use a basic Spring Boot application so only the first step "Installing Spring Authorization Server" and "Developing Your First Application" are important for this example.

Make sure that the application port "9000" is free, else if this port is already used on your system, replace the port in the Spring "application.yml" provided by the Spring Tutorial under the path "server.port".

Also we need to configure the authorization application to support a public client login with our frontend application. To make that possible we first of all need a configuration that adjusts the handling, enables cors configuration for our angular application and a default user setup:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
import org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http)
            throws Exception {
        OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
        http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
                .oidc(Customizer.withDefaults());
        http.exceptionHandling((exceptions) -> exceptions
                        .defaultAuthenticationEntryPointFor(
                                new LoginUrlAuthenticationEntryPoint("/login"),
                                new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
                        )
                )
                .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        return http.cors(Customizer.withDefaults()).build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
                .formLogin(Customizer.withDefaults());
        return http.cors(Customizer.withDefaults()).build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.addAllowedOrigin("http://localhost:4200");
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }


    @Bean
    UserDetailsService users() {
        PasswordEncoder encoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
        UserDetails user = User.builder()
                .username("admin")
                .password("password")
                .passwordEncoder(encoder::encode)
                .roles("USER")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

 

also it is required to extend the application.yml with a public client that we can use in our frontend so our full application.yml should look like this:

server:
  port: 9000

logging:
  level:
    org.springframework.security: trace

spring:
  security:
    oauth2:
      authorizationserver:
        client:
          public-client:
            registration:
              client-id: "public-client"
              client-authentication-methods:
                - "none"
              authorization-grant-types:
                - "authorization_code"
              redirect-uris:
                - "http://localhost:4200"
              scopes:
                - "openid"
                - "profile"
            require-authorization-consent: true
            require-proof-key: true
          oidc-client:
            registration:
              client-id: "oidc-client"
              client-secret: "{noop}secret"
              client-authentication-methods:
                - "client_secret_basic"
              authorization-grant-types:
                - "authorization_code"
                - "refresh_token"
              redirect-uris:
                - "http://127.0.0.1:8080/login/oauth2/code/oidc-client"
              post-logout-redirect-uris:
                - "http://127.0.0.1:8080/"
              scopes:
                - "openid"
                - "profile"
            require-authorization-consent: true

 

Run the main class of the application and now we have our Authorization System up.

 

Backend Application

The next step that is required for us is having a application that checks the access rights and will provide us based on that with data. These applications are known as "backend" applications that store and process data.

1. Create a Spring Boot Application

First, let's create a new Spring Boot application with the Spring Initializr. There you need to select the dependencies "Spring Web", "Spring Security" and "OAuth2 Resource Server" so the selection at the end looks like the example below. Then click on Generate and it will download the initial project setup. Important is that you use spring version 3.

2. Configure Application Properties

Now it is required to configure the application to connect with out Spring Authorization Server to retrieve informations and check incoming requests with there JWT token to verify that token. Therefor we need to edit the application.yml file which could be found under src/main/resources and specify the authentication server details:

server:
  port: 9001
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000

3. Setup Security

Next we need to configure the security settings to enforce authentication for accessing the application endpoints. Create a class named SecurityConfig and add the security filter chain to configure JWT validation and Cors Polices so our frontend application can access this backend service:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.Arrays;
import java.util.List;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true)
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.configurationSource(corsConfigurationSource())).csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(r -> r.requestMatchers("/**").permitAll())
                        .oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    CorsConfigurationSource corsConfigurationSource() {
        org.springframework.web.cors.CorsConfiguration configuration = new org.springframework.web.cors.CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:4200"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "OPTIONS", "DELETE", "PUT", "PATCH"));
        configuration.setAllowedHeaders(Arrays.asList("X-Requested-With", "Origin", "Content-Type", "Accept", "Authorization"));
        configuration.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

4. Create the data endpoint

The last step we need to do for our backend service is creating the endpoint the user then can fetch. Its is required to have a REST endpoint for that which when then later on can request over HTTP with our angular application. It should return a simple entry showing some informations and it can only be accessed when the user is logged in.

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
public class DataController {

    @PreAuthorize(value = "isAuthenticated()")
    @GetMapping("/data")
    public Map<String, String> fetchData() {
        Map<String, String> map = new HashMap<>();
        map.put("secret", "this is super secret");
        return map;
    }
}

5. Run the application

Now the backend is finished and we can run the application


Frontend

The last application we need is our website that will get the JWT token and then will request the data from the backend application where we like to get our data from. For that we will use Angular in this example.

Create a new Angular project with IntelliJ "Angular CLI" wizard or with ng new, its only important that it is a standalone project what is default in the newest major version of angular (17). If you know how to do the shown code with angular modules you can also use for example angular 16.

When you have created your project we first of all need to add a library to support OAuth2. Therefor we use npm -i --save angular-oauth2-oidc to install that library. It contains everything we need.

The first step is that we need to adjust our app.config.ts to load the OAuth2 library components such as the OAuthService with:

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes),
    importProvidersFrom(HttpClientModule),
    provideOAuthClient({
      resourceServer: {
        allowedUrls: ['http://localhost:9001'],
        sendAccessToken: true
      }
    })
  ]
};

 

Now we adjust the app.component.html and just remove everything initially created with:

<h1>Welcome to the application</h1>
<p>{{message || 'Empty'}}</p>


also we need to adjust our app.component.ts to configure to authorization server and do a login, after the login we like to adjust the message with our secret message that is behind the endpoint of our backend service:

import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import {NullValidationHandler, OAuthService} from "angular-oauth2-oidc";
import {HttpClient} from "@angular/common/http";

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet],
  templateUrl: './app.component.html',
  styleUrl: './app.component.css'
})
export class AppComponent {
  title = 'frontend';
  message: string | undefined;

  constructor(private oauthService: OAuthService, private http: HttpClient) {
    this.oauthService.configure({
      issuer: 'http://localhost:9000',
      redirectUri: 'http://localhost:4200',
      responseType: 'code',
      disableAtHashCheck: true,
      scope: 'openid profile',
      useSilentRefresh: false,
      showDebugInformation: true,
      clientId: 'public-client',
      requireHttps: false
    })
    this.oauthService.tokenValidationHandler = new NullValidationHandler();
    this.oauthService.loadDiscoveryDocumentAndLogin().then(r => {
      this.http.get('http://localhost:9001/data', {responseType: 'json'}).subscribe((res: any) => {
        this.message = res.secret
      })
    });
  }
}

and thats it already.

When you now start the frontend application and open your browser on http://localhost:4200 your application should be redirecting you directly to the authorization server at port 9000 and want you to login. Enter the basic login informations we added to the authorization server with "admin" and "password" as credentials and accept the request to log you in. After that you will be redirected to the frontend application and should be apply to see the secret message: "this is super secret".

Congratulations! You just finished implementing your first basic example of state of the art authorization for web applications.
If you had any problems with setting up this project you can find the whole source code at: https://github.com/Arematics/blog-examples/tree/master/jwt-starter

Conclusion

JSON Web Tokens offer a flexible and efficient means for secure communication in web applications, balancing performance and scalability with the need for security. While they come with their set of challenges, especially in terms of security, adopting best practices and leveraging solutions can mitigate these risks, making JWT a viable option for modern web security. As with any technology, the key lies in understanding its capabilities and limitations, ensuring that its implementation enhances the security posture of the application without introducing undue complexity or vulnerabilities.



Would you like to use a different language for the content in the future?

English

Copyright © 2024 Arematics. All rights reserved