What is JWT?

JSON Web Token (JWT) is an open standard (RFC 7519) for securely transmitting information between parties as a JSON object. It's become the de facto standard for authentication in modern web applications, REST APIs, and microservices architectures.

Unlike traditional session-based authentication where the server stores session data, JWT enables stateless authentication. The token itself contains all the information needed to verify the user's identity, eliminating the need for server-side session storage.

Key Concept: A JWT is a self-contained token that carries all the information needed to authenticate a user. The server doesn't need to look up session data in a database—it simply validates the token's signature.

This makes JWT perfect for distributed systems, microservices, and mobile applications where maintaining server-side sessions becomes complex and inefficient.

JWT Structure: Three Parts, One Token

A JWT consists of three parts separated by dots (.):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6Ik9tYXIgRGF3b3VkIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
JWT Structure Diagram

The three components of a JSON Web Token

1. Header

The header typically consists of two parts: the token type (JWT) and the signing algorithm being used (e.g., HMAC SHA256 or RSA).

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

This JSON is then Base64Url encoded to form the first part of the JWT.

2. Payload

The payload contains the claims—statements about the user and additional metadata. There are three types of claims:

  • Registered claims: Predefined claims like iss (issuer), exp (expiration), sub (subject), aud (audience)
  • Public claims: Custom claims that should be defined in the IANA JWT Registry or use collision-resistant names
  • Private claims: Custom claims agreed upon between parties
{
  "sub": "1234567890",
  "name": "Omar Dawoud",
  "email": "omar@example.com",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622
}
Security Warning: The payload is only Base64Url encoded, NOT encrypted. Anyone can decode and read it. Never put sensitive information like passwords or credit card numbers in the payload.

3. Signature

The signature is what makes JWT secure. It's created by taking the encoded header, encoded payload, a secret key, and the algorithm specified in the header.

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

The signature ensures that the token hasn't been tampered with. If someone modifies the header or payload, the signature verification will fail.

How JWT Authentication Works

Understanding the complete authentication flow is crucial for implementing JWT correctly. Let's walk through the entire process step by step.

JWT Authentication Flow

Complete JWT authentication flow from login to protected resource access

1

User Login

The user sends their credentials (username and password) to the authentication endpoint, typically via a POST request to /api/auth/login.

2

Server Validates Credentials

The server checks the credentials against the database. If valid, it proceeds to generate a JWT. If invalid, it returns a 401 Unauthorized response.

3

JWT Generation

The server creates a JWT containing the user's information (user ID, roles, etc.) and signs it with a secret key. The token includes an expiration time for security.

4

Token Returned to Client

The server sends the JWT back to the client in the response body. The client receives and stores this token (typically in memory, localStorage, or a secure cookie).

5

Client Stores Token

The client application stores the JWT securely. For web apps, this might be in memory or localStorage. For mobile apps, secure storage mechanisms are used.

6

Subsequent Requests

For every subsequent request to protected endpoints, the client includes the JWT in the Authorization header: Authorization: Bearer <token>

7

Server Validates Token

The server extracts the JWT from the Authorization header, verifies the signature using the secret key, checks expiration, and validates claims.

8

Access Granted or Denied

If the token is valid, the server processes the request and returns the response. If invalid or expired, it returns a 401 Unauthorized response.

Example: Login Request

POST /api/auth/login
Content-Type: application/json

{
  "username": "omar",
  "password": "securePassword123"
}

Example: Login Response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expiresIn": 3600
}

Example: Protected Resource Request

GET /api/user/profile
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Signature Verification: The Security Core

The signature is what makes JWT trustworthy. Without proper signature verification, an attacker could forge tokens and impersonate any user. Let's understand exactly how signing and verification work.

JWT Signature Process

How JWT signatures are created and verified

Signing Process (Server Side)

When the server creates a JWT, it follows these steps:

  1. Create the header JSON and Base64Url encode it
  2. Create the payload JSON and Base64Url encode it
  3. Concatenate them with a dot: encodedHeader.encodedPayload
  4. Apply the cryptographic algorithm (e.g., HMAC-SHA256) using the secret key
  5. Base64Url encode the resulting signature
  6. Concatenate all three parts: encodedHeader.encodedPayload.signature
