Understanding JWT Tokens: Structure, Security, and Implementation
The Problem with Traditional Tokens
When a client obtains a token from an authentication server and then uses that token to access protected resources, the resource server must verify the token's validity.
The verification flow typically works as follows:
- The client presents the token when requesting resources
- The resource server makes a remote call to the authentication service to verify the token
- If valid, the resource server returns the requested data
This approach has a significant drawback: every token verification requires a network call to the authentication service, which impacts performance. A better solution is to let the resource server verify tokens locally without remote calls.
JWT (JSON Web Token) addresses this issue. When users authenticate successfully, they receive a JWT containing all necessary user information. Clients can then access resource servers directly with this token, and the resource server validates it using a predefined algorithm—no authentication service call required.
What is JWT
JSON Web Token is an open standard (RFC 7519) that defines a compact, self-contained format for transmitting JSON objects between parties. Information within JWTs is digitally signed, allowing recipients to verify authenticity and detect tampering. JWTs can use HMAC algorithms or RSA public/private key pairs for signature verification. The official specification is available at jwt.io.
JWT enables stateless authentication. Traditional session-based approaches store user identity information on the server, creating storage overhead and poor scalability in distributed systems. Session replication or sticky sessions become necessary to handle requests across multiple application servers.
Stateless authentication with JWTs solves these problems. The server doesn't need to store sessions—user identity information lives within the token itself. After successful authentication, the user receives a token, stores it client-side, and includes it with each request. Resource servers extract user information directly from the JWT.
Advantages of JWT:
- JSON format makes parsing straightforward
- Customizable payload allows flexible content expansion
- Digital signatures using asymmetric encryption prevent tampering
- Resource servers can authorize requests independently without calling authentication services
Disadvantages:
- Tokens can be lengthy, consuming more storage space
JWT Structure
A JWT consists of three parts separated by dots: xxxxx.yyyyy.zzzzz
Header
The header specifies the token type (JWT) and the signing algorithm (such as HMAC SHA256 or RSA).
{
"alg": "HS256",
"typ": "JWT"
}
The header JSON is Base64Url encoded to produce the first segmant of the token.
Payload
The second segment contains the payload—a JSON object holding the claims. JWT defines standard claims including iss (issuer), exp (expiration time), and sub (subject). Custom claims can also be added.
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}
The payload is Base64Url encoded to form the second segment. Avoid storing sensitive information here since the payload is encoded but not encrypted.
Signature
The third segment prevents content tampering. It's created by Base64Url encoding the header and payload, concatenating them with a dot, then generating a signature using the algorithm specified in the header.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
The signature ensures that if an attacker modifies the token content, validation fails.
Why JWT Prevents Tampering
The signature is generated using a cryptographic algorithm that requires a secret key. If anyone changes the header or payload, the signature becomes invalid because verification requires the same content and key. This makes JWTs tamper-resistant.
Two encryption approaches exist:
Symmetric encryption uses a shared secret key between the authentication service and resource servers. It's fast but vulnerable if the key is compromised.
Asymmetric encryption keeps the private key only on the authantication service while distributing the public key to clients and resource services. Since public and private keys are paired, this approach is more secure despite slower performance.
Configuration Example
package com.example.auth.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import org.springframework.security.oauth2.provider.token.DefaultTokenServices;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.TokenStore;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtTokenStore;
import java.util.Arrays;
@Configuration
public class TokenConfiguration {
private static final String SECRET_KEY = "secret123";
@Autowired
TokenStore tokenStore;
@Autowired
private JwtAccessTokenConverter tokenConverter;
@Bean
public TokenStore tokenStore() {
return new JwtTokenStore(tokenConverter());
}
@Bean
public JwtAccessTokenConverter tokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey(SECRET_KEY);
return converter;
}
@Bean(name = "customTokenServices")
public AuthorizationServerTokenServices tokenService() {
DefaultTokenServices services = new DefaultTokenServices();
services.setSupportRefreshToken(true);
services.setTokenStore(tokenStore);
TokenEnhancerChain enhancerChain = new TokenEnhancerChain();
enhancerChain.setTokenEnhancers(Arrays.asList(tokenConverter));
services.setTokenEnhancer(enhancerChain);
services.setAccessTokenValiditySeconds(7200);
services.setRefreshTokenValiditySeconds(259200);
return services;
}
}
Sample Token Response
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 7198,
"scope": "all",
"jti": "dddfe91d-bbe2-4a08-be24-48fc457f0217"
}
Field explanations:
access_token: The JWT used for accessing protected resourcestoken_type: Bearer type per RFC 6750; include this before the token in the Authorization headerrefresh_token: Used to obtain a new access token before expirationexpires_in: Token lifetime in secondsscope: Permission scope granted to the tokenjti: Unique token identifier
Embedding Custom Claims
After validating user credentials, you can embed additional user information into the JWT by serializing it as a JSON string and using it as the username claim:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserAccount account = userRepository
.findByUsername(username);
if (account == null) {
return null;
}
String userPassword = account.getPassword();
String[] permissions = {"read", "write"};
account.setPassword(null);
String userJson = JSON.toJSONString(account);
return User.withUsername(userJson)
.password(userPassword)
.authorities(permissions)
.build();
}
This approach embeds complete user data within the token, avoiding the need for additional database calls when processing requests.