// Pseudocode for JWT signing
const header = base64UrlEncode(JSON.stringify({
  alg: "HS256",
  typ: "JWT"
}));

const payload = base64UrlEncode(JSON.stringify({
  sub: "1234567890",
  name: "Omar Dawoud",
  iat: 1516239022
}));

const signature = base64UrlEncode(
  HMACSHA256(header + "." + payload, SECRET_KEY)
);

const jwt = header + "." + payload + "." + signature;

Verification Process (Server Side)

When the server receives a JWT, it must verify it before trusting the claims:

  1. Split the token into three parts: header, payload, signature
  2. Decode the header to determine the algorithm
  3. Recreate the signature using the same algorithm and secret key
  4. Compare the recreated signature with the received signature
  5. If they match, the token is valid; if not, reject it
  6. Check expiration time (exp claim)
  7. Validate other claims as needed
// Pseudocode for JWT verification
const [header, payload, receivedSignature] = jwt.split(".");

// Recreate the signature
const expectedSignature = base64UrlEncode(
  HMACSHA256(header + "." + payload, SECRET_KEY)
);

// Compare signatures
if (expectedSignature !== receivedSignature) {
  throw new Error("Invalid signature");
}

// Decode and check expiration
const decodedPayload = JSON.parse(base64UrlDecode(payload));
if (decodedPayload.exp < Date.now() / 1000) {
  throw new Error("Token expired");
}

// Token is valid!
Why This Works: The signature is created using a secret key that only the server knows. An attacker can modify the header or payload, but without the secret key, they cannot create a valid signature. When the server recalculates the signature and it doesn't match, the tampering is detected.

Session-Based vs Token-Based Authentication

Understanding the difference between traditional session-based authentication and JWT token-based authentication is crucial for choosing the right approach for your application.

Session vs JWT Comparison

Comparing session-based and JWT token-based authentication

Session-Based Authentication

  • Server stores session data in memory or database
  • Session ID sent to client in cookie
  • Server looks up session on each request
  • Stateful - server must remember sessions
  • Easy to revoke sessions immediately
  • Requires sticky sessions in load balancing
  • Memory overhead grows with users

JWT Token-Based Authentication

  • No server-side session storage needed
  • JWT sent in Authorization header
  • Server only validates signature
  • Stateless - server doesn't store anything
  • Cannot revoke tokens before expiration*
  • Works seamlessly across multiple servers
  • Constant memory usage regardless of users

* Token revocation requires additional mechanisms like token blacklisting or refresh token rotation

When to Use JWT

  • Microservices: Multiple services need to validate authentication without sharing session storage
  • Mobile Apps: Stateless authentication works better for mobile clients
  • Single Page Applications (SPAs): API-based architecture benefits from stateless tokens
  • Cross-Domain Authentication: JWT works well across different domains
  • Scalability: Horizontal scaling is easier without session state

When to Use Sessions

  • Traditional Web Apps: Server-rendered pages with server-side state
  • Immediate Revocation Required: Need to instantly invalidate user sessions
  • Simple Architecture: Single server or tightly coupled system
  • Sensitive Applications: Banking or healthcare where token theft is a major concern

Security Best Practices

JWT is secure when implemented correctly, but there are many pitfalls that can compromise your application's security. Here are the essential best practices you must follow.

JWT Security Best Practices

Essential security practices for JWT implementation

Security Checklist

  • Always use HTTPS: Never transmit JWTs over unencrypted connections
  • Use strong secret keys: Minimum 256 bits of random data for HS256
  • Set short expiration times: 15 minutes to 1 hour for access tokens
  • Implement refresh tokens: Long-lived tokens for obtaining new access tokens
  • Validate all claims: Check issuer, audience, expiration, and custom claims
  • Use appropriate algorithms: HS256 for symmetric, RS256 for asymmetric
  • Never store sensitive data: Remember that payload is only encoded, not encrypted
  • Implement token blacklisting: For logout and compromised tokens

1. Always Use HTTPS

JWTs are bearer tokens—anyone who possesses the token can use it. If transmitted over HTTP, an attacker can intercept it via man-in-the-middle attacks.

Critical: Never use JWT over HTTP in production. Always enforce HTTPS for all endpoints that handle JWTs.

2. Use Strong Secret Keys

The security of HMAC-based JWTs (HS256) depends entirely on the secret key. A weak secret can be brute-forced.

// Bad - Weak secret
const SECRET = "mysecret";

// Good - Strong random secret (256+ bits)
const SECRET = "a8f5f167f44f4964e6c998dee827110c"
             + "3a9d3c2e9f1a4e8b7c6d5e4f3a2b1c0d";

// Best - Use environment variables
const SECRET = process.env.JWT_SECRET;

3. Set Short Expiration Times

Access tokens should have short lifespans (15-60 minutes). If a token is stolen, it's only valid for a limited time.

const accessToken = jwt.sign(
  { userId: user.id, role: user.role },
  SECRET,
  { expiresIn: '15m' } // 15 minutes
);

const refreshToken = jwt.sign(
  { userId: user.id },
  REFRESH_SECRET,
  { expiresIn: '7d' } // 7 days
);

4. Implement Refresh Tokens

Use a two-token system: short-lived access tokens and long-lived refresh tokens. When the access token expires, use the refresh token to get a new one without requiring the user to log in again.

// Refresh token endpoint
POST /api/auth/refresh
Content-Type: application/json

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

// Response with new access token
{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expiresIn": 900
}

5. Validate the Algorithm

Always specify and validate the algorithm. The "none" algorithm vulnerability allows attackers to bypass signature verification.

// Bad - Accepts any algorithm
jwt.verify(token, SECRET);

// Good - Explicitly specify algorithm
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
Algorithm Confusion Attack: Some libraries had vulnerabilities where an attacker could change the algorithm from RS256 (asymmetric) to HS256 (symmetric) and sign the token with the public key. Always validate the algorithm!

6. Secure Token Storage

Where you store JWTs on the client side matters:

  • Memory (React state, variables): Most secure but lost on page refresh
  • HttpOnly Cookies: Secure from XSS but vulnerable to CSRF (use CSRF tokens)
  • localStorage: Vulnerable to XSS attacks, avoid for sensitive applications
  • sessionStorage: Better than localStorage but still vulnerable to XSS
Recommendation: For high-security applications, use HttpOnly cookies with SameSite attribute and CSRF protection. For SPAs with good XSS protection, in-memory storage with refresh tokens in HttpOnly cookies is a good balance.

7. Implement Token Blacklisting

Since JWTs are stateless, you can't revoke them before expiration. Implement a blacklist (or whitelist) for critical scenarios like logout or account compromise.

// Store blacklisted tokens in Redis with expiration
const blacklistToken = async (token, expiresIn) => {
  const decoded = jwt.decode(token);
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);
  await redis.setex(`blacklist:${token}`, ttl, 'true');
};

// Check blacklist before validating
const isBlacklisted = await redis.get(`blacklist:${token}`);
if (isBlacklisted) {
  throw new Error('Token has been revoked');
}

8. Common Vulnerabilities to Avoid

None Algorithm Attack: Never accept the "none" algorithm. An attacker can remove the signature and set alg to "none".
Weak Secret Keys: Dictionary words or short secrets can be brute-forced. Use cryptographically random keys of at least 256 bits.
No Expiration: Tokens without expiration times are valid forever if stolen. Always set exp claim.
Sensitive Data in Payload: The payload is only Base64 encoded, not encrypted. Anyone can decode and read it. Never include passwords, credit cards, or other sensitive data.

Complete Spring Boot Implementation

Let's put it all together with a complete Spring Boot implementation that follows all security best practices.

1. Dependencies (pom.xml)

<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.3</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.3</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.3</version>
</dependency>

2. Application Properties

# application.yml
jwt:
  secret: your-256-bit-secret-key-here-make-it-strong
  access-token-expiration: 900000 # 15 minutes in ms
  refresh-token-expiration: 604800000 # 7 days in ms
  issuer: myapp.com
  audience: myapp-users

3. JWT Utility Service

@Service
public class JwtService {

    @Value("${jwt.secret}")
    private String secretKey;

    @Value("${jwt.access-token-expiration}")
    private Long accessTokenExpiration;

    @Value("${jwt.refresh-token-expiration}")
    private Long refreshTokenExpiration;

    private SecretKey getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        return Keys.hmacShaKeyFor(keyBytes);
    }

    public String generateAccessToken(Long userId, String role) {
        return Jwts.builder()
            .subject(userId.toString())
            .claim("role", role)
            .issuer("myapp.com")
            .audience().add("myapp-users").and()
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis()
                + accessTokenExpiration))
            .signWith(getSigningKey())
            .compact();
    }

    public String generateRefreshToken(Long userId) {
        return Jwts.builder()
            .subject(userId.toString())
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis()
                + refreshTokenExpiration))
            .signWith(getSigningKey())
            .compact();
    }

    public Claims extractClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }

    public Long extractUserId(String token) {
        return Long.parseLong(extractClaims(token).getSubject());
    }

    public boolean isTokenValid(String token) {
        try {
            Claims claims = extractClaims(token);
            return !claims.getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

4. JWT Authentication Filter

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);

        try {
            // Check if token is blacklisted
            Boolean isBlacklisted = redisTemplate
                .hasKey("blacklist:" + token);

            if (Boolean.TRUE.equals(isBlacklisted)) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                return;
            }

            if (jwtService.isTokenValid(token)) {
                Long userId = jwtService.extractUserId(token);
                Claims claims = jwtService.extractClaims(token);

                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                        userId, null,
                        List.of(new SimpleGrantedAuthority(
                            claims.get("role", String.class)))
                    );

                SecurityContextHolder.getContext()
                    .setAuthentication(authentication);
            }
        } catch (Exception e) {
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            return;
        }

        filterChain.doFilter(request, response);
    }
}

5. Authentication Controller

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @Autowired
    private JwtService jwtService;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        User user = userRepository.findByUsername(request.getUsername())
            .orElseThrow(() -> new BadCredentialsException(
                "Invalid credentials"));

        if (!passwordEncoder.matches(request.getPassword(),
                user.getPassword())) {
            throw new BadCredentialsException("Invalid credentials");
        }

        String accessToken = jwtService.generateAccessToken(
            user.getId(), user.getRole());
        String refreshToken = jwtService.generateRefreshToken(
            user.getId());

        return ResponseEntity.ok(new AuthResponse(
            accessToken, refreshToken, 900));
    }

    @PostMapping("/logout")
    public ResponseEntity<?> logout(
            @RequestHeader("Authorization") String authHeader) {
        String token = authHeader.substring(7);
        Claims claims = jwtService.extractClaims(token);

        long ttl = claims.getExpiration().getTime()
            - System.currentTimeMillis();

        // Add to blacklist
        redisTemplate.opsForValue().set(
            "blacklist:" + token, "true",
            ttl, TimeUnit.MILLISECONDS);

        return ResponseEntity.ok(
            Map.of("message", "Logged out successfully"));
    }
}

6. Protected Endpoints

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserRepository userRepository;

    @GetMapping("/profile")
    public ResponseEntity<User> getProfile() {
        Long userId = (Long) SecurityContextHolder.getContext()
            .getAuthentication().getPrincipal();

        User user = userRepository.findById(userId)
            .orElseThrow(() -> new ResourceNotFoundException(
                "User not found"));

        return ResponseEntity.ok(user);
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('ADMIN')")
    public ResponseEntity<?> deleteUser(@PathVariable Long id) {
        userRepository.deleteById(id);
        return ResponseEntity.ok(
            Map.of("success", true));
    }
}

Conclusion

JWT provides a powerful, stateless authentication mechanism perfect for modern distributed applications. When implemented correctly with proper security practices, it offers an excellent balance of security, scalability, and developer experience.

Key Takeaways:
  • JWT consists of three parts: header, payload, and signature
  • The signature ensures token integrity and authenticity
  • Always use HTTPS and strong secret keys
  • Implement short-lived access tokens with refresh tokens
  • Never store sensitive data in the payload
  • Validate algorithms and all claims
  • Use token blacklisting for logout and revocation

Remember: JWT is a tool, not a silver bullet. Understand your application's security requirements and choose the authentication strategy that best fits your needs. Sometimes traditional sessions are the better choice, and that's perfectly fine.

← Back to Blog