Enhance a URL Phishing Detection System

Add JWT auth, design patterns, and a React dashboard to a Java microservices app.

Introduction

30 Second Summary

Every day, millions of phishing URLs slip past basic detection tools because they look just convincing enough to fool a quick glance. Real security platforms don't rely on a single check. They layer multiple detection strategies, cross-reference threat intelligence feeds, and assign weighted risk scores to flag the most dangerous links.

In this project, you will transform your existing 3-microservice phishing detection system into a production-grade enterprise application. You will add JWT authentication, implement design patterns for a multi-strategy detection engine, integrate a real-world threat feed, build a full React dashboard, and orchestrate everything with Docker Compose.

What You'll Build

A full-stack URL phishing detection platform where users log in, submit URLs for analysis, receive risk scores computed by multiple detection strategies, view scan history with analytics, and where admins manage blacklists and whitelists.

By the end of this project, you'll have:

  • A fully authenticated microservices system with JWT-based login, role-based access control, and secure API Gateway routing.
  • An enterprise-grade phishing detection engine powered by design patterns (Strategy, Factory, Adapter) with weighted risk scoring and real URLhaus threat feed integration.
  • A complete React dashboard with URL scanning, scan history, analytics charts, and an admin panel for blacklist/whitelist management, all backed by PostgreSQL via Spring Data JPA and Spring Data REST.
  • Secret Mission: Deploy the entire system to AWS using ECS Fargate with a proper CI/CD pipeline.

Are there any prerequisites?

This project builds on your existing URL Phishing Detection project. You should already have three Spring Boot microservices (phishing-service, api-gateway, and eureka-server), a minimal React UI, and basic Docker files.

You also need PostgreSQL installed locally and Java 17 or higher. This is a multi-session project designed to take 8 to 12 hours total, so plan to work through it across several sittings.

Set Up PostgreSQL Databases and Verify Spring Boot 4.x Dependencies

Your phishing detection platform currently has three microservices talking to each other. To add enterprise features like user authentication and persistent scan history, each service needs a real database behind it.

In this step, you'll create two PostgreSQL databases, verify that your project's Spring Boot and Spring Cloud versions are compatible, and update your API Gateway configuration for the new Spring Boot 4.x YAML format.

In this step, get ready to:
  • Create phishing_db and user_db databases in PostgreSQL.
  • Verify Spring Boot 4.0.x and Spring Cloud 2025.1.x version compatibility across all microservices.
  • Update the API Gateway route configuration to use the correct Spring Boot 4.x YAML prefix.
Create PostgreSQL databases for each microservice

In a microservices architecture, each service gets its own database. This is called the "database per service" pattern. It keeps data isolated so that one service's schema changes can never break another.

Why two separate databases?

phishing_db will store URL scan results, blacklist entries, and whitelist entries for the phishing detection service.

user_db will store user accounts, hashed credentials, and roles for the authentication service you'll build in the next step.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
  • Start the PostgreSQL interactive shell by running this command:
psql -U postgres

If PostgreSQL prompts you for a password, enter the password you set when you installed PostgreSQL.

  • Inside the PostgreSQL shell, create both databases and exit by running these commands:
CREATE DATABASE phishing_db;
CREATE DATABASE user_db;
\q

What do these commands do?

  • CREATE DATABASE creates a new empty database on your local PostgreSQL server.
  • \q exits the PostgreSQL shell and returns you to your normal terminal.
  • Verify the databases were created by running this command:
psql -U postgres -c "\l"

You should see both phishing_db and user_db listed alongside the default PostgreSQL databases like postgres and template1.

psql: command not found?

  • On macOS, if you installed PostgreSQL via Homebrew, try running brew services start postgresql first. Then retry the psql command.
  • On Windows, PostgreSQL's bin folder may not be in your PATH. Try opening **SQL Shell (psql)** from the Start menu instead, which was installed with PostgreSQL.
  • If you see a "password authentication failed" error, double-check the password you set during PostgreSQL installation.

Help me troubleshoot my PostgreSQL connection

Verify your Spring Boot and Spring Cloud versions

Spring Boot 4.0.x introduced breaking changes in how Spring Cloud integrates. If your Spring Cloud BOM version doesn't match your Spring Boot version, your project won't compile.

Version mismatch causes a cryptic error

Spring Cloud 2025.0.x was built against Spring Boot 3.5 and is incompatible with Spring Boot 4.0. Using it throws a NoSuchMethodError: ConfigDataLocationResolverContext.getBootstrapContext() error at startup.

You must use Spring Cloud 2025.1.0 or later. This is the first release compatible with Spring Boot 4.

  • Open phishing-service/pom.xml in your editor.
  • Near the top of the file, find the <parent> block. Confirm it looks like this:
<!-- Spring Boot parent version - must be 4.0.x -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.6</version>
    <relativePath/>
</parent>
  • Find the <properties> block and confirm your Java version is 17 or higher:
<properties>
    <java.version>17</java.version>
</properties>
  • Find the <dependencyManagement> block. Confirm the Spring Cloud BOM version is 2025.1.0 or 2025.1.1. If you see 2025.0.x, update it to match the following:
<!-- Spring Cloud BOM - MUST be 2025.1.0+ for Boot 4 compatibility -->
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-dependencies</artifactId>
            <version>2025.1.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Now you need to add the new dependencies that this project requires. Your phishing-service needs Spring Data JPA for database access, Spring Security for authentication, and the PostgreSQL driver to connect to your new database.

  • In the same phishing-service/pom.xml file, find the <dependencies> section. Add these four dependencies if they are not already present:
<!-- Database access with Spring Data JPA -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- Authentication and authorization -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

<!-- REST API support -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- PostgreSQL database driver -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

What do these dependencies do?

  • spring-boot-starter-data-jpa adds JPA (Java Persistence API) support so you can map Java classes directly to database tables.
  • spring-boot-starter-security adds Spring Security 7, which handles authentication and authorization for your API endpoints.
  • spring-boot-starter-web provides the core web framework for building REST APIs.
  • postgresql is the JDBC driver that lets your Java application talk to PostgreSQL. The runtime scope means it's only needed when the app runs, not when it compiles.
  • Save phishing-service/pom.xml.
  • Repeat the same version checks for api-gateway/pom.xml and eureka-server/pom.xml. Confirm the Spring Boot parent is 4.0.x and the Spring Cloud BOM is 2025.1.0 or later in both files.

Avoid Java 25 with Eureka

Eureka has a known bug with Java 25 when AOT (Ahead of Time compilation) is enabled. Stick with Java 17 or 21 LTS to avoid this issue. Do not enable spring.aot.enabled=true for the Eureka server.

Next, you need to scaffold the user-service microservice. This service doesn't exist yet in your project, but it needs the same dependency setup as phishing-service.

  • Open your browser and go to Spring Initializr.
  • Select Maven as the project type.
  • Select Java as the language.
  • Set the Spring Boot version to 4.0.6 (or the latest 4.0.x stable release shown).
  • Set the Group to match your existing project's group ID (check your phishing-service pom.xml for the exact value).
  • Set the Artifact to user-service.
  • Set the Java version to 17.
  • Click Add Dependencies and add: Spring Web, Spring Data JPA, Spring Security, PostgreSQL Driver, and Eureka Discovery Client.
  • Click Generate to download the project as a ZIP file.
  • Extract the ZIP and move the user-service folder into your project root directory, alongside phishing-service, api-gateway, and eureka-server.
  • Open user-service/pom.xml and add the Spring Cloud BOM inside the <dependencyManagement> section, matching what you verified in the phishing-service:
<!-- Add this inside <dependencyManagement><dependencies> -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-dependencies</artifactId>
    <version>2025.1.0</version>
    <type>pom</type>
    <scope>import</scope>
</dependency>

What's happening here?

Spring Initializr generated the user-service with the core dependencies you selected. You're adding the Spring Cloud BOM (Bill of Materials) so that Spring Cloud library versions are managed automatically and stay compatible with Spring Boot 4.0.6.

  • Save user-service/pom.xml.
  • Verify the phishing-service compiles with the new dependencies by navigating to the phishing-service directory and running:
cd phishing-service
./mvnw compile

You should see BUILD SUCCESS at the end of the output. If you see dependency resolution errors, double check that your Spring Cloud BOM version is 2025.1.0 or later.

Seeing NoSuchMethodError on startup?

If you see NoSuchMethodError: ConfigDataLocationResolverContext.getBootstrapContext(), your Spring Cloud BOM is set to 2025.0.x. Change it to 2025.1.0 in your pom.xml and recompile.

Help me fix Spring Cloud version compatibility errors

Verify the API Gateway YAML property prefix

Spring Boot 4.x changed the YAML property prefix for gateway route configuration. If you're using the old prefix, your gateway will start but silently ignore all your routes.

  • Open api-gateway/src/main/resources/application.yml.
  • Look for your route definitions. Find this property path:
# OLD prefix (Spring Boot 3.x) - will NOT work with Boot 4.x
spring:
  cloud:
    gateway:
      routes:
        - id: some-service
          uri: lb://SOME-SERVICE
          predicates:
            - Path=/api/some/**
  • If your routes are under spring.cloud.gateway.routes, you need to update the prefix. Replace it with the correct Spring Boot 4.x prefix for your gateway type.

If your API Gateway uses the reactive (WebFlux) gateway, the updated prefix is:

# NEW prefix for reactive (WebFlux) gateway - Spring Boot 4.x
spring:
  cloud:
    gateway:
      server:
        webflux:
          routes:
            - id: some-service
              uri: lb://SOME-SERVICE
              predicates:
                - Path=/api/some/**

If your API Gateway uses the MVC gateway instead, the prefix is:

# NEW prefix for MVC gateway - Spring Boot 4.x
spring:
  cloud:
    gateway:
      server:
        webmvc:
          routes:
            - id: some-service
              uri: lb://SOME-SERVICE
              predicates:
                - Path=/api/some/**

Which gateway type do I have?

Check your api-gateway/pom.xml dependencies. If you see spring-cloud-gateway-server-webflux (or the older spring-cloud-starter-gateway), use the webflux prefix. If you see spring-cloud-gateway-server-webmvc, use the webmvc prefix.

Most Spring Cloud Gateway projects generated before Spring Boot 4 use the reactive (WebFlux) gateway by default.

RewritePath syntax changed too

If any of your routes use RewritePath filters, Spring Boot 4.x changed the syntax from slash-separated to comma-separated. The new format looks like:

filters:
  - RewritePath=/service-name/(?<remaining>.*), /${remaining}
  • Save application.yml.
  • Move all existing route definitions under the new prefix. Keep the route content (IDs, URIs, predicates, filters) exactly the same.
  • Test that your gateway still starts by navigating to the api-gateway directory and running:
cd ../api-gateway
./mvnw spring-boot:run

The gateway should start without errors. Look for a line like Started ApiGatewayApplication in the console output. Press Ctrl+C to stop it once you've confirmed it starts successfully.

Gateway starts but routes don't work?

  • Double check the YAML indentation. The server key must be nested directly under gateway, and webflux (or webmvc) must be nested under server. YAML is sensitive to spaces.
  • Make sure you didn't accidentally leave both the old prefix and the new prefix in the file. Remove the old spring.cloud.gateway.routes block entirely.
  • If you see a RewritePath error, make sure you're using the comma-separated syntax instead of the old slash-separated syntax.

Help me debug my Spring Cloud Gateway route configuration

Your databases are ready, your dependencies are aligned, and your gateway speaks the right configuration language. Next up, you'll build the user-service with Spring Security and JWT authentication so users can log in to your platform.

Build the User Service with Spring Security and JWT Authentication

Your foundation is ready. PostgreSQL databases are running, dependencies are verified, and the API Gateway routes correctly with Spring Boot 4.x.

Every enterprise application needs to control who can access it. In this step, you'll build a brand new user-service microservice that handles registration, login, and JWT-based authentication from scratch.

In this step, get ready to:
  • Define a JPA User entity and connect the user-service to PostgreSQL.
  • Implement JWT token generation and validation with register and login endpoints.
  • Configure Spring Security 7 with a custom JWT authentication filter.
Create the user-service microservice with JPA entities

The user-service needs three things before it can manage users: the right dependencies in its build file, a database connection, and a JPA entity that maps a Java object to a PostgreSQL table.

Your pom.xml already has the core Spring Boot starters from Step 1. You need to add the Eureka client so this service registers for discovery, plus three JJWT libraries for JWT token handling.

  • Open user-service/pom.xml and add these dependencies inside the <dependencies> section:
<!-- Registers this service with the Eureka discovery server -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

<!-- JWT token creation and parsing (all three are required) -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

Why three JJWT dependencies?

The JJWT library splits its code into three modules. jjwt-api provides the public interfaces your code compiles against. jjwt-impl contains the actual token parsing logic at runtime.

jjwt-jackson handles JSON serialization of token claims. Missing any one of these three causes a runtime error that can be difficult to debug.

  • Save pom.xml.
  • If your IDE prompts you to reload Maven dependencies, accept the reload.

Maven dependencies not resolving?

Make sure the Spring Cloud BOM version in your pom.xml is 2025.1.0 or 2025.1.1. Versions in the 2025.0.x range are incompatible with Spring Boot 4 and will fail.

Also confirm that the JJWT version is exactly 0.12.6 on all three dependencies.

Help me resolve Maven dependency errors in my Spring Boot 4 user-service

Next, the user-service needs to know where its database lives, which port to run on, and how to find the Eureka server.

  • Create a new file at user-service/src/main/resources/application.yml and paste the following configuration:
server:
  port: 8083

spring:
  application:
    name: user-service
  datasource:
    # Points to the user_db database you created in Step 1
    url: jdbc:postgresql://localhost:5432/user_db
    username: postgres
    password: your-postgres-password-here
  jpa:
    hibernate:
      # Automatically creates/updates tables to match your JPA entities
      ddl-auto: update
    show-sql: true

eureka:
  client:
    service-url:
      # Registers this service with the Eureka discovery server
      defaultZone: http://localhost:8761/eureka/

What does this configuration do?

  • server.port: 8083 runs the user-service on port 8083, separate from your other microservices.
  • spring.datasource connects the service to the user_db PostgreSQL database you created in Step 1.
  • ddl-auto: update tells Hibernate to automatically create or modify database tables when the app starts, based on your JPA entity definitions.
  • The Eureka client config registers this microservice so the API Gateway can discover and route to it.
  • Replace your-postgres-password-here with the password you use for your local PostgreSQL installation.
  • Save application.yml.

Not sure what your PostgreSQL password is?

It is the password you set when you installed PostgreSQL. If you left it as the default, try postgres. You can test it by running psql -U postgres in your terminal.

Help me find or reset my PostgreSQL password

Now you need a Java class that maps to a users table in PostgreSQL. This is your JPA entity. It defines what a user record looks like: a username, email, hashed password, role, and creation timestamp.

  • In your user-service project, create a new package called entity inside your main package.
  • Create a new file called User.java inside the entity package and paste the following code:
package com.nextwork.userservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;

// Maps this class to a "users" table in PostgreSQL
@Entity
@Table(name = "users")
public class User {

    // The two roles a user can have in the system
    public enum Role {
        USER, ADMIN
    }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Each username must be unique across all users
    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String email;

    // Stores the BCrypt-hashed password, never the plaintext version
    @Column(nullable = false)
    private String password;

    // Stored as a string ("USER" or "ADMIN") in the database
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    // Automatically set to the current time when the user is created
    @CreationTimestamp
    private LocalDateTime createdAt;

    public User() {}

    // Getters and setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }

    public Role getRole() { return role; }
    public void setRole(Role role) { this.role = role; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this entity do?

  • @Entity and @Table(name = "users") tell Hibernate to create a users table that mirrors this class's fields.
  • @Column(unique = true) on username enforces that no two users can share the same username at the database level.
  • The Role enum is stored as a string in the database thanks to @Enumerated(EnumType.STRING). This keeps the values human-readable when you query the table directly.
  • @CreationTimestamp automatically fills in the createdAt field when a user record is first saved.
  • Save User.java.

Adjust the package name

The code examples use com.nextwork.userservice as the base package. If your Spring Initializr project used a different group or artifact name, update the package declaration and import statements to match your project's package structure.

The entity defines the shape of the data. Now you need a repository that handles reading and writing that data to PostgreSQL.

  • Create a new package called repository inside your main package.
  • Create a new file called UserRepository.java inside the repository package and paste the following code:
package com.nextwork.userservice.repository;

import com.nextwork.userservice.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

// Spring Data JPA auto-generates SQL queries from the method name
public interface UserRepository extends JpaRepository<User, Long> {
    // Finds a user by their unique username, returns Optional to handle "not found"
    Optional<User> findByUsername(String username);
}

What is Spring Data JPA doing here?

By extending JpaRepository, your interface automatically gets save(), findById(), findAll(), and delete() methods with zero implementation code.

The custom findByUsername() method is also auto-generated. Spring parses the method name and builds a SQL query like SELECT * FROM users WHERE username = ? at runtime.

  • Save UserRepository.java.

Let's verify everything connects. Start your Eureka server first (if it is not already running), then start the user-service.

  • Start the user-service by running this command from the user-service directory:
./mvnw spring-boot:run
  • Watch the terminal output. You should see Started UserServiceApplication and Tomcat started on port 8083.

What should I see?

Spring Security auto-generates a random password in the logs (a line like Using generated security password: ...). This is normal. You will replace this default behavior with your custom JWT filter in Substep 2.3.

  • Verify the users table was auto-created by opening a new terminal and running:
psql -U postgres -d user_db -c "\dt"
  • You should see a users table listed in the output. Hibernate created it automatically from your User entity.

Application fails to start?

  • If you see a connection refused error, confirm PostgreSQL is running and the password in application.yml matches your local PostgreSQL installation.
  • If you see database "user_db" does not exist, go back and create it with CREATE DATABASE user_db; in psql.
  • If you see a NoSuchMethodError, your Spring Cloud BOM version is likely 2025.0.x instead of 2025.1.x. Update it to 2025.1.0 or higher.

Help me debug my user-service startup error

✔️ Awesome, I've got everything!

Great. Make sure you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the full application.yml for the user-service:

server:
  port: 8083

spring:
  application:
    name: user-service
  datasource:
    url: jdbc:postgresql://localhost:5432/user_db
    username: postgres
    password: your-postgres-password-here
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

Here is the full User.java:

package com.nextwork.userservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;

@Entity
@Table(name = "users")
public class User {

    public enum Role {
        USER, ADMIN
    }

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Role role;

    @CreationTimestamp
    private LocalDateTime createdAt;

    public User() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }

    public Role getRole() { return role; }
    public void setRole(Role role) { this.role = role; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

Here is the full UserRepository.java:

package com.nextwork.userservice.repository;

import com.nextwork.userservice.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
Implement JWT token generation and validation

Your user-service can store users, but it has no way to prove who someone is after they log in. That is what a JWT token solves.

When a user logs in, the server creates a signed token containing their username and role. The client sends this token with every future request. The server verifies the signature to confirm the token has not been tampered with.

What is a JWT?

A JSON Web Token is a compact, URL-safe string that carries claims (data) about a user. It has three parts separated by dots: a header, a payload, and a signature.

The signature is created using a secret key that only the server knows. If anyone modifies the payload, the signature check fails. This lets the server trust the token without querying the database on every request.

  • Create a new package called util inside your main package.
  • Create a new file called JwtUtil.java inside the util package and paste the following code:
package com.nextwork.userservice.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

// Handles all JWT token operations: creation, validation, and claim extraction
@Component
public class JwtUtil {

    // Secret key for signing tokens (use environment variables in production)
    private final SecretKey key = Keys.hmacShaKeyFor(
        "my-super-secret-key-that-is-at-least-32-bytes-long".getBytes()
    );

    // Tokens expire after 24 hours (in milliseconds)
    private final long EXPIRATION_MS = 86400000;

    // Creates a JWT containing the username and role as claims
    public String generateToken(String username, String role) {
        return Jwts.builder()
            .subject(username)
            .claim("role", role)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
            .signWith(key)
            .compact();
    }

    // Checks whether the token signature is valid and not expired
    public boolean validateToken(String token) {
        try {
            Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // Extracts the username (subject) from a valid token
    public String extractUsername(String token) {
        return getClaims(token).getSubject();
    }

    // Extracts the role claim from a valid token
    public String extractRole(String token) {
        return getClaims(token).get("role", String.class);
    }

    // Parses and returns all claims from the token payload
    private Claims getClaims(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

What does this code do?

  • generateToken() builds a JWT string with the username as the subject and the role as a custom claim. The token is signed with an HMAC key and expires after 24 hours.
  • validateToken() attempts to parse the token with the same secret key. If the signature is wrong or the token has expired, the parser throws an exception and the method returns false.
  • extractUsername() and extractRole() read the claims back out of a valid token so the security filter can identify who is making a request.

Use environment variables for the secret key

The hardcoded secret key string is fine for local development. In a production deployment, you would load this from an environment variable or a secrets manager instead of embedding it in source code.

  • Save JwtUtil.java.

Now you need endpoints that let users register and log in. The AuthController will hash passwords with BCrypt during registration and return a JWT token during login.

  • Create a new package called controller inside your main package.
  • Create a new file called AuthController.java inside the controller package and paste the following code:
package com.nextwork.userservice.controller;

import com.nextwork.userservice.entity.User;
import com.nextwork.userservice.repository.UserRepository;
import com.nextwork.userservice.util.JwtUtil;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

// Handles user registration and login requests
@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;
    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    public AuthController(UserRepository userRepository, JwtUtil jwtUtil) {
        this.userRepository = userRepository;
        this.jwtUtil = jwtUtil;
    }

    // Registers a new user with a hashed password and default USER role
    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
        User user = new User();
        user.setUsername(request.get("username"));
        user.setEmail(request.get("email"));
        user.setPassword(passwordEncoder.encode(request.get("password")));
        user.setRole(User.Role.USER);

        userRepository.save(user);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(Map.of("message", "User registered successfully"));
    }

    // Validates credentials and returns a JWT token on success
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
        User user = userRepository.findByUsername(request.get("username"))
            .orElse(null);

        // Check if user exists and password matches the stored hash
        if (user == null || !passwordEncoder.matches(request.get("password"), user.getPassword())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "Invalid username or password"));
        }

        String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
        return ResponseEntity.ok(Map.of("token", token));
    }
}

What does this controller do?

  • POST /api/auth/register takes a JSON body with username, email, and password. It hashes the password using BCrypt before saving, so the database never stores plaintext passwords.
  • POST /api/auth/login looks up the user by username, compares the plaintext password against the stored hash, and returns a JWT token if they match.
  • Every new user is automatically assigned the USER role. Admin users would be created by updating the role directly in the database or through a future admin endpoint.
  • Save AuthController.java.

You will not be able to test these endpoints just yet. Spring Security blocks all requests by default until you configure it in the next substep.

Seeing import errors in your IDE?

If io.jsonwebtoken imports show as unresolved, your IDE has not picked up the new Maven dependencies yet. Try reloading Maven in your IDE. In VS Code, open the command palette and run Java: Force Java Compilation.

Help me fix unresolved JJWT imports in my IDE

✔️ Awesome, I've got everything!

Great. Make sure you have saved both files before moving on to the security configuration.

ⓧ I'd like to double check the full code

Here is the full JwtUtil.java:

package com.nextwork.userservice.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtUtil {

    private final SecretKey key = Keys.hmacShaKeyFor(
        "my-super-secret-key-that-is-at-least-32-bytes-long".getBytes()
    );

    private final long EXPIRATION_MS = 86400000;

    public String generateToken(String username, String role) {
        return Jwts.builder()
            .subject(username)
            .claim("role", role)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + EXPIRATION_MS))
            .signWith(key)
            .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().verifyWith(key).build().parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    public String extractUsername(String token) {
        return getClaims(token).getSubject();
    }

    public String extractRole(String token) {
        return getClaims(token).get("role", String.class);
    }

    private Claims getClaims(String token) {
        return Jwts.parser()
            .verifyWith(key)
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

Here is the full AuthController.java:

package com.nextwork.userservice.controller;

import com.nextwork.userservice.entity.User;
import com.nextwork.userservice.repository.UserRepository;
import com.nextwork.userservice.util.JwtUtil;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

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

    private final UserRepository userRepository;
    private final JwtUtil jwtUtil;
    private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    public AuthController(UserRepository userRepository, JwtUtil jwtUtil) {
        this.userRepository = userRepository;
        this.jwtUtil = jwtUtil;
    }

    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody Map<String, String> request) {
        User user = new User();
        user.setUsername(request.get("username"));
        user.setEmail(request.get("email"));
        user.setPassword(passwordEncoder.encode(request.get("password")));
        user.setRole(User.Role.USER);

        userRepository.save(user);
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(Map.of("message", "User registered successfully"));
    }

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody Map<String, String> request) {
        User user = userRepository.findByUsername(request.get("username"))
            .orElse(null);

        if (user == null || !passwordEncoder.matches(request.get("password"), user.getPassword())) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(Map.of("error", "Invalid username or password"));
        }

        String token = jwtUtil.generateToken(user.getUsername(), user.getRole().name());
        return ResponseEntity.ok(Map.of("token", token));
    }
}
Configure Spring Security 7 with a JWT filter

Spring Security 7 locks down every endpoint by default. You need to tell it two things: which paths are public (registration and login), and how to authenticate everything else (using your JWT tokens).

This requires two classes. A filter that intercepts incoming requests and extracts the JWT. A configuration class that wires that filter into Spring Security's chain.

  • Create a new package called security inside your main package.
  • Create a new file called JwtAuthenticationFilter.java inside the security package and paste the following code:
package com.nextwork.userservice.security;

import com.nextwork.userservice.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

// Intercepts every request to check for a valid JWT in the Authorization header
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

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

        // Extract the Authorization header value
        String authHeader = request.getHeader("Authorization");

        // Only process if the header starts with "Bearer "
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.extractUsername(token);
                String role = jwtUtil.extractRole(token);

                // Build an authentication token with the user's role as a granted authority
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                        username,
                        null,
                        List.of(new SimpleGrantedAuthority("ROLE_" + role))
                    );

                // Set the authentication in Spring Security's context for this request
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        // Continue to the next filter in the chain
        filterChain.doFilter(request, response);
    }
}

What does this filter do?

  • OncePerRequestFilter guarantees this filter runs exactly once per HTTP request, even if the request is forwarded internally.
  • It checks the Authorization header for a Bearer prefix. If no header exists (like for login/register), the filter simply passes the request through without blocking it.
  • When a valid token is found, the filter sets a UsernamePasswordAuthenticationToken on the SecurityContext. This tells Spring Security that the current request is authenticated and what role the user has.
  • Save JwtAuthenticationFilter.java.

The filter knows how to read tokens, but Spring Security does not know the filter exists yet. The SecurityConfig class registers it and defines the access rules.

  • Create a new file called SecurityConfig.java inside the security package and paste the following code:
package com.nextwork.userservice.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

// Configures Spring Security for stateless JWT-based authentication
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF protection since this is a stateless REST API
            .csrf(csrf -> csrf.disable())
            // No HTTP sessions - every request must carry its own JWT
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                // Registration and login endpoints are open to everyone
                .requestMatchers("/api/auth/**").permitAll()
                // All other endpoints require a valid JWT
                .anyRequest().authenticated()
            )
            // Run the JWT filter before Spring's default username/password filter
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

What does this configuration do?

  • csrf.disable() turns off CSRF protection. CSRF tokens are needed for browser-based form submissions, but a stateless API using JWT tokens does not need them.
  • SessionCreationPolicy.STATELESS tells Spring to never create an HTTP session. Each request must prove identity by including a JWT.
  • requestMatchers("/api/auth/**").permitAll() opens the registration and login endpoints to unauthenticated users. Every other path requires a valid JWT.
  • addFilterBefore() inserts your custom JWT filter before Spring's default login filter. This ensures the JWT is checked first on every incoming request.
  • Save SecurityConfig.java.
  • Stop the running user-service (press Ctrl+C in the terminal) and restart it by running:
./mvnw spring-boot:run
  • Watch the logs for Started UserServiceApplication. You should no longer see the "Using generated security password" line, because your custom security config has replaced the default behavior.

Now test the full registration and login flow. Open a new terminal and register a test user by running:

curl -X POST http://localhost:8083/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username": "testuser", "email": "test@example.com", "password": "password123"}'
  • You should see a JSON response: {"message":"User registered successfully"}.
  • Log in with the same credentials by running:
curl -X POST http://localhost:8083/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "testuser", "password": "password123"}'
  • You should see a JSON response containing a token field with a long string of characters. That string is your JWT.

What is that long string?

The JWT looks something like eyJhbGciOiJIUzI1NiJ9.eyJzdWIi... with three sections separated by dots. The first section is the algorithm header. The second is the payload containing the username and role. The third is the cryptographic signature.

Getting a 403 Forbidden on register?

  • Make sure your SecurityConfig has .requestMatchers("/api/auth/**").permitAll(). Without this, Spring Security blocks registration too.
  • Check that CSRF is disabled with .csrf(csrf -> csrf.disable()). If CSRF is still enabled, POST requests without a CSRF token get rejected.
  • Confirm the app restarted cleanly after adding the security classes. Old compiled classes may still be in memory.

Help me debug a 403 error on my auth endpoints

Getting a 401 Unauthorized on login?

This means the credentials do not match. Double-check that you registered first and that the username and password in the login command exactly match what you used during registration.

Help me debug a 401 error on my login endpoint

✔️ Awesome, I've got everything!

Great. Make sure all your files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full JwtAuthenticationFilter.java:

package com.nextwork.userservice.security;

import com.nextwork.userservice.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

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

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

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.extractUsername(token);
                String role = jwtUtil.extractRole(token);

                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(
                        username,
                        null,
                        List.of(new SimpleGrantedAuthority("ROLE_" + role))
                    );

                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

Here is the full SecurityConfig.java:

package com.nextwork.userservice.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

Your user-service is fully operational with registration, login, and JWT authentication. Next up, you will add JPA entities and repositories to the phishing-service so it can persist scan results to PostgreSQL.

Add Spring Data JPA Entities and Repositories to Phishing Service

Your user-service is live with JWT authentication and role-based access control. Users can register and log in. Now it is time to give the phishing-service a memory.

Right now, the phishing-service has no way to store scan results, track known malicious domains, or remember anything between requests. In this step, you will define the database layer using Spring Data JPA entities and repositories, then connect the service to your phishing_db PostgreSQL database.

In this step, get ready to:
  • Create JPA entities for scan results, blacklist entries, and whitelist entries.
  • Build Spring Data JPA repositories with custom query methods.
  • Configure the phishing-service to connect to PostgreSQL and auto-create database tables.
Create JPA entities for the phishing service

JPA entities are Java classes that map directly to database tables. Each field in the class becomes a column in the table. Spring Boot auto-creates these tables at startup when you set ddl-auto=update in your configuration.

You need three entities: one to store scan results, one for blacklisted domains, and one for whitelisted domains. You also need an enum to categorize risk levels.

  • Inside your phishing-service's main Java source folder, create a new package called entity.
  • Create a file called RiskLevel.java inside the entity package, then paste the following code:
package com.example.phishingservice.entity;

// Categorizes the severity of a scanned URL's risk
public enum RiskLevel {
    SAFE,
    SUSPICIOUS,
    HIGH,
    CRITICAL
}

Adjust the package name

If your phishing-service uses a different base package (not com.example.phishingservice), replace it in every file you create in this step. The package must match your project's directory structure.

  • Create a file called ScanResult.java inside the same entity package, then paste the following code:
package com.example.phishingservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;

// Stores the result of each phishing scan
@Entity
@Table(name = "scan_results")
public class ScanResult {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // The URL that was scanned
    private String url;

    // Numeric risk score from 0 (safe) to 100 (critical)
    private Integer riskScore;

    // Categorical risk level derived from the score
    @Enumerated(EnumType.STRING)
    private RiskLevel riskLevel;

    // JSON string containing individual feature scores
    @Column(columnDefinition = "TEXT")
    private String details;

    // Username of the person who requested the scan
    private String userId;

    @CreationTimestamp
    private LocalDateTime createdAt;

    // Default constructor required by JPA
    public ScanResult() {}

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public String getUrl() { return url; }
    public void setUrl(String url) { this.url = url; }

    public Integer getRiskScore() { return riskScore; }
    public void setRiskScore(Integer riskScore) { this.riskScore = riskScore; }

    public RiskLevel getRiskLevel() { return riskLevel; }
    public void setRiskLevel(RiskLevel riskLevel) { this.riskLevel = riskLevel; }

    public String getDetails() { return details; }
    public void setDetails(String details) { this.details = details; }

    public String getUserId() { return userId; }
    public void setUserId(String userId) { this.userId = userId; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this entity do?

Every time a user scans a URL, the system creates a ScanResult record. This is the core data model for the entire detection engine.

  • The id uses a UUID instead of a Long for globally unique identifiers across microservices.
  • The riskLevel uses @Enumerated(EnumType.STRING) so the database stores human-readable values like SAFE instead of numeric ordinals.
  • The details field stores a JSON string containing the individual scores from each detection strategy. You will populate this in a later step.
  • The @CreationTimestamp annotation tells Hibernate to automatically set the timestamp when a new record is inserted.
  • Save both RiskLevel.java and ScanResult.java.

You will not see a visible change yet. The next two entities complete the set, and then you will start the application to verify table creation.

  • Create a file called BlacklistEntry.java inside the entity package, then paste the following code:
package com.example.phishingservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;

// Represents a domain that has been flagged as malicious
@Entity
@Table(name = "blacklist_entries")
public class BlacklistEntry {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // The domain name flagged as dangerous
    private String domain;

    // Why this domain was blacklisted
    private String reason;

    // The admin username who added this entry
    private String addedBy;

    @CreationTimestamp
    private LocalDateTime createdAt;

    public BlacklistEntry() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getDomain() { return domain; }
    public void setDomain(String domain) { this.domain = domain; }

    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }

    public String getAddedBy() { return addedBy; }
    public void setAddedBy(String addedBy) { this.addedBy = addedBy; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this entity do?

A BlacklistEntry represents a domain that an admin has manually flagged as dangerous. The detection engine will check incoming URLs against this local blacklist in addition to the external URLhaus threat feed.

The id uses GenerationType.IDENTITY instead of UUID because blacklist entries are simpler administrative records. A sequential Long is sufficient here.

  • Save BlacklistEntry.java.
  • Create a file called WhitelistEntry.java inside the same entity package, then paste the following code:
package com.example.phishingservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;

// Represents a domain that has been marked as safe
@Entity
@Table(name = "whitelist_entries")
public class WhitelistEntry {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // The domain name marked as safe
    private String domain;

    // Why this domain was whitelisted
    private String reason;

    // The admin username who added this entry
    private String addedBy;

    @CreationTimestamp
    private LocalDateTime createdAt;

    public WhitelistEntry() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getDomain() { return domain; }
    public void setDomain(String domain) { this.domain = domain; }

    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }

    public String getAddedBy() { return addedBy; }
    public void setAddedBy(String addedBy) { this.addedBy = addedBy; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

Why separate blacklist and whitelist entities?

The WhitelistEntry mirrors the BlacklistEntry structure but serves the opposite purpose. Whitelisted domains skip the reputation check entirely, which speeds up scanning for known-safe domains like google.com.

Keeping them as separate tables makes it easy to query, secure, and expose them independently through Spring Data REST later.

  • Save WhitelistEntry.java.

Seeing import errors in your IDE?

  • Make sure spring-boot-starter-data-jpa is listed as a dependency in your phishing-service's pom.xml. This dependency provides the jakarta.persistence imports.
  • Check that @CreationTimestamp is imported from org.hibernate.annotations.CreationTimestamp, not from a different package.
  • Verify the package name at the top of each file matches your project's directory structure exactly.

Still stuck? Help me fix import errors in my JPA entity files for the phishing-service.

Build Spring Data JPA repositories

Entities define the shape of your data. Repositories provide the methods to read and write that data. Spring Data JPA generates the actual SQL queries from your method names at runtime, so you only need to declare interfaces.

  • Inside your phishing-service's main Java source folder, create a new package called repository.
  • Create a file called ScanResultRepository.java inside the repository package, then paste the following code:
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.entity.ScanResult;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

// Provides CRUD operations and custom queries for scan results
public interface ScanResultRepository extends JpaRepository<ScanResult, UUID> {

    // Fetches all scans for a specific user, newest first
    List<ScanResult> findByUserIdOrderByCreatedAtDesc(String userId);

    // Fetches all scans that match a specific risk level
    List<ScanResult> findByRiskLevel(RiskLevel level);

    // Counts how many scans fall into a specific risk level (for analytics)
    long countByRiskLevel(RiskLevel level);
}

How do these method names become SQL queries?

Spring Data JPA uses a naming convention called query derivation. It parses method names into SQL at startup.

  • The method findByUserIdOrderByCreatedAtDesc becomes SELECT * FROM scan_results WHERE user_id = ? ORDER BY created_at DESC.
  • The method countByRiskLevel becomes SELECT COUNT(*) FROM scan_results WHERE risk_level = ?. This powers the analytics dashboard you will build later.

You never write any SQL yourself. If a method name does not follow the naming convention, Spring will throw an error at startup.

  • Save ScanResultRepository.java.
  • Create a file called BlacklistRepository.java inside the repository package, then paste the following code:
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.BlacklistEntry;
import org.springframework.data.jpa.repository.JpaRepository;

// Provides CRUD operations for blacklisted domains
public interface BlacklistRepository extends JpaRepository<BlacklistEntry, Long> {

    // Checks if a domain is already blacklisted (returns true/false)
    boolean existsByDomain(String domain);
}
  • Save BlacklistRepository.java.
  • Create a file called WhitelistRepository.java inside the repository package, then paste the following code:
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.WhitelistEntry;
import org.springframework.data.jpa.repository.JpaRepository;

// Provides CRUD operations for whitelisted domains
public interface WhitelistRepository extends JpaRepository<WhitelistEntry, Long> {

    // Checks if a domain is already whitelisted (returns true/false)
    boolean existsByDomain(String domain);
}

Why existsByDomain instead of findByDomain?

The detection engine only needs to know whether a domain is on the list, not the full entry details. The existsByDomain method returns a simple boolean, which is faster than loading an entire entity object.

  • Save WhitelistRepository.java.

Seeing red underlines on the import paths?

  • Double check that the import paths for your entity classes match the actual package where you created them. If your base package is not com.example.phishingservice, update the imports accordingly.
  • Make sure all four entity files (RiskLevel.java, ScanResult.java, BlacklistEntry.java, WhitelistEntry.java) are saved before creating the repositories.

Need a hand? Help me fix import errors in my Spring Data JPA repository interfaces.

Configure PostgreSQL and verify table creation

Your entities and repositories are in place. Now you need to tell the phishing-service how to connect to the phishing_db database you created in Step 1.

  • Open the file application.yml inside your phishing-service's src/main/resources folder.
  • Replace its contents with the following configuration:
# Server runs on port 8082
server:
  port: 8082

spring:
  # Register this service with Eureka
  application:
    name: phishing-service

  # PostgreSQL connection settings
  datasource:
    url: jdbc:postgresql://localhost:5432/phishing_db
    username: postgres
    password: [[POSTGRES_PASSWORD="your PostgreSQL password"]]
    driver-class-name: org.postgresql.Driver

  # JPA auto-creates tables from your entities on startup
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

# Eureka client registration
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

What do these settings do?

  • The datasource.url points to the phishing_db database running on your local PostgreSQL instance.
  • The ddl-auto: update setting tells Hibernate to compare your entity classes to the database schema on startup. It creates any missing tables and adds any missing columns without dropping existing data.
  • The show-sql: true setting prints the generated SQL statements to your console. This is helpful for debugging during development.
  • The Eureka client settings register the phishing-service with your service discovery server, just like the user-service.
  • Save application.yml.

Time to start the phishing-service and verify that Hibernate auto-creates your three database tables.

  • Make sure your Eureka server is running on port 8761 and PostgreSQL is running locally.
  • Start the phishing-service by running this command from the phishing-service directory:
./mvnw spring-boot:run

Watch the console output. You should see Hibernate SQL statements creating the scan_results, blacklist_entries, and whitelist_entries tables.

  • Verify the tables were created by connecting to PostgreSQL and running this command:
psql -U postgres -d phishing_db -c "\dt"

You should see three tables listed: blacklist_entries, scan_results, and whitelist_entries.

Application fails to start?

  • If you see a Connection refused error, make sure PostgreSQL is running and the phishing_db database exists. You created it in Step 1 with CREATE DATABASE phishing_db;.
  • If you see an authentication error, double check the username and password in your application.yml match your local PostgreSQL credentials.
  • If you see a MappingException or entity-related error, verify that every entity class has a no-arg constructor and that @Id and @GeneratedValue are present on the id field.

Need help? My phishing-service won't start after adding JPA entities and PostgreSQL configuration.

✔️ Awesome, I've got everything!

Great. Double check that all your files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full code for every file you created or modified in this step.

package com.example.phishingservice.entity;

// Categorizes the severity of a scanned URL's risk
public enum RiskLevel {
    SAFE,
    SUSPICIOUS,
    HIGH,
    CRITICAL
}
package com.example.phishingservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;

// Stores the result of each phishing scan
@Entity
@Table(name = "scan_results")
public class ScanResult {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    // The URL that was scanned
    private String url;

    // Numeric risk score from 0 (safe) to 100 (critical)
    private Integer riskScore;

    // Categorical risk level derived from the score
    @Enumerated(EnumType.STRING)
    private RiskLevel riskLevel;

    // JSON string containing individual feature scores
    @Column(columnDefinition = "TEXT")
    private String details;

    // Username of the person who requested the scan
    private String userId;

    @CreationTimestamp
    private LocalDateTime createdAt;

    // Default constructor required by JPA
    public ScanResult() {}

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public String getUrl() { return url; }
    public void setUrl(String url) { this.url = url; }

    public Integer getRiskScore() { return riskScore; }
    public void setRiskScore(Integer riskScore) { this.riskScore = riskScore; }

    public RiskLevel getRiskLevel() { return riskLevel; }
    public void setRiskLevel(RiskLevel riskLevel) { this.riskLevel = riskLevel; }

    public String getDetails() { return details; }
    public void setDetails(String details) { this.details = details; }

    public String getUserId() { return userId; }
    public void setUserId(String userId) { this.userId = userId; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.example.phishingservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;

// Represents a domain that has been flagged as malicious
@Entity
@Table(name = "blacklist_entries")
public class BlacklistEntry {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // The domain name flagged as dangerous
    private String domain;

    // Why this domain was blacklisted
    private String reason;

    // The admin username who added this entry
    private String addedBy;

    @CreationTimestamp
    private LocalDateTime createdAt;

    public BlacklistEntry() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getDomain() { return domain; }
    public void setDomain(String domain) { this.domain = domain; }

    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }

    public String getAddedBy() { return addedBy; }
    public void setAddedBy(String addedBy) { this.addedBy = addedBy; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.example.phishingservice.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;

// Represents a domain that has been marked as safe
@Entity
@Table(name = "whitelist_entries")
public class WhitelistEntry {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // The domain name marked as safe
    private String domain;

    // Why this domain was whitelisted
    private String reason;

    // The admin username who added this entry
    private String addedBy;

    @CreationTimestamp
    private LocalDateTime createdAt;

    public WhitelistEntry() {}

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getDomain() { return domain; }
    public void setDomain(String domain) { this.domain = domain; }

    public String getReason() { return reason; }
    public void setReason(String reason) { this.reason = reason; }

    public String getAddedBy() { return addedBy; }
    public void setAddedBy(String addedBy) { this.addedBy = addedBy; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.entity.ScanResult;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.UUID;

// Provides CRUD operations and custom queries for scan results
public interface ScanResultRepository extends JpaRepository<ScanResult, UUID> {

    // Fetches all scans for a specific user, newest first
    List<ScanResult> findByUserIdOrderByCreatedAtDesc(String userId);

    // Fetches all scans that match a specific risk level
    List<ScanResult> findByRiskLevel(RiskLevel level);

    // Counts how many scans fall into a specific risk level (for analytics)
    long countByRiskLevel(RiskLevel level);
}
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.BlacklistEntry;
import org.springframework.data.jpa.repository.JpaRepository;

// Provides CRUD operations for blacklisted domains
public interface BlacklistRepository extends JpaRepository<BlacklistEntry, Long> {

    // Checks if a domain is already blacklisted (returns true/false)
    boolean existsByDomain(String domain);
}
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.WhitelistEntry;
import org.springframework.data.jpa.repository.JpaRepository;

// Provides CRUD operations for whitelisted domains
public interface WhitelistRepository extends JpaRepository<WhitelistEntry, Long> {

    // Checks if a domain is already whitelisted (returns true/false)
    boolean existsByDomain(String domain);
}
# Server runs on port 8082
server:
  port: 8082

spring:
  # Register this service with Eureka
  application:
    name: phishing-service

  # PostgreSQL connection settings
  datasource:
    url: jdbc:postgresql://localhost:5432/phishing_db
    username: postgres
    password: [[POSTGRES_PASSWORD="your PostgreSQL password"]]
    driver-class-name: org.postgresql.Driver

  # JPA auto-creates tables from your entities on startup
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

# Eureka client registration
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

Your phishing-service now has a complete database layer with entities for scan results, blacklists, and whitelists. Next up, you will build the detection engine itself using Strategy, Factory, and Adapter design patterns to power the risk scoring logic.

Implement Design Patterns in the Phishing Detection Engine

In the previous step, you gave the phishing-service a PostgreSQL database layer with JPA entities and repositories. Your service can now persist scan results, blacklist entries, and whitelist entries.

Now it is time to build the actual detection brain. A real phishing detection engine needs to analyze URLs from multiple angles: text patterns, content signals, and reputation data from external threat feeds.

You will use three classic design patterns to structure this engine. The Strategy pattern lets you plug in different analysis algorithms. The Adapter pattern wraps external APIs behind a common interface. The Factory pattern decides which strategies to run for each URL.

In this step, get ready to:
  • Create a Strategy interface and three concrete detection strategies (lexical, content, reputation).
  • Build Adapter classes that wrap the local blacklist database and the URLhaus threat intelligence API behind a common interface.
  • Wire everything together with a Factory that decides which strategies apply to each URL.
Create the Strategy interface and AnalysisResult class

The Strategy pattern defines a common interface that all detection algorithms implement. Each strategy receives a URL and returns a normalized score between 0.0 (safe) and 1.0 (dangerous) along with detailed findings.

  • Inside your phishing-service's src/main/java/.../ directory, create a new package folder called strategy.
  • Create a file called DetectionStrategy.java in the strategy package and paste the following code:
package com.nextwork.phishingservice.strategy;

// Common contract for all detection algorithms
public interface DetectionStrategy {
    AnalysisResult analyze(String url);
}
  • Create a second file in the same package called AnalysisResult.java and paste this code:
package com.nextwork.phishingservice.strategy;

import java.util.Map;

// Holds the output of a single detection strategy
public class AnalysisResult {

    // Which strategy produced this result
    private String strategyName;

    // Risk score normalized to 0.0 (safe) - 1.0 (dangerous)
    private double normalizedScore;

    // Granular findings from the analysis
    private Map<String, Object> details;

    public AnalysisResult(String strategyName, double normalizedScore,
                          Map<String, Object> details) {
        this.strategyName = strategyName;
        this.normalizedScore = normalizedScore;
        this.details = details;
    }

    public String getStrategyName() {
        return strategyName;
    }

    public double getNormalizedScore() {
        return normalizedScore;
    }

    public Map<String, Object> getDetails() {
        return details;
    }
}

What does this code do?

DetectionStrategy is the common contract every analysis algorithm will implement. It has a single method, analyze(String url), that takes a URL and returns an AnalysisResult.

AnalysisResult bundles three pieces of information: which strategy produced the result (strategyName), a normalized risk score from 0.0 to 1.0 (normalizedScore), and a map of granular findings (details).

  • Save both DetectionStrategy.java and AnalysisResult.java.

You won't see a change yet. These two files define the contract that the concrete strategies will implement next.

Build the LexicalAnalysisStrategy

Phishing URLs often have telltale textual patterns. They tend to be unusually long, packed with special characters, and loaded with keywords like "login" or "verify". The lexical strategy catches these signals by examining the raw URL string.

  • Create LexicalAnalysisStrategy.java in the strategy package and paste the following code:
package com.nextwork.phishingservice.strategy;

import org.springframework.stereotype.Component;
import java.net.URI;
import java.util.*;

@Component
public class LexicalAnalysisStrategy implements DetectionStrategy {

    // TLDs commonly abused in phishing campaigns
    private static final Set<String> SUSPICIOUS_TLDS =
        Set.of(".xyz", ".top", ".club", ".tk", ".ml");

    // Keywords that often appear in phishing URLs
    private static final Set<String> SUSPICIOUS_KEYWORDS =
        Set.of("login", "secure", "verify", "update", "account", "banking");

    @Override
    public AnalysisResult analyze(String url) {
        Map<String, Object> details = new LinkedHashMap<>();
        double totalScore = 0.0;
        int checks = 0;

        // Check URL length (over 75 characters is suspicious)
        boolean longUrl = url.length() > 75;
        details.put("urlLength", url.length());
        details.put("longUrl", longUrl);
        if (longUrl) totalScore += 1.0;
        checks++;

        // Count special characters that phishing URLs overuse
        long specialCharCount = url.chars()
            .filter(c -> c == '@' || c == '-' || c == '_')
            .count();
        details.put("specialCharCount", specialCharCount);
        if (specialCharCount > 5) totalScore += 1.0;
        checks++;

        // Detect IP address used as the host instead of a domain
        boolean ipBased = url.matches(
            ".*://\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}.*");
        details.put("ipBasedHost", ipBased);
        if (ipBased) totalScore += 1.0;
        checks++;

        // Flag URLs with known-suspicious TLDs
        String lowerUrl = url.toLowerCase();
        boolean suspiciousTld = SUSPICIOUS_TLDS.stream()
            .anyMatch(lowerUrl::contains);
        details.put("suspiciousTld", suspiciousTld);
        if (suspiciousTld) totalScore += 1.0;
        checks++;

        // Count subdomain depth (more than 3 dots is suspicious)
        int subdomainDepth = getSubdomainDepth(url);
        details.put("subdomainDepth", subdomainDepth);
        if (subdomainDepth > 3) totalScore += 1.0;
        checks++;

        // Calculate Shannon entropy (randomized URLs score higher)
        double entropy = calculateEntropy(url);
        details.put("entropy", entropy);
        if (entropy > 4.0) totalScore += 1.0;
        checks++;

        // Search for phishing-related keywords
        long keywordCount = SUSPICIOUS_KEYWORDS.stream()
            .filter(lowerUrl::contains)
            .count();
        details.put("suspiciousKeywordCount", keywordCount);
        if (keywordCount > 0) totalScore += 1.0;
        checks++;

        // Normalize to 0.0 - 1.0 range
        double normalizedScore = checks > 0
            ? totalScore / checks : 0.0;

        return new AnalysisResult("lexical", normalizedScore, details);
    }

    // Counts dots in the host to estimate subdomain nesting
    private int getSubdomainDepth(String url) {
        try {
            String host = URI.create(url).getHost();
            if (host == null) return 0;
            return (int) host.chars().filter(c -> c == '.').count();
        } catch (Exception e) {
            return 0;
        }
    }

    // Computes Shannon entropy to detect randomized/obfuscated URLs
    private double calculateEntropy(String text) {
        Map<Character, Integer> freq = new HashMap<>();
        for (char c : text.toCharArray()) {
            freq.merge(c, 1, Integer::sum);
        }
        double entropy = 0.0;
        for (int count : freq.values()) {
            double p = (double) count / text.length();
            entropy -= p * (Math.log(p) / Math.log(2));
        }
        return entropy;
    }
}

What does this code do?

This strategy inspects the raw text of a URL and runs seven independent checks. Each check that triggers adds 1.0 to a running total, and the final score is divided by the number of checks to produce a normalized 0.0-1.0 score.

  • URL length flags URLs longer than 75 characters. Phishing URLs tend to be padded with extra path segments to look legitimate.
  • IP-based host detection catches URLs that use raw IP addresses instead of domain names.
  • Shannon entropy measures how random a URL string looks. Randomly generated phishing domains score higher than normal English words.
  • Suspicious keywords like "login", "verify", and "banking" appear frequently in phishing URLs that impersonate real services.
  • Save LexicalAnalysisStrategy.java.

You won't see a change yet. The next chunk adds the second strategy.

Build the ContentAnalysisStrategy

While lexical analysis focuses on the URL text itself, content analysis checks structural signals. Missing HTTPS, heavy URL encoding, or the use of a shortener domain are all red flags that point to a disguised destination.

  • Create ContentAnalysisStrategy.java in the strategy package and paste the following code:
package com.nextwork.phishingservice.strategy;

import org.springframework.stereotype.Component;
import java.util.*;

@Component
public class ContentAnalysisStrategy implements DetectionStrategy {

    // Known URL shortener domains
    private static final Set<String> SHORTENER_DOMAINS =
        Set.of("bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd");

    @Override
    public AnalysisResult analyze(String url) {
        Map<String, Object> details = new LinkedHashMap<>();
        double totalScore = 0.0;
        int checks = 0;

        // HTTP-only URLs are more likely to be phishing
        boolean usesHttps = url.toLowerCase().startsWith("https://");
        details.put("usesHttps", usesHttps);
        if (!usesHttps) totalScore += 1.0;
        checks++;

        // Count percent-encoded sequences (%XX) that hide characters
        long encodedCharCount = countEncodedChars(url);
        details.put("encodedCharCount", encodedCharCount);
        if (encodedCharCount > 3) totalScore += 1.0;
        checks++;

        // Check if URL uses a known shortener to mask its destination
        String lowerUrl = url.toLowerCase();
        boolean isShortener = SHORTENER_DOMAINS.stream()
            .anyMatch(lowerUrl::contains);
        details.put("urlShortener", isShortener);
        if (isShortener) totalScore += 1.0;
        checks++;

        // Look for redirect chain indicators in query parameters
        boolean hasRedirectIndicators = lowerUrl.contains("redirect")
            || lowerUrl.contains("url=")
            || lowerUrl.contains("next=")
            || lowerUrl.contains("goto=");
        details.put("redirectIndicators", hasRedirectIndicators);
        if (hasRedirectIndicators) totalScore += 1.0;
        checks++;

        // Normalize to 0.0 - 1.0 range
        double normalizedScore = checks > 0
            ? totalScore / checks : 0.0;

        return new AnalysisResult("content", normalizedScore, details);
    }

    // Counts percent-encoded sequences like %20, %3A, etc.
    private long countEncodedChars(String url) {
        long count = 0;
        for (int i = 0; i < url.length() - 2; i++) {
            if (url.charAt(i) == '%'
                && Character.isLetterOrDigit(url.charAt(i + 1))
                && Character.isLetterOrDigit(url.charAt(i + 2))) {
                count++;
            }
        }
        return count;
    }
}

What does this code do?

This strategy runs four structural checks on the URL and normalizes the score the same way as the lexical strategy.

  • HTTPS check flags plain HTTP URLs. Legitimate sites almost always use HTTPS.
  • URL encoding detection counts percent-encoded sequences like %3A. Heavy encoding is a common obfuscation technique.
  • Shortener detection flags domains like bit.ly and t.co that hide the real destination.
  • Redirect indicators catch query parameters like url= or goto= that bounce users through redirect chains.
  • Save ContentAnalysisStrategy.java.

You now have two working detection strategies. The third strategy (reputation checking) depends on the Adapter classes you will build in the next substep.

Seeing import errors in your IDE?

Make sure each file is in the correct package folder. The strategy directory must sit alongside your existing entity and repository packages inside the phishing-service source root.

Help me fix import errors in my phishing-service strategy classes.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files in the strategy package.

ⓧ I'd like to double check the full code

Here are the four strategy files for reference.

package com.nextwork.phishingservice.strategy;

// Common contract for all detection algorithms
public interface DetectionStrategy {
    AnalysisResult analyze(String url);
}
package com.nextwork.phishingservice.strategy;

import java.util.Map;

// Holds the output of a single detection strategy
public class AnalysisResult {

    // Which strategy produced this result
    private String strategyName;

    // Risk score normalized to 0.0 (safe) - 1.0 (dangerous)
    private double normalizedScore;

    // Granular findings from the analysis
    private Map<String, Object> details;

    public AnalysisResult(String strategyName, double normalizedScore,
                          Map<String, Object> details) {
        this.strategyName = strategyName;
        this.normalizedScore = normalizedScore;
        this.details = details;
    }

    public String getStrategyName() {
        return strategyName;
    }

    public double getNormalizedScore() {
        return normalizedScore;
    }

    public Map<String, Object> getDetails() {
        return details;
    }
}
package com.nextwork.phishingservice.strategy;

import org.springframework.stereotype.Component;
import java.net.URI;
import java.util.*;

@Component
public class LexicalAnalysisStrategy implements DetectionStrategy {

    // TLDs commonly abused in phishing campaigns
    private static final Set<String> SUSPICIOUS_TLDS =
        Set.of(".xyz", ".top", ".club", ".tk", ".ml");

    // Keywords that often appear in phishing URLs
    private static final Set<String> SUSPICIOUS_KEYWORDS =
        Set.of("login", "secure", "verify", "update", "account", "banking");

    @Override
    public AnalysisResult analyze(String url) {
        Map<String, Object> details = new LinkedHashMap<>();
        double totalScore = 0.0;
        int checks = 0;

        // Check URL length (over 75 characters is suspicious)
        boolean longUrl = url.length() > 75;
        details.put("urlLength", url.length());
        details.put("longUrl", longUrl);
        if (longUrl) totalScore += 1.0;
        checks++;

        // Count special characters that phishing URLs overuse
        long specialCharCount = url.chars()
            .filter(c -> c == '@' || c == '-' || c == '_')
            .count();
        details.put("specialCharCount", specialCharCount);
        if (specialCharCount > 5) totalScore += 1.0;
        checks++;

        // Detect IP address used as the host instead of a domain
        boolean ipBased = url.matches(
            ".*://\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}.*");
        details.put("ipBasedHost", ipBased);
        if (ipBased) totalScore += 1.0;
        checks++;

        // Flag URLs with known-suspicious TLDs
        String lowerUrl = url.toLowerCase();
        boolean suspiciousTld = SUSPICIOUS_TLDS.stream()
            .anyMatch(lowerUrl::contains);
        details.put("suspiciousTld", suspiciousTld);
        if (suspiciousTld) totalScore += 1.0;
        checks++;

        // Count subdomain depth (more than 3 dots is suspicious)
        int subdomainDepth = getSubdomainDepth(url);
        details.put("subdomainDepth", subdomainDepth);
        if (subdomainDepth > 3) totalScore += 1.0;
        checks++;

        // Calculate Shannon entropy (randomized URLs score higher)
        double entropy = calculateEntropy(url);
        details.put("entropy", entropy);
        if (entropy > 4.0) totalScore += 1.0;
        checks++;

        // Search for phishing-related keywords
        long keywordCount = SUSPICIOUS_KEYWORDS.stream()
            .filter(lowerUrl::contains)
            .count();
        details.put("suspiciousKeywordCount", keywordCount);
        if (keywordCount > 0) totalScore += 1.0;
        checks++;

        // Normalize to 0.0 - 1.0 range
        double normalizedScore = checks > 0
            ? totalScore / checks : 0.0;

        return new AnalysisResult("lexical", normalizedScore, details);
    }

    // Counts dots in the host to estimate subdomain nesting
    private int getSubdomainDepth(String url) {
        try {
            String host = URI.create(url).getHost();
            if (host == null) return 0;
            return (int) host.chars().filter(c -> c == '.').count();
        } catch (Exception e) {
            return 0;
        }
    }

    // Computes Shannon entropy to detect randomized/obfuscated URLs
    private double calculateEntropy(String text) {
        Map<Character, Integer> freq = new HashMap<>();
        for (char c : text.toCharArray()) {
            freq.merge(c, 1, Integer::sum);
        }
        double entropy = 0.0;
        for (int count : freq.values()) {
            double p = (double) count / text.length();
            entropy -= p * (Math.log(p) / Math.log(2));
        }
        return entropy;
    }
}
package com.nextwork.phishingservice.strategy;

import org.springframework.stereotype.Component;
import java.util.*;

@Component
public class ContentAnalysisStrategy implements DetectionStrategy {

    // Known URL shortener domains
    private static final Set<String> SHORTENER_DOMAINS =
        Set.of("bit.ly", "tinyurl.com", "t.co", "goo.gl", "ow.ly", "is.gd");

    @Override
    public AnalysisResult analyze(String url) {
        Map<String, Object> details = new LinkedHashMap<>();
        double totalScore = 0.0;
        int checks = 0;

        // HTTP-only URLs are more likely to be phishing
        boolean usesHttps = url.toLowerCase().startsWith("https://");
        details.put("usesHttps", usesHttps);
        if (!usesHttps) totalScore += 1.0;
        checks++;

        // Count percent-encoded sequences (%XX) that hide characters
        long encodedCharCount = countEncodedChars(url);
        details.put("encodedCharCount", encodedCharCount);
        if (encodedCharCount > 3) totalScore += 1.0;
        checks++;

        // Check if URL uses a known shortener to mask its destination
        String lowerUrl = url.toLowerCase();
        boolean isShortener = SHORTENER_DOMAINS.stream()
            .anyMatch(lowerUrl::contains);
        details.put("urlShortener", isShortener);
        if (isShortener) totalScore += 1.0;
        checks++;

        // Look for redirect chain indicators in query parameters
        boolean hasRedirectIndicators = lowerUrl.contains("redirect")
            || lowerUrl.contains("url=")
            || lowerUrl.contains("next=")
            || lowerUrl.contains("goto=");
        details.put("redirectIndicators", hasRedirectIndicators);
        if (hasRedirectIndicators) totalScore += 1.0;
        checks++;

        // Normalize to 0.0 - 1.0 range
        double normalizedScore = checks > 0
            ? totalScore / checks : 0.0;

        return new AnalysisResult("content", normalizedScore, details);
    }

    // Counts percent-encoded sequences like %20, %3A, etc.
    private long countEncodedChars(String url) {
        long count = 0;
        for (int i = 0; i < url.length() - 2; i++) {
            if (url.charAt(i) == '%'
                && Character.isLetterOrDigit(url.charAt(i + 1))
                && Character.isLetterOrDigit(url.charAt(i + 2))) {
                count++;
            }
        }
        return count;
    }
}
Implement the Adapter pattern for external threat feeds

Your detection engine needs to check URLs against reputation databases. One source is the local blacklist table in PostgreSQL. The other is URLhaus, a free threat intelligence feed that tracks malware distribution URLs.

The Adapter pattern wraps both data sources behind a common ReputationProvider interface. This way the reputation strategy does not need to know whether it is talking to a local database or an external API.

Why the Adapter pattern here?

Without the Adapter, your reputation strategy would need separate code paths for the local database and the URLhaus HTTP API. If you ever add a third source (say, Google Safe Browsing), you would have to modify the strategy class itself.

The Adapter pattern isolates that integration complexity. Each adapter handles its own data source and returns the same ReputationResult object. Adding a new source means writing a new adapter class, with zero changes to the strategy.

Add the WebFlux dependency and URLhaus auth key

The URLhaus adapter will use Spring WebFlux's WebClient to make HTTP calls. You also need to store the URLhaus auth key in your configuration.

  • Open pom.xml in the phishing-service and add this dependency alongside your existing dependencies:
<!-- WebClient for making HTTP calls to external APIs -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

What does this dependency do?

spring-boot-starter-webflux provides WebClient, Spring's modern, non-blocking HTTP client. Your UrlhausAdapter will use it to POST URLs to the URLhaus API and parse the JSON response.

  • Get your free URLhaus auth key by signing in at auth.abuse.ch using your GitHub, Google, LinkedIn, or X account.
  • Once signed in, copy your Auth-Key from the URLhaus API page.
  • Open application.yml in the phishing-service. Add the following block at the bottom of the file:
# URLhaus threat intelligence API key
urlhaus:
  auth-key: your-urlhaus-auth-key-here
  • Replace your-urlhaus-auth-key-here with the actual auth key you copied from the URLhaus API page.
  • Save both pom.xml and application.yml.

Keep your auth key private

Do not commit your application.yml with a real auth key to a public repository. If you push to GitHub later, add application.yml to your .gitignore or use environment variables instead.

Maven not pulling the new dependency?

After saving pom.xml, your IDE should prompt you to reimport Maven dependencies. If it does not, right-click on pom.xml and select **Maven**, then **Reload Project**.

Help me resolve a Maven dependency issue with spring-boot-starter-webflux in my phishing-service.

Create the ReputationProvider interface and ReputationResult class
  • Create a new package called adapter alongside your strategy and entity packages in the phishing-service.
  • Create ReputationProvider.java in the adapter package and paste this code:
package com.nextwork.phishingservice.adapter;

// Common interface for all reputation data sources
public interface ReputationProvider {
    ReputationResult checkUrl(String url);
}
  • Create ReputationResult.java in the same adapter package and paste this code:
package com.nextwork.phishingservice.adapter;

// Holds the outcome of a single reputation lookup
public class ReputationResult {

    // Whether the URL was found on a blacklist
    private boolean isBlacklisted;

    // The type of threat identified, or "none"
    private String threat;

    // Which data source produced this result
    private String source;

    public ReputationResult(boolean isBlacklisted, String threat,
                            String source) {
        this.isBlacklisted = isBlacklisted;
        this.threat = threat;
        this.source = source;
    }

    public boolean isBlacklisted() {
        return isBlacklisted;
    }

    public String getThreat() {
        return threat;
    }

    public String getSource() {
        return source;
    }
}

What does this code do?

ReputationProvider is the Adapter pattern's common interface. Every reputation data source (local database, URLhaus, or any future source you add) will implement this single checkUrl() method.

ReputationResult carries three fields: whether the URL is blacklisted, the threat type (like "malware_download"), and which source produced the result ("local" or "urlhaus").

  • Save both files.

You won't see a change yet. The next two chunks build the concrete adapters that implement this interface.

Build the LocalBlacklistAdapter

This adapter checks URLs against the local blacklist table in your phishing_db database. It reuses the BlacklistRepository you created in the previous step.

  • Create LocalBlacklistAdapter.java in the adapter package and paste this code:
package com.nextwork.phishingservice.adapter;

import com.nextwork.phishingservice.repository.BlacklistRepository;
import org.springframework.stereotype.Component;
import java.net.URI;

@Component
public class LocalBlacklistAdapter implements ReputationProvider {

    private final BlacklistRepository blacklistRepository;

    public LocalBlacklistAdapter(BlacklistRepository blacklistRepository) {
        this.blacklistRepository = blacklistRepository;
    }

    @Override
    public ReputationResult checkUrl(String url) {
        // Extract the domain from the full URL
        String domain = extractDomain(url);

        // Query the local blacklist table in PostgreSQL
        boolean isBlacklisted = blacklistRepository.existsByDomain(domain);

        return new ReputationResult(
            isBlacklisted,
            isBlacklisted ? "local_blacklist" : "none",
            "local"
        );
    }

    // Pulls the host portion from a URL string
    private String extractDomain(String url) {
        try {
            return URI.create(url).getHost();
        } catch (Exception e) {
            return url;
        }
    }
}

What does this code do?

This adapter extracts the domain from the incoming URL and calls BlacklistRepository.existsByDomain() to check if it exists in the local blacklist table. Spring Data JPA auto-implements that query method from the method name you defined in the previous step.

  • Save LocalBlacklistAdapter.java.

You won't see a change yet. The next chunk builds the URLhaus adapter that queries the external threat feed.

Build the UrlhausAdapter

This adapter wraps the URLhaus threat intelligence API. It sends a URL to the URLhaus endpoint and parses the response to determine if the URL has been flagged as malicious.

  • Create UrlhausAdapter.java in the adapter package and paste this code:
package com.nextwork.phishingservice.adapter;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.Map;

@Component
public class UrlhausAdapter implements ReputationProvider {

    private final WebClient webClient;

    // Auth key loaded from application.yml
    @Value("${urlhaus.auth-key}")
    private String authKey;

    public UrlhausAdapter(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder
            .baseUrl("https://urlhaus-api.abuse.ch/v1")
            .build();
    }

    @Override
    public ReputationResult checkUrl(String url) {
        try {
            // POST the URL to the URLhaus query endpoint
            Map<String, Object> response = webClient.post()
                .uri("/url/")
                .header("Auth-Key", authKey)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .bodyValue("url=" + url)
                .retrieve()
                .bodyToMono(Map.class)
                .block();

            if (response == null) {
                return new ReputationResult(false, "none", "urlhaus");
            }

            // Parse the key fields from the JSON response
            String queryStatus = (String) response
                .getOrDefault("query_status", "no_results");
            String urlStatus = (String) response
                .getOrDefault("url_status", "unknown");
            String threat = (String) response
                .getOrDefault("threat", "none");

            // A known entry means URLhaus has flagged this URL
            boolean isBlacklisted = "ok".equals(queryStatus)
                && !"unknown".equals(urlStatus);

            return new ReputationResult(isBlacklisted, threat, "urlhaus");

        } catch (Exception e) {
            // Fail open so a network error does not block the scan
            return new ReputationResult(
                false, "error: " + e.getMessage(), "urlhaus"
            );
        }
    }
}

What does this code do?

The adapter sends a POST request to https://urlhaus-api.abuse.ch/v1/url/ with the URL as a form parameter and the Auth-Key header. The URLhaus API returns fields like query_status, url_status, and threat.

The try/catch block is important. If URLhaus is down or the network fails, the adapter returns a safe-by-default result instead of crashing the entire scan. This "fail open" strategy means a network error never blocks the rest of the detection engine.

  • Save UrlhausAdapter.java.

Cannot resolve WebClient or bodyToMono?

Double check that you added spring-boot-starter-webflux to your phishing-service pom.xml and reimported Maven. The WebClient class and reactive types come from this dependency.

Help me fix WebClient import errors in my UrlhausAdapter.

Build the ReputationCheckStrategy

With both adapters in place, you can now build the third and final detection strategy. This strategy calls both adapters and flags the URL if either source reports a match.

  • Create ReputationCheckStrategy.java in the strategy package (not the adapter package) and paste this code:
package com.nextwork.phishingservice.strategy;

import com.nextwork.phishingservice.adapter.LocalBlacklistAdapter;
import com.nextwork.phishingservice.adapter.ReputationResult;
import com.nextwork.phishingservice.adapter.UrlhausAdapter;
import org.springframework.stereotype.Component;
import java.util.*;

@Component
public class ReputationCheckStrategy implements DetectionStrategy {

    private final LocalBlacklistAdapter localBlacklistAdapter;
    private final UrlhausAdapter urlhausAdapter;

    public ReputationCheckStrategy(
            LocalBlacklistAdapter localBlacklistAdapter,
            UrlhausAdapter urlhausAdapter) {
        this.localBlacklistAdapter = localBlacklistAdapter;
        this.urlhausAdapter = urlhausAdapter;
    }

    @Override
    public AnalysisResult analyze(String url) {
        Map<String, Object> details = new LinkedHashMap<>();

        // Query the local blacklist database
        ReputationResult localResult =
            localBlacklistAdapter.checkUrl(url);
        details.put("localBlacklisted", localResult.isBlacklisted());
        details.put("localThreat", localResult.getThreat());

        // Query the URLhaus threat intelligence feed
        ReputationResult urlhausResult =
            urlhausAdapter.checkUrl(url);
        details.put("urlhausBlacklisted", urlhausResult.isBlacklisted());
        details.put("urlhausThreat", urlhausResult.getThreat());

        // Score 1.0 if either source flags the URL
        boolean isBlacklisted =
            localResult.isBlacklisted() || urlhausResult.isBlacklisted();
        double normalizedScore = isBlacklisted ? 1.0 : 0.0;

        return new AnalysisResult("reputation", normalizedScore, details);
    }
}

What does this code do?

This strategy calls both adapters and combines their results. If either the local blacklist or URLhaus flags the URL, the normalized score is 1.0 (maximum danger). If neither flags it, the score is 0.0.

The detailed findings map captures results from both sources independently. This gives the final scan report visibility into exactly which reputation source triggered the flag.

  • Save ReputationCheckStrategy.java.

You won't see a change yet. The next substep builds the Factory that ties all three strategies together.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files in the adapter package and the ReputationCheckStrategy in the strategy package.

ⓧ I'd like to double check the full code

Here are all the adapter and reputation strategy files for reference.

package com.nextwork.phishingservice.adapter;

// Common interface for all reputation data sources
public interface ReputationProvider {
    ReputationResult checkUrl(String url);
}
package com.nextwork.phishingservice.adapter;

// Holds the outcome of a single reputation lookup
public class ReputationResult {

    // Whether the URL was found on a blacklist
    private boolean isBlacklisted;

    // The type of threat identified, or "none"
    private String threat;

    // Which data source produced this result
    private String source;

    public ReputationResult(boolean isBlacklisted, String threat,
                            String source) {
        this.isBlacklisted = isBlacklisted;
        this.threat = threat;
        this.source = source;
    }

    public boolean isBlacklisted() {
        return isBlacklisted;
    }

    public String getThreat() {
        return threat;
    }

    public String getSource() {
        return source;
    }
}
package com.nextwork.phishingservice.adapter;

import com.nextwork.phishingservice.repository.BlacklistRepository;
import org.springframework.stereotype.Component;
import java.net.URI;

@Component
public class LocalBlacklistAdapter implements ReputationProvider {

    private final BlacklistRepository blacklistRepository;

    public LocalBlacklistAdapter(BlacklistRepository blacklistRepository) {
        this.blacklistRepository = blacklistRepository;
    }

    @Override
    public ReputationResult checkUrl(String url) {
        // Extract the domain from the full URL
        String domain = extractDomain(url);

        // Query the local blacklist table in PostgreSQL
        boolean isBlacklisted = blacklistRepository.existsByDomain(domain);

        return new ReputationResult(
            isBlacklisted,
            isBlacklisted ? "local_blacklist" : "none",
            "local"
        );
    }

    // Pulls the host portion from a URL string
    private String extractDomain(String url) {
        try {
            return URI.create(url).getHost();
        } catch (Exception e) {
            return url;
        }
    }
}
package com.nextwork.phishingservice.adapter;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.util.Map;

@Component
public class UrlhausAdapter implements ReputationProvider {

    private final WebClient webClient;

    // Auth key loaded from application.yml
    @Value("${urlhaus.auth-key}")
    private String authKey;

    public UrlhausAdapter(WebClient.Builder webClientBuilder) {
        this.webClient = webClientBuilder
            .baseUrl("https://urlhaus-api.abuse.ch/v1")
            .build();
    }

    @Override
    public ReputationResult checkUrl(String url) {
        try {
            // POST the URL to the URLhaus query endpoint
            Map<String, Object> response = webClient.post()
                .uri("/url/")
                .header("Auth-Key", authKey)
                .contentType(MediaType.APPLICATION_FORM_URLENCODED)
                .bodyValue("url=" + url)
                .retrieve()
                .bodyToMono(Map.class)
                .block();

            if (response == null) {
                return new ReputationResult(false, "none", "urlhaus");
            }

            // Parse the key fields from the JSON response
            String queryStatus = (String) response
                .getOrDefault("query_status", "no_results");
            String urlStatus = (String) response
                .getOrDefault("url_status", "unknown");
            String threat = (String) response
                .getOrDefault("threat", "none");

            // A known entry means URLhaus has flagged this URL
            boolean isBlacklisted = "ok".equals(queryStatus)
                && !"unknown".equals(urlStatus);

            return new ReputationResult(isBlacklisted, threat, "urlhaus");

        } catch (Exception e) {
            // Fail open so a network error does not block the scan
            return new ReputationResult(
                false, "error: " + e.getMessage(), "urlhaus"
            );
        }
    }
}
package com.nextwork.phishingservice.strategy;

import com.nextwork.phishingservice.adapter.LocalBlacklistAdapter;
import com.nextwork.phishingservice.adapter.ReputationResult;
import com.nextwork.phishingservice.adapter.UrlhausAdapter;
import org.springframework.stereotype.Component;
import java.util.*;

@Component
public class ReputationCheckStrategy implements DetectionStrategy {

    private final LocalBlacklistAdapter localBlacklistAdapter;
    private final UrlhausAdapter urlhausAdapter;

    public ReputationCheckStrategy(
            LocalBlacklistAdapter localBlacklistAdapter,
            UrlhausAdapter urlhausAdapter) {
        this.localBlacklistAdapter = localBlacklistAdapter;
        this.urlhausAdapter = urlhausAdapter;
    }

    @Override
    public AnalysisResult analyze(String url) {
        Map<String, Object> details = new LinkedHashMap<>();

        // Query the local blacklist database
        ReputationResult localResult =
            localBlacklistAdapter.checkUrl(url);
        details.put("localBlacklisted", localResult.isBlacklisted());
        details.put("localThreat", localResult.getThreat());

        // Query the URLhaus threat intelligence feed
        ReputationResult urlhausResult =
            urlhausAdapter.checkUrl(url);
        details.put("urlhausBlacklisted", urlhausResult.isBlacklisted());
        details.put("urlhausThreat", urlhausResult.getThreat());

        // Score 1.0 if either source flags the URL
        boolean isBlacklisted =
            localResult.isBlacklisted() || urlhausResult.isBlacklisted();
        double normalizedScore = isBlacklisted ? 1.0 : 0.0;

        return new AnalysisResult("reputation", normalizedScore, details);
    }
}
Build the DetectionHandlerFactory

The Factory pattern decides which strategies to run for a given URL. Every URL gets lexical and content analysis. The reputation check only runs when the URL's domain is not in the whitelist.

  • Create a new package called factory alongside your existing packages in the phishing-service.
  • Create DetectionHandlerFactory.java in the factory package and paste this code:
package com.nextwork.phishingservice.factory;

import com.nextwork.phishingservice.repository.WhitelistRepository;
import com.nextwork.phishingservice.strategy.*;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;

@Component
public class DetectionHandlerFactory {

    private final LexicalAnalysisStrategy lexicalAnalysisStrategy;
    private final ContentAnalysisStrategy contentAnalysisStrategy;
    private final ReputationCheckStrategy reputationCheckStrategy;
    private final WhitelistRepository whitelistRepository;

    public DetectionHandlerFactory(
            LexicalAnalysisStrategy lexicalAnalysisStrategy,
            ContentAnalysisStrategy contentAnalysisStrategy,
            ReputationCheckStrategy reputationCheckStrategy,
            WhitelistRepository whitelistRepository) {
        this.lexicalAnalysisStrategy = lexicalAnalysisStrategy;
        this.contentAnalysisStrategy = contentAnalysisStrategy;
        this.reputationCheckStrategy = reputationCheckStrategy;
        this.whitelistRepository = whitelistRepository;
    }

    // Decides which strategies apply to a given URL
    public List<DetectionStrategy> createStrategies(String url) {
        List<DetectionStrategy> strategies = new ArrayList<>();

        // Every URL gets lexical and content analysis
        strategies.add(lexicalAnalysisStrategy);
        strategies.add(contentAnalysisStrategy);

        // Add reputation check only if the domain is not whitelisted
        String domain = extractDomain(url);
        if (!whitelistRepository.existsByDomain(domain)) {
            strategies.add(reputationCheckStrategy);
        }

        return strategies;
    }

    // Extracts the host from a URL string
    private String extractDomain(String url) {
        try {
            return URI.create(url).getHost();
        } catch (Exception e) {
            return url;
        }
    }
}

What does this code do?

The factory's createStrategies() method builds the list of detection algorithms that should run for a specific URL. Lexical and content analysis always run. The reputation check is skipped for whitelisted domains to avoid unnecessary API calls and false positives.

Spring injects all four dependencies (the three strategy beans and the WhitelistRepository) through the constructor. The factory does not create new strategy instances. It reuses the singleton beans that Spring manages.

  • Save DetectionHandlerFactory.java.
  • Run the phishing-service to verify all classes compile and Spring starts successfully. You should see Started PhishingServiceApplication in the console output with no bean creation errors.

Seeing a bean creation error on startup?

Check that every class annotated with @Component lives inside a package that Spring Boot's component scan covers. All packages should be sub-packages of your main application class's package.

If you see a Could not resolve placeholder 'urlhaus.auth-key' error, double check that the urlhaus.auth-key property exists in your application.yml and has a value set.

Help me fix a bean creation error in my phishing-service after adding the detection engine classes.

✔️ Awesome, I've got everything!

Great. Make sure you have saved all files and the phishing-service starts cleanly.

ⓧ I'd like to double check the full code

Here is the factory file for reference.

package com.nextwork.phishingservice.factory;

import com.nextwork.phishingservice.repository.WhitelistRepository;
import com.nextwork.phishingservice.strategy.*;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;

@Component
public class DetectionHandlerFactory {

    private final LexicalAnalysisStrategy lexicalAnalysisStrategy;
    private final ContentAnalysisStrategy contentAnalysisStrategy;
    private final ReputationCheckStrategy reputationCheckStrategy;
    private final WhitelistRepository whitelistRepository;

    public DetectionHandlerFactory(
            LexicalAnalysisStrategy lexicalAnalysisStrategy,
            ContentAnalysisStrategy contentAnalysisStrategy,
            ReputationCheckStrategy reputationCheckStrategy,
            WhitelistRepository whitelistRepository) {
        this.lexicalAnalysisStrategy = lexicalAnalysisStrategy;
        this.contentAnalysisStrategy = contentAnalysisStrategy;
        this.reputationCheckStrategy = reputationCheckStrategy;
        this.whitelistRepository = whitelistRepository;
    }

    // Decides which strategies apply to a given URL
    public List<DetectionStrategy> createStrategies(String url) {
        List<DetectionStrategy> strategies = new ArrayList<>();

        // Every URL gets lexical and content analysis
        strategies.add(lexicalAnalysisStrategy);
        strategies.add(contentAnalysisStrategy);

        // Add reputation check only if the domain is not whitelisted
        String domain = extractDomain(url);
        if (!whitelistRepository.existsByDomain(domain)) {
            strategies.add(reputationCheckStrategy);
        }

        return strategies;
    }

    // Extracts the host from a URL string
    private String extractDomain(String url) {
        try {
            return URI.create(url).getHost();
        } catch (Exception e) {
            return url;
        }
    }
}

Your detection engine now has three strategies, two adapters, and a factory that orchestrates them. Next up, you will build the weighted risk scoring engine that takes the raw strategy results and computes a final 0-100 risk score.

Build the Weighted Risk Scoring Engine

Your detection strategies each know how to analyze one dimension of a URL. Lexical analysis catches suspicious character patterns. Content analysis flags missing HTTPS. Reputation checking queries threat feeds. But right now, each strategy works in isolation.

In this step, you will build the engine that combines all those signals into a single weighted risk score. You will also create the orchestration layer that runs every strategy, computes the score, persists the result to PostgreSQL, and exposes a REST endpoint so clients can submit URLs for scanning.

In this step, get ready to:
  • Create a weighted scoring service that turns strategy outputs into a 0-100 risk score.
  • Build an orchestration service that runs the full detection pipeline and persists scan results.
  • Wire up a POST endpoint so clients can submit URLs for scanning.
Add scoring weights to your application properties

The scoring engine needs configurable weights so you can tune how much each detection strategy contributes to the final score. Storing these as application properties means you can adjust them without recompiling code.

  • Open application.yml inside your phishing-service/src/main/resources/ folder.
  • Add the following scoring weight configuration at the bottom of the file:
# Configurable weights for the risk scoring formula
scoring:
  weights:
    lexical: 30
    reputation: 40
    content: 20
    blacklist-hit: 10

What do these weights mean?

Each weight controls what percentage of the final score comes from that strategy. Reputation gets the highest weight (40) because a known-malicious URL in a threat feed is the strongest signal.

Lexical analysis gets 30 because URL structure is a reliable indicator. Content analysis gets 20. The blacklist-hit bonus adds 10 extra points if the URL appears in a local blacklist. All weights sum to 100.

  • Save application.yml.
Create the risk scoring service

The RiskScoringService is the core of your scoring engine. It reads the weights you just configured, multiplies each strategy's normalized score by its weight, and maps the total to a risk level.

  • Create a new service package inside your phishing-service's main Java source folder.
  • Create a new file called RiskScoringService.java inside the service package and add the following code:
package com.example.phishingservice.service;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.strategy.AnalysisResult;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class RiskScoringService {

    // Inject configurable weights from application.yml
    @Value("${scoring.weights.lexical}")
    private int lexicalWeight;

    @Value("${scoring.weights.reputation}")
    private int reputationWeight;

    @Value("${scoring.weights.content}")
    private int contentWeight;

    @Value("${scoring.weights.blacklist-hit}")
    private int blacklistHitWeight;

    // Calculate weighted risk score from all strategy results
    public int computeScore(List<AnalysisResult> results, boolean blacklistHit) {
        double lexicalScore = 0.0;
        double reputationScore = 0.0;
        double contentScore = 0.0;

        // Extract each strategy's normalized score by name
        for (AnalysisResult result : results) {
            switch (result.getStrategyName()) {
                case "lexical" -> lexicalScore = result.getNormalizedScore();
                case "reputation" -> reputationScore = result.getNormalizedScore();
                case "content" -> contentScore = result.getNormalizedScore();
            }
        }

        // Apply the weighted formula
        double weightedSum = (lexicalScore * lexicalWeight)
                + (reputationScore * reputationWeight)
                + (contentScore * contentWeight)
                + ((blacklistHit ? 1.0 : 0.0) * blacklistHitWeight);

        // Clamp the result between 0 and 100
        return (int) Math.max(0, Math.min(100, weightedSum));
    }

    // Map a numeric score to a risk level category
    public RiskLevel determineRiskLevel(int score) {
        if (score <= 24) return RiskLevel.SAFE;
        if (score <= 59) return RiskLevel.SUSPICIOUS;
        if (score <= 84) return RiskLevel.HIGH;
        return RiskLevel.CRITICAL;
    }
}

What does this code do?

  • The four @Value fields pull the weights from application.yml at startup. You can change a weight and restart without touching Java code.
  • computeScore() loops through each strategy's result, matches it by name, and applies the weighted formula: (lexical * 30) + (reputation * 40) + (content * 20) + (blacklist * 10).
  • determineRiskLevel() converts the numeric score into a human-readable category. Scores 0-24 are SAFE, 25-59 are SUSPICIOUS, 60-84 are HIGH, and 85-100 are CRITICAL.
  • Save RiskScoringService.java.

Seeing an unresolved import error?

  • Make sure your AnalysisResult class has both getStrategyName() and getNormalizedScore() getter methods. This class was created in the previous step.
  • Confirm the package declaration matches your project structure. If your base package is different from com.example.phishingservice, update the imports accordingly.
  • Help me fix import errors in my RiskScoringService

✔️ Awesome, I've got everything!

Make sure you have saved both application.yml and RiskScoringService.java.

ⓧ I'd like to double check the full code

Here is the complete RiskScoringService.java:

package com.example.phishingservice.service;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.strategy.AnalysisResult;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class RiskScoringService {

    @Value("${scoring.weights.lexical}")
    private int lexicalWeight;

    @Value("${scoring.weights.reputation}")
    private int reputationWeight;

    @Value("${scoring.weights.content}")
    private int contentWeight;

    @Value("${scoring.weights.blacklist-hit}")
    private int blacklistHitWeight;

    public int computeScore(List<AnalysisResult> results, boolean blacklistHit) {
        double lexicalScore = 0.0;
        double reputationScore = 0.0;
        double contentScore = 0.0;

        for (AnalysisResult result : results) {
            switch (result.getStrategyName()) {
                case "lexical" -> lexicalScore = result.getNormalizedScore();
                case "reputation" -> reputationScore = result.getNormalizedScore();
                case "content" -> contentScore = result.getNormalizedScore();
            }
        }

        double weightedSum = (lexicalScore * lexicalWeight)
                + (reputationScore * reputationWeight)
                + (contentScore * contentWeight)
                + ((blacklistHit ? 1.0 : 0.0) * blacklistHitWeight);

        return (int) Math.max(0, Math.min(100, weightedSum));
    }

    public RiskLevel determineRiskLevel(int score) {
        if (score <= 24) return RiskLevel.SAFE;
        if (score <= 59) return RiskLevel.SUSPICIOUS;
        if (score <= 84) return RiskLevel.HIGH;
        return RiskLevel.CRITICAL;
    }
}

Here is the scoring section to add at the bottom of your application.yml:

scoring:
  weights:
    lexical: 30
    reputation: 40
    content: 20
    blacklist-hit: 10
Orchestrate the detection pipeline

The scoring service knows how to crunch numbers, but something needs to drive the full pipeline. The PhishingDetectionService is that orchestrator. It takes a URL, asks the factory for the right strategies, runs each one, feeds the results to the scorer, and persists the final ScanResult to the database.

  • Create a new file called PhishingDetectionService.java inside the same service package and add the following code:
package com.example.phishingservice.service;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.entity.ScanResult;
import com.example.phishingservice.factory.DetectionHandlerFactory;
import com.example.phishingservice.repository.ScanResultRepository;
import com.example.phishingservice.strategy.AnalysisResult;
import com.example.phishingservice.strategy.DetectionStrategy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@Service
public class PhishingDetectionService {

    private final DetectionHandlerFactory factory;
    private final RiskScoringService riskScoringService;
    private final ScanResultRepository scanResultRepository;
    private final ObjectMapper objectMapper;

    // Constructor injection for all dependencies
    public PhishingDetectionService(DetectionHandlerFactory factory,
                                     RiskScoringService riskScoringService,
                                     ScanResultRepository scanResultRepository,
                                     ObjectMapper objectMapper) {
        this.factory = factory;
        this.riskScoringService = riskScoringService;
        this.scanResultRepository = scanResultRepository;
        this.objectMapper = objectMapper;
    }

    // Run the full detection pipeline for a URL
    public ScanResult scanUrl(String url, String userId) {
        // Step 1: Get the appropriate strategies from the factory
        List<DetectionStrategy> strategies = factory.createStrategies(url);

        // Step 2: Execute each strategy and collect results
        List<AnalysisResult> results = new ArrayList<>();
        boolean blacklistHit = false;
        for (DetectionStrategy strategy : strategies) {
            AnalysisResult result = strategy.analyze(url);
            results.add(result);

            // Check if the reputation strategy flagged a blacklist hit
            if ("reputation".equals(result.getStrategyName())
                    && result.getNormalizedScore() >= 1.0) {
                blacklistHit = true;
            }
        }

        // Step 3: Compute the weighted risk score
        int riskScore = riskScoringService.computeScore(results, blacklistHit);
        RiskLevel riskLevel = riskScoringService.determineRiskLevel(riskScore);

        // Step 4: Serialize individual strategy scores to JSON for storage
        String details = serializeDetails(results);

        // Step 5: Build and persist the scan result entity
        ScanResult scanResult = new ScanResult();
        scanResult.setId(UUID.randomUUID());
        scanResult.setUrl(url);
        scanResult.setRiskScore(riskScore);
        scanResult.setRiskLevel(riskLevel);
        scanResult.setDetails(details);
        scanResult.setUserId(userId);
        scanResult.setCreatedAt(LocalDateTime.now());

        return scanResultRepository.save(scanResult);
    }

    // Convert analysis results into a JSON string for the details column
    private String serializeDetails(List<AnalysisResult> results) {
        try {
            Map<String, Object> detailsMap = new LinkedHashMap<>();
            for (AnalysisResult result : results) {
                Map<String, Object> entry = new LinkedHashMap<>();
                entry.put("score", result.getNormalizedScore());
                entry.put("details", result.getDetails());
                detailsMap.put(result.getStrategyName(), entry);
            }
            return objectMapper.writeValueAsString(detailsMap);
        } catch (Exception e) {
            return "{}";
        }
    }
}

What does this code do?

  • scanUrl() is the single entry point for the entire detection pipeline. It coordinates five steps: get strategies, execute them, score the results, serialize details, and persist to the database.
  • The blacklistHit flag triggers when the reputation strategy returns a perfect 1.0 score. This means the URL was found in a threat feed or local blacklist, adding the extra 10-point blacklist weight to the score.
  • serializeDetails() uses Jackson's ObjectMapper to convert individual strategy scores into a JSON string. This gets stored in the details column so the frontend can later show a breakdown of how each strategy scored.
  • Save PhishingDetectionService.java.

You will not see a visible change yet. The next substep creates the REST endpoint that calls this service.

Seeing a red underline on ObjectMapper?

  • ObjectMapper comes from the com.fasterxml.jackson.databind package. It is included automatically by spring-boot-starter-web. If the import fails, confirm that dependency is in your pom.xml.
  • Spring Boot auto-registers an ObjectMapper bean, so constructor injection picks it up automatically. You do not need to create one yourself.
  • Help me resolve ObjectMapper import issues in my PhishingDetectionService

✔️ Awesome, I've got everything!

Make sure you have saved PhishingDetectionService.java.

ⓧ I'd like to double check the full code

Here is the complete PhishingDetectionService.java:

package com.example.phishingservice.service;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.entity.ScanResult;
import com.example.phishingservice.factory.DetectionHandlerFactory;
import com.example.phishingservice.repository.ScanResultRepository;
import com.example.phishingservice.strategy.AnalysisResult;
import com.example.phishingservice.strategy.DetectionStrategy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@Service
public class PhishingDetectionService {

    private final DetectionHandlerFactory factory;
    private final RiskScoringService riskScoringService;
    private final ScanResultRepository scanResultRepository;
    private final ObjectMapper objectMapper;

    public PhishingDetectionService(DetectionHandlerFactory factory,
                                     RiskScoringService riskScoringService,
                                     ScanResultRepository scanResultRepository,
                                     ObjectMapper objectMapper) {
        this.factory = factory;
        this.riskScoringService = riskScoringService;
        this.scanResultRepository = scanResultRepository;
        this.objectMapper = objectMapper;
    }

    public ScanResult scanUrl(String url, String userId) {
        List<DetectionStrategy> strategies = factory.createStrategies(url);

        List<AnalysisResult> results = new ArrayList<>();
        boolean blacklistHit = false;
        for (DetectionStrategy strategy : strategies) {
            AnalysisResult result = strategy.analyze(url);
            results.add(result);

            if ("reputation".equals(result.getStrategyName())
                    && result.getNormalizedScore() >= 1.0) {
                blacklistHit = true;
            }
        }

        int riskScore = riskScoringService.computeScore(results, blacklistHit);
        RiskLevel riskLevel = riskScoringService.determineRiskLevel(riskScore);

        String details = serializeDetails(results);

        ScanResult scanResult = new ScanResult();
        scanResult.setId(UUID.randomUUID());
        scanResult.setUrl(url);
        scanResult.setRiskScore(riskScore);
        scanResult.setRiskLevel(riskLevel);
        scanResult.setDetails(details);
        scanResult.setUserId(userId);
        scanResult.setCreatedAt(LocalDateTime.now());

        return scanResultRepository.save(scanResult);
    }

    private String serializeDetails(List<AnalysisResult> results) {
        try {
            Map<String, Object> detailsMap = new LinkedHashMap<>();
            for (AnalysisResult result : results) {
                Map<String, Object> entry = new LinkedHashMap<>();
                entry.put("score", result.getNormalizedScore());
                entry.put("details", result.getDetails());
                detailsMap.put(result.getStrategyName(), entry);
            }
            return objectMapper.writeValueAsString(detailsMap);
        } catch (Exception e) {
            return "{}";
        }
    }
}
Create the scan endpoint and test the flow

The scoring engine and orchestration service are ready, but there is no way for a client to reach them. You need a REST controller with a POST /api/detect endpoint that accepts a URL and returns the full scan result.

  • Create a controller package inside your phishing-service's main Java source folder.
  • Create a new file called PhishingController.java inside the controller package and add the following code:
package com.example.phishingservice.controller;

import com.example.phishingservice.entity.ScanResult;
import com.example.phishingservice.service.PhishingDetectionService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class PhishingController {

    private final PhishingDetectionService phishingDetectionService;

    public PhishingController(PhishingDetectionService phishingDetectionService) {
        this.phishingDetectionService = phishingDetectionService;
    }

    // Accept a URL scan request and return the risk assessment
    @PostMapping("/detect")
    public ResponseEntity<ScanResult> detectPhishing(
            @RequestBody Map<String, String> request) {
        String url = request.get("url");
        String userId = request.get("userId");
        ScanResult result = phishingDetectionService.scanUrl(url, userId);
        return ResponseEntity.ok(result);
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON responses directly.
  • @RequestMapping("/api") prefixes all endpoints in this controller with /api. Combined with @PostMapping("/detect"), the full path becomes POST /api/detect.
  • The request body is a simple JSON map with url and userId fields. In a later step, you will extract the userId from the JWT token in the security context instead of the request body.

Why accept userId in the request body for now?

The phishing-service does not have its own JWT validation filter yet. That gets added in Step 6 when you secure the Spring Data REST endpoints. For now, passing userId in the request body lets you test the full pipeline without needing authentication wired up on this service.

  • Save PhishingController.java.

Your phishing-service has spring-boot-starter-security on the classpath, which locks down all endpoints by default. You need a temporary security configuration so the /api/detect endpoint is reachable for testing.

  • If you do not already have a SecurityConfig class in your phishing-service, create one that permits all requests to /api/**. This is temporary. You will lock it down with JWT validation in Step 6.

Now test the full detection pipeline. Make sure your PostgreSQL server, Eureka server, and phishing-service are all running.

  • Start (or restart) your phishing-service. Confirm it starts without errors in the console output.
  • Test the scan endpoint by running this command in a new terminal window:
curl -X POST http://localhost:8082/api/detect \
  -H "Content-Type: application/json" \
  -d '{"url": "http://login-secure-update.xyz/account/verify", "userId": "testuser"}'

What should I see?

You should see a JSON response containing the full scan result. It includes the URL, a numeric riskScore between 0 and 100, a riskLevel (likely SUSPICIOUS or HIGH for this test URL), and a details field with the individual strategy scores in JSON format.

The test URL http://login-secure-update.xyz/account/verify contains several phishing indicators: suspicious keywords (login, secure, update, verify, account), a suspicious TLD (.xyz), and no HTTPS.

  • Try a second test with a safe URL to confirm low-risk scoring by running:
curl -X POST http://localhost:8082/api/detect \
  -H "Content-Type: application/json" \
  -d '{"url": "https://www.google.com", "userId": "testuser"}'

This URL should return a much lower risk score, likely in the SAFE range.

Getting a 403 Forbidden or 401 Unauthorized?

  • Spring Security blocks all requests by default when spring-boot-starter-security is on the classpath. Make sure you have a SecurityConfig class that permits requests to /api/**.
  • If you see a 403 specifically on POST requests, your security config may be missing .csrf(csrf -> csrf.disable()). CSRF protection blocks POST requests from non-browser clients like curl. Since this is a stateless REST API, CSRF should be disabled.
  • Help me fix 403/401 errors when calling my phishing-service endpoint

Seeing a database connection error?

  • Confirm your PostgreSQL server is running and the phishing_db database exists. You can check by running psql -U postgres -l in a terminal.
  • Check that your application.yml datasource URL, username, and password match your local PostgreSQL configuration.
  • Help me fix PostgreSQL connection errors in my Spring Boot app

✔️ Awesome, I've got everything!

Make sure you have saved PhishingController.java and confirmed the endpoint returns scan results.

ⓧ I'd like to double check the full code

Here is the complete PhishingController.java:

package com.example.phishingservice.controller;

import com.example.phishingservice.entity.ScanResult;
import com.example.phishingservice.service.PhishingDetectionService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class PhishingController {

    private final PhishingDetectionService phishingDetectionService;

    public PhishingController(PhishingDetectionService phishingDetectionService) {
        this.phishingDetectionService = phishingDetectionService;
    }

    @PostMapping("/detect")
    public ResponseEntity<ScanResult> detectPhishing(
            @RequestBody Map<String, String> request) {
        String url = request.get("url");
        String userId = request.get("userId");
        ScanResult result = phishingDetectionService.scanUrl(url, userId);
        return ResponseEntity.ok(result);
    }
}

Your detection engine is now fully operational. A client submits a URL, the system runs it through multiple strategies, computes a weighted score, and persists the result. Next up, you will expose blacklist and whitelist management endpoints using Spring Data REST so admins can manage threat lists without writing controller code.

Expose Blacklist/Whitelist Management with Spring Data REST

Your phishing detection engine can now scan URLs, compute risk scores, and persist results to PostgreSQL. But all that data is locked inside the service with no way for admins to manage blacklists or for users to view their scan history.

In this step, you will use Spring Data REST to auto-expose blacklist and whitelist CRUD endpoints directly from your JPA repositories. You will also add JWT authentication to the phishing-service and build scan history endpoints for your React dashboard.

In this step, get ready to:
  • Add Spring Data REST to auto-expose blacklist and whitelist CRUD endpoints.
  • Secure the phishing-service with JWT authentication and role-based access control.
  • Build scan history and analytics endpoints for the frontend dashboard.
Add Spring Data REST and annotate your repositories

Spring Data REST takes your existing JPA repositories and automatically generates full REST endpoints for them. Instead of writing controller classes, mapping methods, and serializing responses by hand, a single annotation gives you GET, POST, PUT, and DELETE operations with zero controller code.

  • Add the spring-boot-starter-data-rest dependency to your phishing-service pom.xml by placing the following inside the <dependencies> section:
<!-- Auto-expose JPA repositories as REST endpoints -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-rest</artifactId>
</dependency>

What does this dependency do?

spring-boot-starter-data-rest scans your project for JPA repositories annotated with @RepositoryRestResource and generates fully functional REST endpoints at startup. You get paginated collection endpoints, individual item lookups, and full CRUD operations without writing a single controller.

Spring Data REST needs to know what base URL path to serve its endpoints from. Your existing controllers already use /api, so you will configure Spring Data REST to use the same prefix.

  • Open application.yml in your phishing-service and add the following property under the spring: section:
spring:
  data:
    rest:
      # All Spring Data REST endpoints will be served under /api
      base-path: /api

What does base-path do?

base-path: /api tells Spring Data REST to prefix all auto-generated endpoints with /api. A repository exposed at path blacklist becomes reachable at /api/blacklist. This keeps your API consistent with the hand-written controller endpoints like /api/detect.

  • Save pom.xml and application.yml.

Now you need to tell Spring Data REST which repositories to expose. You will annotate both BlacklistRepository and WhitelistRepository with @RepositoryRestResource.

  • Open BlacklistRepository.java in the repository package and replace its contents with the following:
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.BlacklistEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

// Exposes GET/POST/PUT/DELETE endpoints at /api/blacklist
@RepositoryRestResource(collectionResourceRel = "blacklist", path = "blacklist")
public interface BlacklistRepository extends JpaRepository<BlacklistEntry, Long> {
    boolean existsByDomain(String domain);
}

What does @RepositoryRestResource do?

collectionResourceRel = "blacklist" sets the JSON key name for the collection in API responses. path = "blacklist" defines the URL segment, so this repository is exposed at /api/blacklist.

Your existing existsByDomain method stays untouched. Spring Data REST only adds the standard CRUD operations on top of what you already defined.

  • Open WhitelistRepository.java and replace its contents with the same pattern:
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.WhitelistEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

// Exposes GET/POST/PUT/DELETE endpoints at /api/whitelist
@RepositoryRestResource(collectionResourceRel = "whitelist", path = "whitelist")
public interface WhitelistRepository extends JpaRepository<WhitelistEntry, Long> {
    boolean existsByDomain(String domain);
}
  • Save both repository files.
  • Restart the phishing-service. Once it starts, open your browser and navigate to http://localhost:8082/api.

You should see a JSON response listing the available resources, including blacklist and whitelist links. Spring Data REST auto-generated these from your two annotated repositories.

Not seeing the endpoints?

Check your pom.xml includes spring-boot-starter-data-rest inside the <dependencies> block. If you added it outside, Maven ignores it.

Verify that spring.data.rest.base-path is indented under spring: in your YAML. Incorrect indentation causes the property to be silently ignored.

help me troubleshoot why Spring Data REST endpoints are not appearing

Secure the endpoints with JWT authentication and role-based access

Right now, anyone can hit your Spring Data REST endpoints and add or delete blacklist entries. That is a security problem. You need the phishing-service to validate JWT tokens and enforce role-based rules.

The user-service already issues JWT tokens. You will add the same token validation logic to the phishing-service so it can read and trust those tokens.

  • First, add the JJWT library dependencies to your phishing-service pom.xml inside the <dependencies> section:
<!-- JWT token parsing and validation -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

Why three JJWT dependencies?

jjwt-api provides the public API you code against. jjwt-impl provides the runtime implementation. jjwt-jackson handles JSON serialization inside tokens. Missing any one of these three causes a runtime error.

  • Save pom.xml.

Next, you need a utility class to parse and validate JWT tokens. This is the same logic your user-service uses, now duplicated into the phishing-service so it can independently verify tokens.

  • Create a new util package inside your phishing-service source folder.
  • Create a file called JwtUtil.java inside the util package and paste the following code:
package com.example.phishingservice.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;

@Component
public class JwtUtil {

    // Same secret key used by user-service to sign tokens
    @Value("${jwt.secret}")
    private String secret;

    // Build the signing key from the shared secret string
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // Parse all claims from a JWT token
    public Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // Extract the username (subject) from the token
    public String extractUsername(String token) {
        return extractAllClaims(token).getSubject();
    }

    // Extract the role claim from the token
    public String extractRole(String token) {
        return extractAllClaims(token).get("role", String.class);
    }

    // Validate that the token is not expired and can be parsed
    public boolean validateToken(String token) {
        try {
            extractAllClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

What does this class do?

JwtUtil uses the same shared secret as your user-service to decode JWT tokens. It extracts the username, role, and validates the token signature. Both services must use the same jwt.secret property value for tokens to be trusted across services.

  • Add the jwt.secret property to your phishing-service application.yml. Use the same secret value you configured in your user-service:
# Must match the jwt.secret in user-service
jwt:
  secret: your-jwt-secret-key-here-at-least-32-characters-long
  • Save JwtUtil.java and application.yml.

With the utility class in place, you need a filter that intercepts every incoming HTTP request, extracts the JWT from the Authorization header, and sets up the Spring Security context.

  • Create a security package inside your phishing-service source folder.
  • Create JwtAuthenticationFilter.java inside the security package and paste the following code:
package com.example.phishingservice.security;

import com.example.phishingservice.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

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

        // Look for the Authorization header
        String authHeader = request.getHeader("Authorization");

        // Only process requests that carry a Bearer token
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            if (jwtUtil.validateToken(token)) {
                // Extract identity and role from the token
                String username = jwtUtil.extractUsername(token);
                String role = jwtUtil.extractRole(token);

                // Build a Spring Security authentication with the user's role
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                username,
                                null,
                                List.of(new SimpleGrantedAuthority("ROLE_" + role))
                        );

                // Set the security context so downstream code knows who the user is
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        // Continue the filter chain regardless
        filterChain.doFilter(request, response);
    }
}

What does this filter do?

This filter runs once per request, before your controllers execute. It reads the Authorization: Bearer <token> header and passes the token to JwtUtil for validation.

If the token is valid, it creates a UsernamePasswordAuthenticationToken with the user's role prefixed as ROLE_ADMIN or ROLE_USER. This is how Spring Security maps roles to authorities for access control decisions.

  • Save JwtAuthenticationFilter.java.

The filter can now authenticate requests, but Spring Security does not know which endpoints require authentication and which require the ADMIN role. The SecurityConfig class defines these rules.

  • Create SecurityConfig.java inside the security package and paste the following code:
package com.example.phishingservice.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // Enables @PreAuthorize annotations on methods
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF since this is a stateless REST API
            .csrf(csrf -> csrf.disable())

            // No HTTP sessions — every request carries its own JWT
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            .authorizeHttpRequests(auth -> auth
                // Auth endpoints pass through without a token
                .requestMatchers("/api/auth/**").permitAll()

                // Blacklist/whitelist write operations require ADMIN role
                .requestMatchers(HttpMethod.POST, "/api/blacklist/**", "/api/whitelist/**")
                    .hasRole("ADMIN")
                .requestMatchers(HttpMethod.PUT, "/api/blacklist/**", "/api/whitelist/**")
                    .hasRole("ADMIN")
                .requestMatchers(HttpMethod.DELETE, "/api/blacklist/**", "/api/whitelist/**")
                    .hasRole("ADMIN")

                // All other endpoints require authentication (any role)
                .anyRequest().authenticated()
            )

            // Run our JWT filter before Spring's default username/password filter
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

What do these security rules enforce?

/api/auth/** is open to everyone so users can register and log in without a token. POST, PUT, and DELETE on the blacklist and whitelist endpoints require the ADMIN role. Reading those lists and all other endpoints just require a valid token.

@EnableMethodSecurity also enables @PreAuthorize annotations, which you can use on individual repository or controller methods if you need finer-grained control later.

  • Save SecurityConfig.java.
  • Restart the phishing-service.

Test that the security rules are working by making an unauthenticated request to the blacklist endpoint. Run this in your terminal:

  • Verify that unauthenticated requests are blocked by running:
curl -s -o /dev/null -w "%{http_code}" http://localhost:8082/api/blacklist

You should see 401 (Unauthorized) as the response code. This confirms that the JWT filter is active and blocking requests without a valid token.

Getting 200 instead of 401?

If the endpoint responds with 200 instead of 401, your SecurityConfig may not be getting picked up by Spring. Confirm the class has both @Configuration and @EnableWebSecurity annotations.

Also check that the security package is inside your main application package. Spring Boot only scans packages at or below the main application class.

help me debug why my Spring Security config is not blocking unauthenticated requests

Build the scan history and analytics endpoints

Your React dashboard will need two more endpoints to show users their past scans and aggregate statistics. These are custom endpoints because they need user-specific filtering and aggregation that Spring Data REST does not generate automatically.

  • Create ScanHistoryController.java inside the controller package and paste the following code:
package com.example.phishingservice.controller;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.entity.ScanResult;
import com.example.phishingservice.repository.ScanResultRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
@RequestMapping("/api/scans")
public class ScanHistoryController {

    private final ScanResultRepository scanResultRepository;

    public ScanHistoryController(ScanResultRepository scanResultRepository) {
        this.scanResultRepository = scanResultRepository;
    }

    // Returns paginated scan history for the authenticated user
    @GetMapping("/history")
    public Page<ScanResult> getScanHistory(Authentication authentication,
                                           Pageable pageable) {
        // The principal name is the username extracted by JwtAuthenticationFilter
        String userId = authentication.getName();
        return scanResultRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
    }

    // Returns aggregate scan counts grouped by risk level
    @GetMapping("/stats")
    public Map<String, Long> getScanStats() {
        Map<String, Long> stats = new HashMap<>();

        // Count scans for each risk level
        for (RiskLevel level : RiskLevel.values()) {
            stats.put(level.name(), scanResultRepository.countByRiskLevel(level));
        }

        return stats;
    }
}

What do these endpoints do?

GET /api/scans/history returns a paginated list of scan results filtered by the authenticated user. Spring Data JPA handles pagination automatically when you pass a Pageable parameter. The frontend can request specific pages with query parameters like ?page=0&size=10.

GET /api/scans/stats loops through each RiskLevel value and counts how many scans fall into each category. This powers the analytics pie chart on the dashboard.

Why does Pageable work without extra repository changes?

Spring Data JPA automatically supports pagination on any derived query method. When you pass a Pageable parameter to findByUserIdOrderByCreatedAtDesc, it returns a Page<ScanResult> instead of a List<ScanResult>. No changes to your ScanResultRepository are needed.

  • Save ScanHistoryController.java.
  • Restart the phishing-service.

Test the full flow by getting a JWT token from your user-service and using it to call the new endpoints.

  • First, log in to get a token. Run this command, replacing the credentials with a user you registered earlier:
curl -X POST http://localhost:8083/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username": "testuser", "password": "testpassword"}'
  • Copy the token from the response.
  • Test the blacklist endpoint with your token by running:
curl -H "Authorization: Bearer your-jwt-token-here" \
  http://localhost:8082/api/blacklist

You should see a 200 response with an empty blacklist collection. The request succeeded because it carried a valid JWT.

  • Test the scan history endpoint by running:
curl -H "Authorization: Bearer your-jwt-token-here" \
  http://localhost:8082/api/scans/history
  • Test the stats endpoint by running:
curl -H "Authorization: Bearer your-jwt-token-here" \
  http://localhost:8082/api/scans/stats

The stats endpoint should return a JSON object with counts for each risk level, like {"SAFE":0,"SUSPICIOUS":0,"HIGH":0,"CRITICAL":0}. If you have previous scan results stored, you will see non-zero counts.

Getting 403 Forbidden on the blacklist endpoint?

A 403 means your token is valid but the role does not match. For GET requests, any authenticated user should be allowed. Check that your SecurityConfig only restricts POST/PUT/DELETE to ADMIN, not GET.

If you are getting 401 instead, your token may be expired or the jwt.secret in phishing-service does not match the one in user-service. Both services must use the exact same secret string.

help me debug JWT authentication errors between my microservices

✔️ Awesome, I've got everything!

Make sure you have saved all files in your phishing-service before moving on.

ⓧ I'd like to double check the full code

Here are the new and updated files from this step. Compare each one against your code.

package com.example.phishingservice.repository;

import com.example.phishingservice.entity.BlacklistEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

// Exposes GET/POST/PUT/DELETE endpoints at /api/blacklist
@RepositoryRestResource(collectionResourceRel = "blacklist", path = "blacklist")
public interface BlacklistRepository extends JpaRepository<BlacklistEntry, Long> {
    boolean existsByDomain(String domain);
}
package com.example.phishingservice.repository;

import com.example.phishingservice.entity.WhitelistEntry;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.rest.core.annotation.RepositoryRestResource;

// Exposes GET/POST/PUT/DELETE endpoints at /api/whitelist
@RepositoryRestResource(collectionResourceRel = "whitelist", path = "whitelist")
public interface WhitelistRepository extends JpaRepository<WhitelistEntry, Long> {
    boolean existsByDomain(String domain);
}
package com.example.phishingservice.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;

@Component
public class JwtUtil {

    // Same secret key used by user-service to sign tokens
    @Value("${jwt.secret}")
    private String secret;

    // Build the signing key from the shared secret string
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // Parse all claims from a JWT token
    public Claims extractAllClaims(String token) {
        return Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

    // Extract the username (subject) from the token
    public String extractUsername(String token) {
        return extractAllClaims(token).getSubject();
    }

    // Extract the role claim from the token
    public String extractRole(String token) {
        return extractAllClaims(token).get("role", String.class);
    }

    // Validate that the token is not expired and can be parsed
    public boolean validateToken(String token) {
        try {
            extractAllClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}
package com.example.phishingservice.security;

import com.example.phishingservice.util.JwtUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.List;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;

    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

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

        // Look for the Authorization header
        String authHeader = request.getHeader("Authorization");

        // Only process requests that carry a Bearer token
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);

            if (jwtUtil.validateToken(token)) {
                // Extract identity and role from the token
                String username = jwtUtil.extractUsername(token);
                String role = jwtUtil.extractRole(token);

                // Build a Spring Security authentication with the user's role
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                username,
                                null,
                                List.of(new SimpleGrantedAuthority("ROLE_" + role))
                        );

                // Set the security context so downstream code knows who the user is
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        // Continue the filter chain regardless
        filterChain.doFilter(request, response);
    }
}
package com.example.phishingservice.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
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.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
@EnableMethodSecurity  // Enables @PreAuthorize annotations on methods
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
        this.jwtAuthenticationFilter = jwtAuthenticationFilter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // Disable CSRF since this is a stateless REST API
            .csrf(csrf -> csrf.disable())

            // No HTTP sessions — every request carries its own JWT
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            .authorizeHttpRequests(auth -> auth
                // Auth endpoints pass through without a token
                .requestMatchers("/api/auth/**").permitAll()

                // Blacklist/whitelist write operations require ADMIN role
                .requestMatchers(HttpMethod.POST, "/api/blacklist/**", "/api/whitelist/**")
                    .hasRole("ADMIN")
                .requestMatchers(HttpMethod.PUT, "/api/blacklist/**", "/api/whitelist/**")
                    .hasRole("ADMIN")
                .requestMatchers(HttpMethod.DELETE, "/api/blacklist/**", "/api/whitelist/**")
                    .hasRole("ADMIN")

                // All other endpoints require authentication (any role)
                .anyRequest().authenticated()
            )

            // Run our JWT filter before Spring's default username/password filter
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}
package com.example.phishingservice.controller;

import com.example.phishingservice.entity.RiskLevel;
import com.example.phishingservice.entity.ScanResult;
import com.example.phishingservice.repository.ScanResultRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

@RestController
@RequestMapping("/api/scans")
public class ScanHistoryController {

    private final ScanResultRepository scanResultRepository;

    public ScanHistoryController(ScanResultRepository scanResultRepository) {
        this.scanResultRepository = scanResultRepository;
    }

    // Returns paginated scan history for the authenticated user
    @GetMapping("/history")
    public Page<ScanResult> getScanHistory(Authentication authentication,
                                           Pageable pageable) {
        // The principal name is the username extracted by JwtAuthenticationFilter
        String userId = authentication.getName();
        return scanResultRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable);
    }

    // Returns aggregate scan counts grouped by risk level
    @GetMapping("/stats")
    public Map<String, Long> getScanStats() {
        Map<String, Long> stats = new HashMap<>();

        // Count scans for each risk level
        for (RiskLevel level : RiskLevel.values()) {
            stats.put(level.name(), scanResultRepository.countByRiskLevel(level));
        }

        return stats;
    }
}

Your phishing-service now has complete data management capabilities. Authenticated users can read blacklists, view their scan history, and check analytics. Only admins can modify the blacklist and whitelist. Next up, you will build the React dashboard that puts all of these endpoints to work in a real user interface.

Enhance the React Frontend with Dashboard and Admin Panel

Your backend is a powerhouse. You have authenticated users, a multi-strategy detection engine, weighted risk scores persisted to PostgreSQL, and Spring Data REST endpoints exposing blacklist and whitelist management. The problem is that nobody can use any of it without a proper UI.

In this step, you will transform the minimal React frontend into a full-featured dashboard. Users will log in with JWT tokens, scan URLs and see color-coded risk scores, browse their scan history with pagination, view analytics charts, and (if they are admins) manage blacklists and whitelists.

In this step, get ready to:
  • Set up React authentication with JWT tokens, an AuthContext, and protected routes.
  • Build a dashboard with URL scanning, scan result cards, scan history, and analytics charts.
  • Build an admin panel for blacklist and whitelist CRUD management with a role-based navigation bar.
Set up authentication in the React app

Every API call to your backend (except login and register) requires a valid JWT token. The frontend needs a way to store this token after login, attach it to every request, and redirect unauthenticated users away from protected pages.

You will build this auth layer from three pieces: a React Context that holds the token and user info, an Axios instance with a request interceptor for the token header, and a PrivateRoute wrapper that guards pages.

  • Open a terminal inside your frontend project directory.
  • Install the three new dependencies by running:
npm install axios react-router-dom recharts

What do these packages do?

  • axios is an HTTP client that makes API calls and supports request interceptors for attaching headers.
  • react-router-dom provides client-side routing so the app can navigate between pages without a full page reload.
  • recharts is a charting library built on React components. You will use it to render a pie chart of scan analytics.
  • Confirm the packages installed by checking your package.json. You should see axios, react-router-dom, and recharts listed under dependencies.

npm install failing?

Make sure you are running the command from inside the frontend folder, not the project root. If you see peer dependency warnings, they are usually safe to ignore for development.

Help me troubleshoot my npm install

  • Create a new folder called context inside frontend/src/.
  • Inside that folder, create a new file called AuthContext.js and add the following code:
import { createContext, useContext, useState } from 'react';

// Create a context to share auth state across all components
const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  // Initialize token from localStorage so login persists across refreshes
  const [token, setToken] = useState(localStorage.getItem('token'));
  const [user, setUser] = useState(
    JSON.parse(localStorage.getItem('user'))
  );

  // Save token and user info to both state and localStorage
  function login(newToken, newUser) {
    localStorage.setItem('token', newToken);
    localStorage.setItem('user', JSON.stringify(newUser));
    setToken(newToken);
    setUser(newUser);
  }

  // Clear all auth data on logout
  function logout() {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    setToken(null);
    setUser(null);
  }

  return (
    <AuthContext.Provider value={{ token, user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

// Custom hook for easy access to auth state
export function useAuth() {
  return useContext(AuthContext);
}

What does this code do?

AuthContext is a React Context that acts as a central store for authentication state. Every component in the app can read the current token and user without passing props manually through every level.

The login() function writes the JWT and user object to both React state and localStorage. The localStorage copy means the user stays logged in across browser refreshes.

  • Save AuthContext.js.

You will not see a visible change yet. The next file wires this context into your API calls.

  • Create a new folder called api inside frontend/src/.
  • Inside that folder, create a new file called axiosInstance.js and add the following code:
import axios from 'axios';

// Point all API requests at the API Gateway
const axiosInstance = axios.create({
  baseURL: 'http://localhost:8080'
});

// Attach the JWT token to every outgoing request
axiosInstance.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default axiosInstance;

What does this code do?

This creates a pre-configured Axios client. The baseURL points to your API Gateway at http://localhost:8080, so every call like axiosInstance.post('/api/auth/login', ...) automatically routes through the gateway.

The request interceptor runs before every outgoing request. It reads the JWT token from localStorage and attaches it as an Authorization: Bearer <token> header. This is how your backend validates every request.

  • Save axiosInstance.js.
  • Create a new folder called pages inside frontend/src/.
  • Inside that folder, create a new file called LoginPage.js and add the following code:
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import axiosInstance from '../api/axiosInstance';

function LoginPage() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login } = useAuth();
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    try {
      // Send credentials to user-service via the API Gateway
      const response = await axiosInstance.post('/api/auth/login', {
        username,
        password
      });

      const token = response.data.token;

      // Decode the JWT payload to extract username and role
      const payload = JSON.parse(atob(token.split('.')[1]));
      const user = { username: payload.sub, role: payload.role };

      // Store auth state and redirect to the dashboard
      login(token, user);
      navigate('/');
    } catch (err) {
      setError('Invalid username or password.');
    }
  }

  return (
    <div style={{ maxWidth: '400px', margin: '100px auto' }}>
      <h2>Login</h2>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '10px' }}>
          <label>Username</label>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <button type="submit" style={{ padding: '10px 20px' }}>
          Login
        </button>
      </form>
      <p>
        Don't have an account? <Link to="/register">Register</Link>
      </p>
    </div>
  );
}

export default LoginPage;

What does this code do?

  • handleSubmit() sends the username and password to POST /api/auth/login via the API Gateway.
  • atob(token.split('.')[1]) decodes the middle section of the JWT (the payload) to extract the username and role without a library.
  • After a successful login, it calls login() from AuthContext to store the token, then redirects to the dashboard.
  • Save LoginPage.js.
  • In the same pages folder, create a new file called RegisterPage.js and add the following code:
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import axiosInstance from '../api/axiosInstance';

function RegisterPage() {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    try {
      // Register a new user account via the user-service
      await axiosInstance.post('/api/auth/register', {
        username,
        email,
        password
      });

      // Redirect to login page on successful registration
      navigate('/login');
    } catch (err) {
      setError('Registration failed. Try a different username.');
    }
  }

  return (
    <div style={{ maxWidth: '400px', margin: '100px auto' }}>
      <h2>Register</h2>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '10px' }}>
          <label>Username</label>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>Email</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <button type="submit" style={{ padding: '10px 20px' }}>
          Register
        </button>
      </form>
      <p>
        Already have an account? <Link to="/login">Login</Link>
      </p>
    </div>
  );
}

export default RegisterPage;

What does this code do?

RegisterPage follows the same pattern as LoginPage. It sends the username, email, and password to POST /api/auth/register. On success, it redirects the user to the login page to sign in with their new account.

  • Save RegisterPage.js.
  • Create a new folder called components inside frontend/src/.
  • Inside that folder, create a new file called PrivateRoute.js and add the following code:
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

// Redirect unauthenticated users to the login page
function PrivateRoute({ children }) {
  const { token } = useAuth();

  if (!token) {
    return <Navigate to="/login" />;
  }

  return children;
}

export default PrivateRoute;

What does this code do?

PrivateRoute is a wrapper component. Any route wrapped with <PrivateRoute> checks for a token in AuthContext before rendering the page. If no token exists, the user gets redirected to /login.

  • Save PrivateRoute.js.

You now have all the auth infrastructure files. You will not see these in the browser yet because App.js has not been updated to use them. That happens at the end of this step.

✔️ Awesome, I've got everything!

Make sure you have saved all four files: AuthContext.js, axiosInstance.js, LoginPage.js, RegisterPage.js, and PrivateRoute.js.

ⓧ I'd like to double check the full code

import { createContext, useContext, useState } from 'react';

const AuthContext = createContext(null);

export function AuthProvider({ children }) {
  const [token, setToken] = useState(localStorage.getItem('token'));
  const [user, setUser] = useState(
    JSON.parse(localStorage.getItem('user'))
  );

  function login(newToken, newUser) {
    localStorage.setItem('token', newToken);
    localStorage.setItem('user', JSON.stringify(newUser));
    setToken(newToken);
    setUser(newUser);
  }

  function logout() {
    localStorage.removeItem('token');
    localStorage.removeItem('user');
    setToken(null);
    setUser(null);
  }

  return (
    <AuthContext.Provider value={{ token, user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  return useContext(AuthContext);
}
import axios from 'axios';

const axiosInstance = axios.create({
  baseURL: 'http://localhost:8080'
});

axiosInstance.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

export default axiosInstance;
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import axiosInstance from '../api/axiosInstance';

function LoginPage() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login } = useAuth();
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    try {
      const response = await axiosInstance.post('/api/auth/login', {
        username,
        password
      });
      const token = response.data.token;
      const payload = JSON.parse(atob(token.split('.')[1]));
      const user = { username: payload.sub, role: payload.role };
      login(token, user);
      navigate('/');
    } catch (err) {
      setError('Invalid username or password.');
    }
  }

  return (
    <div style={{ maxWidth: '400px', margin: '100px auto' }}>
      <h2>Login</h2>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '10px' }}>
          <label>Username</label>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <button type="submit" style={{ padding: '10px 20px' }}>
          Login
        </button>
      </form>
      <p>
        Don't have an account? <Link to="/register">Register</Link>
      </p>
    </div>
  );
}

export default LoginPage;
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import axiosInstance from '../api/axiosInstance';

function RegisterPage() {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const navigate = useNavigate();

  async function handleSubmit(e) {
    e.preventDefault();
    try {
      await axiosInstance.post('/api/auth/register', {
        username,
        email,
        password
      });
      navigate('/login');
    } catch (err) {
      setError('Registration failed. Try a different username.');
    }
  }

  return (
    <div style={{ maxWidth: '400px', margin: '100px auto' }}>
      <h2>Register</h2>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '10px' }}>
          <label>Username</label>
          <input
            type="text"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>Email</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <div style={{ marginBottom: '10px' }}>
          <label>Password</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            required
            style={{ width: '100%', padding: '8px' }}
          />
        </div>
        <button type="submit" style={{ padding: '10px 20px' }}>
          Register
        </button>
      </form>
      <p>
        Already have an account? <Link to="/login">Login</Link>
      </p>
    </div>
  );
}

export default RegisterPage;
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

function PrivateRoute({ children }) {
  const { token } = useAuth();

  if (!token) {
    return <Navigate to="/login" />;
  }

  return children;
}

export default PrivateRoute;
Build the main dashboard with URL scanning and analytics

With the auth layer in place, you can now build the pages that users actually interact with. The dashboard is the heart of the application. It lets users type in a URL, get an instant risk assessment, and see a pie chart of all their past scans.

  • Inside the frontend/src/components/ folder, create a new file called UrlScanForm.js and add the following code:
import { useState } from 'react';
import axiosInstance from '../api/axiosInstance';
import ScanResultCard from './ScanResultCard';

function UrlScanForm() {
  const [url, setUrl] = useState('');
  const [result, setResult] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  async function handleScan(e) {
    e.preventDefault();
    setLoading(true);
    setError('');
    setResult(null);

    try {
      // Send the URL to the phishing detection engine
      const response = await axiosInstance.post('/api/detect', { url });
      setResult(response.data);
    } catch (err) {
      setError('Scan failed. Please try again.');
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <h3>Scan a URL</h3>
      <form onSubmit={handleScan} style={{ display: 'flex', gap: '10px' }}>
        <input
          type="text"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          placeholder="Enter a URL to scan..."
          required
          style={{ flex: 1, padding: '10px' }}
        />
        <button type="submit" disabled={loading} style={{ padding: '10px 20px' }}>
          {loading ? 'Scanning...' : 'Scan'}
        </button>
      </form>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {result && <ScanResultCard scanResult={result} />}
    </div>
  );
}

export default UrlScanForm;

What does this code do?

  • handleScan() sends the entered URL to POST /api/detect, which triggers your multi-strategy detection engine on the backend.
  • While waiting for the response, the button shows "Scanning..." to give the user feedback.
  • When the scan completes, it renders a ScanResultCard component (which you will create next) to display the results.
  • Save UrlScanForm.js.
  • In the same components folder, create a new file called ScanResultCard.js and add the following code:
import { useState } from 'react';

// Map each risk level to a display color
const RISK_COLORS = {
  SAFE: 'green',
  SUSPICIOUS: '#cccc00',
  HIGH: 'orange',
  CRITICAL: 'red'
};

function ScanResultCard({ scanResult }) {
  const [expanded, setExpanded] = useState(false);

  const { url, riskScore, riskLevel, details, createdAt } = scanResult;
  const color = RISK_COLORS[riskLevel] || 'gray';

  // Parse the JSON details string into an object for display
  let detailsObj = {};
  try {
    detailsObj = JSON.parse(details);
  } catch {
    detailsObj = {};
  }

  return (
    <div style={{ border: '1px solid #ccc', padding: '15px', marginTop: '15px', borderRadius: '8px' }}>
      <p><strong>URL:</strong> {url}</p>
      <p>
        <strong>Risk Score:</strong>{' '}
        <span style={{ color, fontWeight: 'bold', fontSize: '1.2em' }}>
          {riskScore}/100
        </span>
      </p>
      <p>
        <strong>Risk Level:</strong>{' '}
        <span style={{
          backgroundColor: color,
          color: 'white',
          padding: '2px 10px',
          borderRadius: '4px'
        }}>
          {riskLevel}
        </span>
      </p>
      {createdAt && <p><strong>Scanned:</strong> {new Date(createdAt).toLocaleString()}</p>}
      <button onClick={() => setExpanded(!expanded)} style={{ marginTop: '10px' }}>
        {expanded ? 'Hide Details' : 'Show Details'}
      </button>
      {expanded && (
        <div style={{ marginTop: '10px', backgroundColor: '#f5f5f5', padding: '10px', borderRadius: '4px' }}>
          <h4>Feature Scores</h4>
          <ul>
            {Object.entries(detailsObj).map(([key, value]) => (
              <li key={key}>
                <strong>{key}:</strong> {typeof value === 'number' ? value.toFixed(2) : String(value)}
              </li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

export default ScanResultCard;

What does this code do?

  • RISK_COLORS maps each risk level to a color: green for SAFE, yellow for SUSPICIOUS, orange for HIGH, red for CRITICAL.
  • The risk level is displayed as a colored badge. The risk score is displayed as a number out of 100 in the matching color.
  • The expandable section parses the details JSON string and lists each individual feature score (lexical, reputation, content) so the user can understand why the URL received its score.
  • Save ScanResultCard.js.
  • In the same components folder, create a new file called StatsPanel.js and add the following code:
import { useState, useEffect } from 'react';
import { PieChart, Pie, Cell, Legend, Tooltip } from 'recharts';
import axiosInstance from '../api/axiosInstance';

// Colors matching each risk level
const COLORS = {
  SAFE: 'green',
  SUSPICIOUS: '#cccc00',
  HIGH: 'orange',
  CRITICAL: 'red'
};

function StatsPanel() {
  const [stats, setStats] = useState([]);

  useEffect(() => {
    // Fetch aggregate scan counts grouped by risk level
    axiosInstance.get('/api/scans/stats')
      .then((res) => {
        // Transform the stats map into an array for recharts
        const data = Object.entries(res.data).map(([name, value]) => ({
          name,
          value
        }));
        setStats(data);
      })
      .catch(() => setStats([]));
  }, []);

  if (stats.length === 0) {
    return <p>No scan data available yet.</p>;
  }

  return (
    <div>
      <h3>Scan Analytics</h3>
      <PieChart width={400} height={300}>
        <Pie
          data={stats}
          dataKey="value"
          nameKey="name"
          cx="50%"
          cy="50%"
          outerRadius={100}
          label
        >
          {stats.map((entry) => (
            <Cell key={entry.name} fill={COLORS[entry.name] || 'gray'} />
          ))}
        </Pie>
        <Tooltip />
        <Legend />
      </PieChart>
    </div>
  );
}

export default StatsPanel;

What does this code do?

StatsPanel fetches GET /api/scans/stats, which returns a map of risk level counts (e.g. {SAFE: 12, SUSPICIOUS: 3, HIGH: 1, CRITICAL: 0}). It transforms this into a recharts-compatible array and renders a PieChart with color-coded slices.

  • Save StatsPanel.js.

Now create the Dashboard page that combines the scan form and the analytics panel.

  • Inside the frontend/src/pages/ folder, create a new file called Dashboard.js and add the following code:
import UrlScanForm from '../components/UrlScanForm';
import StatsPanel from '../components/StatsPanel';

// Main dashboard combines URL scanning with analytics overview
function Dashboard() {
  return (
    <div style={{ padding: '20px' }}>
      <h2>Phishing Detection Dashboard</h2>
      <UrlScanForm />
      <hr style={{ margin: '30px 0' }} />
      <StatsPanel />
    </div>
  );
}

export default Dashboard;

What does this code do?

Dashboard is a composition page. It renders the UrlScanForm at the top for real-time scanning and the StatsPanel below for a visual breakdown of all past scans. Each component manages its own data fetching independently.

  • Save Dashboard.js.

The last piece for the main user experience is a dedicated page for browsing scan history.

  • Inside frontend/src/pages/, create a new file called ScanHistoryTable.js and add the following code:
import { useState, useEffect } from 'react';
import axiosInstance from '../api/axiosInstance';

// Colors for each risk level
const RISK_COLORS = {
  SAFE: 'green',
  SUSPICIOUS: '#cccc00',
  HIGH: 'orange',
  CRITICAL: 'red'
};

function ScanHistoryTable() {
  const [scans, setScans] = useState([]);
  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(0);

  useEffect(() => {
    // Fetch paginated scan history for the current user
    axiosInstance.get(`/api/scans/history?page=${page}&size=10`)
      .then((res) => {
        setScans(res.data.content);
        setTotalPages(res.data.totalPages);
      })
      .catch(() => setScans([]));
  }, [page]);

  return (
    <div style={{ padding: '20px' }}>
      <h2>Scan History</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr style={{ borderBottom: '2px solid #ccc' }}>
            <th style={{ textAlign: 'left', padding: '8px' }}>URL</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Risk Score</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Risk Level</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Date</th>
          </tr>
        </thead>
        <tbody>
          {scans.map((scan) => (
            <tr key={scan.id} style={{ borderBottom: '1px solid #eee' }}>
              <td style={{ padding: '8px' }}>{scan.url}</td>
              <td style={{ padding: '8px' }}>{scan.riskScore}/100</td>
              <td style={{ padding: '8px' }}>
                <span style={{
                  backgroundColor: RISK_COLORS[scan.riskLevel] || 'gray',
                  color: 'white',
                  padding: '2px 8px',
                  borderRadius: '4px'
                }}>
                  {scan.riskLevel}
                </span>
              </td>
              <td style={{ padding: '8px' }}>
                {new Date(scan.createdAt).toLocaleString()}
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      {/* Pagination controls */}
      <div style={{ marginTop: '15px', display: 'flex', gap: '10px' }}>
        <button onClick={() => setPage(page - 1)} disabled={page === 0}>
          Previous
        </button>
        <span>Page {page + 1} of {totalPages}</span>
        <button onClick={() => setPage(page + 1)} disabled={page >= totalPages - 1}>
          Next
        </button>
      </div>
    </div>
  );
}

export default ScanHistoryTable;

What does this code do?

  • The component fetches GET /api/scans/history?page=0&size=10 which returns a Spring Data Page object with content (the scan array) and totalPages.
  • Each row shows the URL, its risk score, a color-coded risk level badge, and the scan date.
  • The Previous and Next buttons change the page state variable, which triggers a re-fetch via the useEffect dependency.
  • Save ScanHistoryTable.js.

✔️ Awesome, I've got everything!

Make sure you have saved all five files: UrlScanForm.js, ScanResultCard.js, StatsPanel.js, Dashboard.js, and ScanHistoryTable.js.

ⓧ I'd like to double check the full code

import { useState } from 'react';
import axiosInstance from '../api/axiosInstance';
import ScanResultCard from './ScanResultCard';

function UrlScanForm() {
  const [url, setUrl] = useState('');
  const [result, setResult] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');

  async function handleScan(e) {
    e.preventDefault();
    setLoading(true);
    setError('');
    setResult(null);
    try {
      const response = await axiosInstance.post('/api/detect', { url });
      setResult(response.data);
    } catch (err) {
      setError('Scan failed. Please try again.');
    } finally {
      setLoading(false);
    }
  }

  return (
    <div>
      <h3>Scan a URL</h3>
      <form onSubmit={handleScan} style={{ display: 'flex', gap: '10px' }}>
        <input
          type="text"
          value={url}
          onChange={(e) => setUrl(e.target.value)}
          placeholder="Enter a URL to scan..."
          required
          style={{ flex: 1, padding: '10px' }}
        />
        <button type="submit" disabled={loading} style={{ padding: '10px 20px' }}>
          {loading ? 'Scanning...' : 'Scan'}
        </button>
      </form>
      {error && <p style={{ color: 'red' }}>{error}</p>}
      {result && <ScanResultCard scanResult={result} />}
    </div>
  );
}

export default UrlScanForm;
import { useState } from 'react';

const RISK_COLORS = {
  SAFE: 'green',
  SUSPICIOUS: '#cccc00',
  HIGH: 'orange',
  CRITICAL: 'red'
};

function ScanResultCard({ scanResult }) {
  const [expanded, setExpanded] = useState(false);
  const { url, riskScore, riskLevel, details, createdAt } = scanResult;
  const color = RISK_COLORS[riskLevel] || 'gray';

  let detailsObj = {};
  try {
    detailsObj = JSON.parse(details);
  } catch {
    detailsObj = {};
  }

  return (
    <div style={{ border: '1px solid #ccc', padding: '15px', marginTop: '15px', borderRadius: '8px' }}>
      <p><strong>URL:</strong> {url}</p>
      <p>
        <strong>Risk Score:</strong>{' '}
        <span style={{ color, fontWeight: 'bold', fontSize: '1.2em' }}>{riskScore}/100</span>
      </p>
      <p>
        <strong>Risk Level:</strong>{' '}
        <span style={{ backgroundColor: color, color: 'white', padding: '2px 10px', borderRadius: '4px' }}>{riskLevel}</span>
      </p>
      {createdAt && <p><strong>Scanned:</strong> {new Date(createdAt).toLocaleString()}</p>}
      <button onClick={() => setExpanded(!expanded)} style={{ marginTop: '10px' }}>
        {expanded ? 'Hide Details' : 'Show Details'}
      </button>
      {expanded && (
        <div style={{ marginTop: '10px', backgroundColor: '#f5f5f5', padding: '10px', borderRadius: '4px' }}>
          <h4>Feature Scores</h4>
          <ul>
            {Object.entries(detailsObj).map(([key, value]) => (
              <li key={key}><strong>{key}:</strong> {typeof value === 'number' ? value.toFixed(2) : String(value)}</li>
            ))}
          </ul>
        </div>
      )}
    </div>
  );
}

export default ScanResultCard;
import { useState, useEffect } from 'react';
import { PieChart, Pie, Cell, Legend, Tooltip } from 'recharts';
import axiosInstance from '../api/axiosInstance';

const COLORS = {
  SAFE: 'green',
  SUSPICIOUS: '#cccc00',
  HIGH: 'orange',
  CRITICAL: 'red'
};

function StatsPanel() {
  const [stats, setStats] = useState([]);

  useEffect(() => {
    axiosInstance.get('/api/scans/stats')
      .then((res) => {
        const data = Object.entries(res.data).map(([name, value]) => ({ name, value }));
        setStats(data);
      })
      .catch(() => setStats([]));
  }, []);

  if (stats.length === 0) {
    return <p>No scan data available yet.</p>;
  }

  return (
    <div>
      <h3>Scan Analytics</h3>
      <PieChart width={400} height={300}>
        <Pie data={stats} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
          {stats.map((entry) => (
            <Cell key={entry.name} fill={COLORS[entry.name] || 'gray'} />
          ))}
        </Pie>
        <Tooltip />
        <Legend />
      </PieChart>
    </div>
  );
}

export default StatsPanel;
import UrlScanForm from '../components/UrlScanForm';
import StatsPanel from '../components/StatsPanel';

function Dashboard() {
  return (
    <div style={{ padding: '20px' }}>
      <h2>Phishing Detection Dashboard</h2>
      <UrlScanForm />
      <hr style={{ margin: '30px 0' }} />
      <StatsPanel />
    </div>
  );
}

export default Dashboard;
import { useState, useEffect } from 'react';
import axiosInstance from '../api/axiosInstance';

const RISK_COLORS = {
  SAFE: 'green',
  SUSPICIOUS: '#cccc00',
  HIGH: 'orange',
  CRITICAL: 'red'
};

function ScanHistoryTable() {
  const [scans, setScans] = useState([]);
  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(0);

  useEffect(() => {
    axiosInstance.get(`/api/scans/history?page=${page}&size=10`)
      .then((res) => {
        setScans(res.data.content);
        setTotalPages(res.data.totalPages);
      })
      .catch(() => setScans([]));
  }, [page]);

  return (
    <div style={{ padding: '20px' }}>
      <h2>Scan History</h2>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr style={{ borderBottom: '2px solid #ccc' }}>
            <th style={{ textAlign: 'left', padding: '8px' }}>URL</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Risk Score</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Risk Level</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Date</th>
          </tr>
        </thead>
        <tbody>
          {scans.map((scan) => (
            <tr key={scan.id} style={{ borderBottom: '1px solid #eee' }}>
              <td style={{ padding: '8px' }}>{scan.url}</td>
              <td style={{ padding: '8px' }}>{scan.riskScore}/100</td>
              <td style={{ padding: '8px' }}>
                <span style={{ backgroundColor: RISK_COLORS[scan.riskLevel] || 'gray', color: 'white', padding: '2px 8px', borderRadius: '4px' }}>{scan.riskLevel}</span>
              </td>
              <td style={{ padding: '8px' }}>{new Date(scan.createdAt).toLocaleString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
      <div style={{ marginTop: '15px', display: 'flex', gap: '10px' }}>
        <button onClick={() => setPage(page - 1)} disabled={page === 0}>Previous</button>
        <span>Page {page + 1} of {totalPages}</span>
        <button onClick={() => setPage(page + 1)} disabled={page >= totalPages - 1}>Next</button>
      </div>
    </div>
  );
}

export default ScanHistoryTable;
Build the admin panel and wire up routing

The final piece is the admin panel and navigation. Admin users need a way to manage blacklist and whitelist entries through the Spring Data REST endpoints you exposed in the previous step. All users need a navigation bar to move between pages.

  • Inside the frontend/src/pages/ folder, create a new file called AdminPanel.js and add the following code:
import { useState, useEffect } from 'react';
import axiosInstance from '../api/axiosInstance';

function AdminPanel() {
  const [activeTab, setActiveTab] = useState('blacklist');
  const [entries, setEntries] = useState([]);
  const [domain, setDomain] = useState('');
  const [reason, setReason] = useState('');

  // Fetch entries whenever the active tab changes
  useEffect(() => {
    fetchEntries();
  }, [activeTab]);

  function fetchEntries() {
    // Spring Data REST exposes entries under _embedded
    axiosInstance.get(`/api/${activeTab}`)
      .then((res) => {
        const data = res.data._embedded
          ? res.data._embedded[activeTab]
          : [];
        setEntries(data);
      })
      .catch(() => setEntries([]));
  }

  async function handleAdd(e) {
    e.preventDefault();
    try {
      // Create a new blacklist or whitelist entry
      await axiosInstance.post(`/api/${activeTab}`, { domain, reason });
      setDomain('');
      setReason('');
      fetchEntries();
    } catch (err) {
      alert('Failed to add entry. Check your permissions.');
    }
  }

  async function handleDelete(entryUrl) {
    try {
      // Spring Data REST provides a self link for each entry
      await axiosInstance.delete(entryUrl);
      fetchEntries();
    } catch (err) {
      alert('Failed to delete entry.');
    }
  }

  return (
    <div style={{ padding: '20px' }}>
      <h2>Admin Panel</h2>
      {/* Tab buttons to switch between blacklist and whitelist */}
      <div style={{ marginBottom: '20px' }}>
        <button
          onClick={() => setActiveTab('blacklist')}
          style={{
            padding: '10px 20px',
            fontWeight: activeTab === 'blacklist' ? 'bold' : 'normal'
          }}
        >
          Blacklist Management
        </button>
        <button
          onClick={() => setActiveTab('whitelist')}
          style={{
            padding: '10px 20px',
            fontWeight: activeTab === 'whitelist' ? 'bold' : 'normal'
          }}
        >
          Whitelist Management
        </button>
      </div>

      {/* Add entry form */}
      <form onSubmit={handleAdd} style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
        <input
          type="text"
          value={domain}
          onChange={(e) => setDomain(e.target.value)}
          placeholder="Domain (e.g. evil-site.com)"
          required
          style={{ padding: '8px' }}
        />
        <input
          type="text"
          value={reason}
          onChange={(e) => setReason(e.target.value)}
          placeholder="Reason"
          required
          style={{ padding: '8px' }}
        />
        <button type="submit" style={{ padding: '8px 16px' }}>Add Entry</button>
      </form>

      {/* Entries table */}
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr style={{ borderBottom: '2px solid #ccc' }}>
            <th style={{ textAlign: 'left', padding: '8px' }}>Domain</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Reason</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Added By</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Date</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Actions</th>
          </tr>
        </thead>
        <tbody>
          {entries.map((entry) => (
            <tr key={entry.domain} style={{ borderBottom: '1px solid #eee' }}>
              <td style={{ padding: '8px' }}>{entry.domain}</td>
              <td style={{ padding: '8px' }}>{entry.reason}</td>
              <td style={{ padding: '8px' }}>{entry.addedBy}</td>
              <td style={{ padding: '8px' }}>
                {entry.createdAt ? new Date(entry.createdAt).toLocaleString() : 'N/A'}
              </td>
              <td style={{ padding: '8px' }}>
                <button
                  onClick={() => handleDelete(entry._links.self.href)}
                  style={{ color: 'red' }}
                >
                  Delete
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default AdminPanel;

What does this code do?

  • activeTab toggles between blacklist and whitelist. The same URL pattern (/api/${activeTab}) works for both because Spring Data REST uses the repository path you configured.
  • Spring Data REST wraps collections under _embedded in its responses. Each entry also includes a _links.self.href URL that the delete button uses for the DELETE request.
  • Only ADMIN users can create or delete entries. If a non-admin tries, the backend returns a 403 and the alert fires.
  • Save AdminPanel.js.

Admin panel showing empty entries or 403 errors?

If the entries table is empty, confirm your phishing-service is running on port 8082 and the API Gateway is routing /api/blacklist/** correctly.

If you see a 403 when adding entries, make sure you registered and logged in as a user with the ADMIN role. Check that your SecurityConfig in phishing-service enforces ADMIN only on POST, PUT, and DELETE.

Help me debug my admin panel API calls

Next, create the navigation bar that ties all these pages together.

  • Inside frontend/src/components/, create a new file called Navbar.js and add the following code:
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

function Navbar() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();

  function handleLogout() {
    logout();
    navigate('/login');
  }

  return (
    <nav style={{
      display: 'flex',
      gap: '15px',
      padding: '10px 20px',
      backgroundColor: '#333',
      color: 'white',
      alignItems: 'center'
    }}>
      <Link to="/" style={{ color: 'white' }}>Dashboard</Link>
      <Link to="/history" style={{ color: 'white' }}>Scan History</Link>
      {/* Only show the Admin link for users with the ADMIN role */}
      {user?.role === 'ADMIN' && (
        <Link to="/admin" style={{ color: 'white' }}>Admin</Link>
      )}
      <span style={{ marginLeft: 'auto' }}>
        {user?.username}
      </span>
      <button onClick={handleLogout} style={{ padding: '5px 10px' }}>
        Logout
      </button>
    </nav>
  );
}

export default Navbar;

What does this code do?

The Navbar reads the user object from AuthContext. If the user has the ADMIN role, an extra "Admin" link appears. The logout button calls logout() to clear the token and user from both state and localStorage, then redirects to the login page.

  • Save Navbar.js.

Finally, wire everything together in App.js. This is where you define the routes and wrap the application in the AuthProvider.

  • Open frontend/src/App.js and replace its entire contents with the following code:
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import Navbar from './components/Navbar';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import Dashboard from './pages/Dashboard';
import ScanHistoryTable from './pages/ScanHistoryTable';
import AdminPanel from './pages/AdminPanel';

// Inner layout that conditionally shows the Navbar
function AppLayout() {
  const { token } = useAuth();

  return (
    <>
      {/* Show the Navbar only when the user is logged in */}
      {token && <Navbar />}
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route path="/register" element={<RegisterPage />} />
        <Route path="/" element={
          <PrivateRoute><Dashboard /></PrivateRoute>
        } />
        <Route path="/history" element={
          <PrivateRoute><ScanHistoryTable /></PrivateRoute>
        } />
        <Route path="/admin" element={
          <PrivateRoute><AdminPanel /></PrivateRoute>
        } />
      </Routes>
    </>
  );
}

function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <AppLayout />
      </AuthProvider>
    </BrowserRouter>
  );
}

export default App;

What does this code do?

  • AuthProvider wraps the entire app so every component can access auth state.
  • AppLayout is a separate inner component because useAuth() can only be called inside a component wrapped by AuthProvider. It conditionally shows the Navbar when a token exists.
  • The /login and /register routes are public. All other routes are wrapped in PrivateRoute which redirects to login if the user is not authenticated.
  • Save App.js.

Now test the full flow. Make sure your backend services (eureka-server, user-service, phishing-service, api-gateway) are all running.

  • Start the React development server by running:
npm start
  • Open http://localhost:3000 in your browser. You should see the login page.
  • Click the Register link and create a test account.
  • Log in with your new account. You should be redirected to the dashboard.
  • Enter a URL in the scan form and click Scan. You should see a ScanResultCard with a color-coded risk score and an expandable details section.
  • Click Scan History in the navigation bar. You should see the URL you just scanned listed in the table.
  • If you registered as an ADMIN user, click Admin in the navigation bar. You should see the blacklist and whitelist management tabs.

Seeing a blank page or CORS errors?

Open your browser's developer console (press F12) and check for errors. A blank page usually means a missing import or a typo in one of your component files.

CORS errors mean the API Gateway is not allowing requests from http://localhost:3000. You will configure CORS in the next step when you enhance the API Gateway. For now, if you need to test, you can temporarily add a CORS configuration to the gateway.

Help me debug my React frontend

✔️ Awesome, I've got everything!

Make sure you have saved AdminPanel.js, Navbar.js, and App.js.

ⓧ I'd like to double check the full code

import { useState, useEffect } from 'react';
import axiosInstance from '../api/axiosInstance';

function AdminPanel() {
  const [activeTab, setActiveTab] = useState('blacklist');
  const [entries, setEntries] = useState([]);
  const [domain, setDomain] = useState('');
  const [reason, setReason] = useState('');

  useEffect(() => {
    fetchEntries();
  }, [activeTab]);

  function fetchEntries() {
    axiosInstance.get(`/api/${activeTab}`)
      .then((res) => {
        const data = res.data._embedded
          ? res.data._embedded[activeTab]
          : [];
        setEntries(data);
      })
      .catch(() => setEntries([]));
  }

  async function handleAdd(e) {
    e.preventDefault();
    try {
      await axiosInstance.post(`/api/${activeTab}`, { domain, reason });
      setDomain('');
      setReason('');
      fetchEntries();
    } catch (err) {
      alert('Failed to add entry. Check your permissions.');
    }
  }

  async function handleDelete(entryUrl) {
    try {
      await axiosInstance.delete(entryUrl);
      fetchEntries();
    } catch (err) {
      alert('Failed to delete entry.');
    }
  }

  return (
    <div style={{ padding: '20px' }}>
      <h2>Admin Panel</h2>
      <div style={{ marginBottom: '20px' }}>
        <button
          onClick={() => setActiveTab('blacklist')}
          style={{ padding: '10px 20px', fontWeight: activeTab === 'blacklist' ? 'bold' : 'normal' }}
        >Blacklist Management</button>
        <button
          onClick={() => setActiveTab('whitelist')}
          style={{ padding: '10px 20px', fontWeight: activeTab === 'whitelist' ? 'bold' : 'normal' }}
        >Whitelist Management</button>
      </div>
      <form onSubmit={handleAdd} style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
        <input type="text" value={domain} onChange={(e) => setDomain(e.target.value)} placeholder="Domain (e.g. evil-site.com)" required style={{ padding: '8px' }} />
        <input type="text" value={reason} onChange={(e) => setReason(e.target.value)} placeholder="Reason" required style={{ padding: '8px' }} />
        <button type="submit" style={{ padding: '8px 16px' }}>Add Entry</button>
      </form>
      <table style={{ width: '100%', borderCollapse: 'collapse' }}>
        <thead>
          <tr style={{ borderBottom: '2px solid #ccc' }}>
            <th style={{ textAlign: 'left', padding: '8px' }}>Domain</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Reason</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Added By</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Date</th>
            <th style={{ textAlign: 'left', padding: '8px' }}>Actions</th>
          </tr>
        </thead>
        <tbody>
          {entries.map((entry) => (
            <tr key={entry.domain} style={{ borderBottom: '1px solid #eee' }}>
              <td style={{ padding: '8px' }}>{entry.domain}</td>
              <td style={{ padding: '8px' }}>{entry.reason}</td>
              <td style={{ padding: '8px' }}>{entry.addedBy}</td>
              <td style={{ padding: '8px' }}>{entry.createdAt ? new Date(entry.createdAt).toLocaleString() : 'N/A'}</td>
              <td style={{ padding: '8px' }}>
                <button onClick={() => handleDelete(entry._links.self.href)} style={{ color: 'red' }}>Delete</button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

export default AdminPanel;
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';

function Navbar() {
  const { user, logout } = useAuth();
  const navigate = useNavigate();

  function handleLogout() {
    logout();
    navigate('/login');
  }

  return (
    <nav style={{ display: 'flex', gap: '15px', padding: '10px 20px', backgroundColor: '#333', color: 'white', alignItems: 'center' }}>
      <Link to="/" style={{ color: 'white' }}>Dashboard</Link>
      <Link to="/history" style={{ color: 'white' }}>Scan History</Link>
      {user?.role === 'ADMIN' && (
        <Link to="/admin" style={{ color: 'white' }}>Admin</Link>
      )}
      <span style={{ marginLeft: 'auto' }}>{user?.username}</span>
      <button onClick={handleLogout} style={{ padding: '5px 10px' }}>Logout</button>
    </nav>
  );
}

export default Navbar;
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext';
import PrivateRoute from './components/PrivateRoute';
import Navbar from './components/Navbar';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import Dashboard from './pages/Dashboard';
import ScanHistoryTable from './pages/ScanHistoryTable';
import AdminPanel from './pages/AdminPanel';

function AppLayout() {
  const { token } = useAuth();

  return (
    <>
      {token && <Navbar />}
      <Routes>
        <Route path="/login" element={<LoginPage />} />
        <Route path="/register" element={<RegisterPage />} />
        <Route path="/" element={
          <PrivateRoute><Dashboard /></PrivateRoute>
        } />
        <Route path="/history" element={
          <PrivateRoute><ScanHistoryTable /></PrivateRoute>
        } />
        <Route path="/admin" element={
          <PrivateRoute><AdminPanel /></PrivateRoute>
        } />
      </Routes>
    </>
  );
}

function App() {
  return (
    <BrowserRouter>
      <AuthProvider>
        <AppLayout />
      </AuthProvider>
    </BrowserRouter>
  );
}

export default App;

Your frontend is now a complete, interactive application. Next up, you will configure the API Gateway with a JWT authentication filter and CORS settings so the frontend and backend communicate securely.

Configure API Gateway with JWT Authentication Filter

Your React frontend is built and ready to talk to your microservices. Right now, though, every request from the browser needs to know the exact port of each service.

The API Gateway solves this by acting as the single entry point for all traffic. In this step, you will configure routes for every microservice, add a JWT authentication filter that protects your endpoints, and set up CORS so the React frontend can communicate with the gateway.

In this step, get ready to:
  • Define gateway routes for user-service, phishing-service, and Spring Data REST endpoints.
  • Build a global JWT authentication filter that validates tokens and forwards user claims to downstream services.
  • Configure CORS and test the full end-to-end flow from React login to scan results.
Add JWT dependencies to the API Gateway

The gateway needs to validate JWT tokens before forwarding requests. The JJWT library handles token parsing and signature verification.

  • Open api-gateway/pom.xml in your editor.
  • Find the <dependencies> section and add the following three JJWT dependencies:
<!-- JWT token parsing and validation -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-api</artifactId>
    <version>0.12.6</version>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-impl</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt-jackson</artifactId>
    <version>0.12.6</version>
    <scope>runtime</scope>
</dependency>

Why three separate dependencies?

JJWT splits its functionality across three artifacts. jjwt-api provides the public classes you code against. jjwt-impl and jjwt-jackson are runtime-only libraries that handle the cryptographic work and JSON serialization behind the scenes.

Missing any one of the three will cause a runtime error, even though your code compiles fine.

  • Save pom.xml.
  • If your IDE prompts you to reload Maven dependencies, click Load Maven Changes (or the equivalent refresh button).

Maven not resolving the new dependencies?

Make sure you placed the dependencies inside the existing <dependencies> block, not outside it or inside <dependencyManagement>. Try running mvn clean install -DskipTests from the api-gateway directory to force a fresh dependency download.

Help me debug Maven dependency resolution errors in my API Gateway

Configure routes for all microservices

Spring Boot 4.0.x changed the YAML property prefix for Spring Cloud Gateway routes. The old prefix spring.cloud.gateway.routes no longer works.

If you are using the reactive gateway (the default), routes must now live under spring.cloud.gateway.server.webflux.routes. Getting this prefix wrong is one of the most common Spring Boot 4 migration issues.

  • Open api-gateway/src/main/resources/application.yml.
  • Replace the entire file content with the following configuration:
server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      server:
        webflux:
          routes:
            # Route authentication requests to user-service
            - id: user-service
              uri: lb://user-service
              predicates:
                - Path=/api/auth/**

            # Route URL detection requests to phishing-service
            - id: phishing-detect
              uri: lb://phishing-service
              predicates:
                - Path=/api/detect/**

            # Route scan history and stats to phishing-service
            - id: phishing-scans
              uri: lb://phishing-service
              predicates:
                - Path=/api/scans/**

            # Route blacklist management to phishing-service (Spring Data REST)
            - id: blacklist
              uri: lb://phishing-service
              predicates:
                - Path=/api/blacklist/**

            # Route whitelist management to phishing-service (Spring Data REST)
            - id: whitelist
              uri: lb://phishing-service
              predicates:
                - Path=/api/whitelist/**

# Eureka service discovery
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

What does this configuration do?

  • Each route has an id, a uri pointing to a service, and a Path predicate that determines which incoming requests match the route.
  • The lb:// prefix tells the gateway to use Eureka-based load balancing to find the target service instead of hardcoding a host and port.
  • The blacklist and whitelist routes point to phishing-service because that is where your Spring Data REST repositories are hosted.

Why the new YAML prefix?

Spring Cloud Gateway now supports both reactive (WebFlux) and servlet (MVC) modes. The old spring.cloud.gateway.routes prefix was ambiguous. Spring Boot 4.0.x split it into server.webflux.routes for reactive and server.webmvc.routes for MVC.

If you use the old prefix, the gateway starts but silently ignores your routes. Every request returns a 404.

  • Save application.yml.
  • Make sure your Eureka server, user-service, and phishing-service are all running.
  • Start (or restart) the API Gateway.
  • Open http://localhost:8761 in your browser. You should see API-GATEWAY, USER-SERVICE, and PHISHING-SERVICE all registered in the Eureka dashboard.

Gateway starts but routes return 404?

Double-check the YAML prefix. The routes must be under spring.cloud.gateway.server.webflux.routes (not spring.cloud.gateway.routes). The old prefix compiles without errors but silently drops all routes.

Also verify that your Spring Cloud BOM version is 2025.1.0 or later. Versions 2025.0.x are incompatible with Spring Boot 4 and will throw a NoSuchMethodError.

Help me debug my API Gateway routes not working with Spring Boot 4

Create the JWT utility class

The gateway needs to validate every incoming JWT token before forwarding the request. Your user-service already has its own JwtUtil class for generating tokens. The gateway needs a matching class that can read and verify those same tokens.

  • In your api-gateway project, create a new package called util inside your main source directory.
  • Create a new file called JwtUtil.java inside the util package and add the following code:
package com.example.apigateway.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;

@Component
public class JwtUtil {

    // Reads the signing key from application.yml
    @Value("${jwt.secret}")
    private String secret;

    // Converts the string secret into a cryptographic key
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // Returns true if the token signature and expiration are valid
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // Extracts the username (subject) from the token payload
    public String extractUsername(String token) {
        Claims claims = extractAllClaims(token);
        return claims.getSubject();
    }

    // Extracts the user role from a custom claim
    public String extractRole(String token) {
        Claims claims = extractAllClaims(token);
        return claims.get("role", String.class);
    }

    // Parses and returns all claims from the token
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}

What does this class do?

  • validateToken() checks the token's signature and expiration. If the token was tampered with or has expired, the JJWT parser throws an exception and the method returns false.
  • extractUsername() and extractRole() pull the username and role from inside the token. The gateway passes these values downstream as headers so your microservices know who made the request.
  • The @Value("${jwt.secret}") annotation reads the signing key from application.yml. This key must be identical to the one your user-service uses to generate tokens.
  • Open api-gateway/src/main/resources/application.yml and add the jwt.secret property at the bottom of the file:
# JWT signing key - must match the secret in user-service
jwt:
  secret: your-256-bit-secret-key-for-jwt-signing-must-be-long-enough

Match your user-service secret

Replace the placeholder value with the exact same secret string your user-service uses to sign tokens. If these two values do not match, every token the gateway receives will fail validation.

  • Save JwtUtil.java and application.yml.
  • You won't see a visible change yet. The next chunk creates the filter that uses this utility class.

✔️ Awesome, I've got everything!

Make sure you have saved both JwtUtil.java and the updated application.yml before continuing.

ⓧ I'd like to double check the full code

package com.example.apigateway.util;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;

@Component
public class JwtUtil {

    // Reads the signing key from application.yml
    @Value("${jwt.secret}")
    private String secret;

    // Converts the string secret into a cryptographic key
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    // Returns true if the token signature and expiration are valid
    public boolean validateToken(String token) {
        try {
            Jwts.parser()
                .verifyWith(getSigningKey())
                .build()
                .parseSignedClaims(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // Extracts the username (subject) from the token payload
    public String extractUsername(String token) {
        Claims claims = extractAllClaims(token);
        return claims.getSubject();
    }

    // Extracts the user role from a custom claim
    public String extractRole(String token) {
        Claims claims = extractAllClaims(token);
        return claims.get("role", String.class);
    }

    // Parses and returns all claims from the token
    private Claims extractAllClaims(String token) {
        return Jwts.parser()
            .verifyWith(getSigningKey())
            .build()
            .parseSignedClaims(token)
            .getPayload();
    }
}
Create the gateway authentication filter

A GlobalFilter in Spring Cloud Gateway intercepts every request before it reaches a route. Your filter will check for a valid JWT on all protected endpoints and reject unauthenticated requests with a 401 status.

  • Create a new package called filter inside your api-gateway main source directory.
  • Create a new file called JwtAuthGatewayFilter.java inside the filter package and add the following code:
package com.example.apigateway.filter;

import com.example.apigateway.util.JwtUtil;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
public class JwtAuthGatewayFilter implements GlobalFilter, Ordered {

    private final JwtUtil jwtUtil;

    public JwtAuthGatewayFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // Let login and register requests pass through without a token
        if (path.startsWith("/api/auth")) {
            return chain.filter(exchange);
        }

        // Extract the Authorization header
        String authHeader = exchange.getRequest()
            .getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);

        // Reject if no token is present
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // Strip the "Bearer " prefix to get the raw token
        String token = authHeader.substring(7);

        // Reject if the token is invalid or expired
        if (!jwtUtil.validateToken(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // Extract user info and forward as headers to downstream services
        String username = jwtUtil.extractUsername(token);
        String role = jwtUtil.extractRole(token);

        ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
            .header("X-User-Name", username)
            .header("X-User-Role", role)
            .build();

        // Continue the filter chain with the enriched request
        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }

    @Override
    public int getOrder() {
        // Run this filter before all others
        return -1;
    }
}

What does this filter do?

  • The filter first checks the request path. Anything under /api/auth (login and register) passes through without authentication.
  • For all other paths, it looks for an Authorization: Bearer <token> header. If the header is missing or the token fails validation, the filter returns HTTP 401 immediately.
  • When the token is valid, the filter extracts the username and role, then adds them as X-User-Name and X-User-Role headers on the forwarded request. Your downstream services can read these headers to know who made the request without re-parsing the JWT themselves.
  • getOrder() returns -1 so this filter runs before any route-specific filters. Authentication happens first, routing happens second.
  • Save JwtAuthGatewayFilter.java.
  • Restart the API Gateway.
  • Test that protected endpoints reject unauthenticated requests by running this in your terminal:
curl -i http://localhost:8080/api/scans/history

You should see a 401 Unauthorized response. This confirms the filter is blocking requests without a valid JWT.

Seeing a 404 instead of 401?

A 404 means the route is not matching, not that the filter is failing. Check that the Path predicates in application.yml use the correct Spring Boot 4.x prefix under spring.cloud.gateway.server.webflux.routes.

If you see a 200 or a 500 instead of 401, make sure the @Component annotation is present on JwtAuthGatewayFilter. Without it, Spring will not register the filter.

Help me debug my JWT gateway filter not intercepting requests

✔️ Awesome, I've got everything!

Make sure you have saved JwtAuthGatewayFilter.java before continuing.

ⓧ I'd like to double check the full code

package com.example.apigateway.filter;

import com.example.apigateway.util.JwtUtil;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component
public class JwtAuthGatewayFilter implements GlobalFilter, Ordered {

    private final JwtUtil jwtUtil;

    public JwtAuthGatewayFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getPath();

        // Let login and register requests pass through without a token
        if (path.startsWith("/api/auth")) {
            return chain.filter(exchange);
        }

        // Extract the Authorization header
        String authHeader = exchange.getRequest()
            .getHeaders()
            .getFirst(HttpHeaders.AUTHORIZATION);

        // Reject if no token is present
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // Strip the "Bearer " prefix to get the raw token
        String token = authHeader.substring(7);

        // Reject if the token is invalid or expired
        if (!jwtUtil.validateToken(token)) {
            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            return exchange.getResponse().setComplete();
        }

        // Extract user info and forward as headers to downstream services
        String username = jwtUtil.extractUsername(token);
        String role = jwtUtil.extractRole(token);

        ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
            .header("X-User-Name", username)
            .header("X-User-Role", role)
            .build();

        // Continue the filter chain with the enriched request
        return chain.filter(exchange.mutate().request(mutatedRequest).build());
    }

    @Override
    public int getOrder() {
        // Run this filter before all others
        return -1;
    }
}
Add CORS configuration to the gateway

Your React frontend runs on http://localhost:3000 and the gateway runs on http://localhost:8080. Browsers block cross-origin requests by default. Without CORS configuration, every API call from the frontend will be silently rejected.

  • Open api-gateway/src/main/resources/application.yml.
  • Add the following CORS section inside the spring.cloud.gateway block, at the same indentation level as server:
      # CORS: allow the React frontend to call the gateway
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins:
              - "http://localhost:3000"
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders:
              - Authorization
              - Content-Type

What does this CORS config do?

  • [/**] applies the CORS policy to all routes on the gateway.
  • allowedOrigins restricts which domains can call your API. Only your React app at http://localhost:3000 is permitted.
  • OPTIONS is included in allowedMethods because browsers send a preflight OPTIONS request before any non-simple request. Without it, the browser blocks the actual request before it even happens.
  • Save application.yml.
  • Restart the API Gateway.

YAML indentation errors on startup?

The globalcors key must be nested under spring.cloud.gateway at the same level as server. If it is indented too far (inside server.webflux), Spring will ignore it. Use the complete file below to verify your indentation.

Help me fix YAML indentation in my Spring Cloud Gateway configuration

✔️ Awesome, I've got everything!

Make sure you have saved application.yml and restarted the gateway.

ⓧ I'd like to double check the full code

server:
  port: 8080

spring:
  application:
    name: api-gateway
  cloud:
    gateway:
      server:
        webflux:
          routes:
            # Route authentication requests to user-service
            - id: user-service
              uri: lb://user-service
              predicates:
                - Path=/api/auth/**

            # Route URL detection requests to phishing-service
            - id: phishing-detect
              uri: lb://phishing-service
              predicates:
                - Path=/api/detect/**

            # Route scan history and stats to phishing-service
            - id: phishing-scans
              uri: lb://phishing-service
              predicates:
                - Path=/api/scans/**

            # Route blacklist management to phishing-service (Spring Data REST)
            - id: blacklist
              uri: lb://phishing-service
              predicates:
                - Path=/api/blacklist/**

            # Route whitelist management to phishing-service (Spring Data REST)
            - id: whitelist
              uri: lb://phishing-service
              predicates:
                - Path=/api/whitelist/**

      # CORS: allow the React frontend to call the gateway
      globalcors:
        cors-configurations:
          '[/**]':
            allowedOrigins:
              - "http://localhost:3000"
            allowedMethods:
              - GET
              - POST
              - PUT
              - DELETE
              - OPTIONS
            allowedHeaders:
              - Authorization
              - Content-Type

# Eureka service discovery
eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka/

# JWT signing key - must match the secret in user-service
jwt:
  secret: your-256-bit-secret-key-for-jwt-signing-must-be-long-enough
Test the full authentication flow

Everything is wired together now. Time to test the complete chain from React login to scan results.

  • Make sure all services are running: Eureka server, user-service, phishing-service, and the API Gateway.
  • Start your React frontend by running this command from your frontend directory:
npm start
  • Open http://localhost:3000 in your browser.
  • Register a new user on the register page.
  • Log in with the credentials you just registered.
  • On the Dashboard, enter a URL in the scan form and click Scan.
  • You should see a scan result card with a risk score, risk level, and color-coded badge.
  • Navigate to Scan History in the navbar. You should see the scan you just performed listed in the table.

What just happened end to end?

The React app sent your login request to http://localhost:8080/api/auth/login. The gateway let it pass through (no JWT needed for auth endpoints) and routed it to user-service.

When you scanned a URL, the axios interceptor attached the JWT to the Authorization header. The gateway filter validated the token, extracted your username and role, added them as X-User-Name and X-User-Role headers, and forwarded the request to phishing-service.

Seeing CORS errors in the browser console?

Open your browser's developer tools and check the Console tab. If you see a message like Access-Control-Allow-Origin missing, verify that the globalcors block is inside spring.cloud.gateway (not under spring.cloud.gateway.server.webflux).

Also make sure the allowedOrigins value is exactly http://localhost:3000 with no trailing slash.

Help me fix CORS errors between my React frontend and Spring Cloud Gateway

Login works but scan returns 401?

This means the JWT signing key in your gateway does not match the key in your user-service. Open both application.yml files and confirm the jwt.secret values are identical.

Also check that your user-service stores the role inside the JWT as a claim named role. If it uses a different claim name (like roles or authorities), update the extractRole() method in the gateway's JwtUtil to match.

Help me debug why my gateway rejects valid JWT tokens from user-service

Your API Gateway is now the single secured entry point for the entire system. Every request passes through JWT validation before reaching any microservice. Next up, you will containerize everything with Docker Compose so the full platform runs with a single command.

Dockerize Everything with Docker Compose

Your entire phishing detection platform works end to end on your local machine. Users can register, log in, scan URLs, view their scan history, and manage blacklists through the React dashboard.

The problem is that "it works on my machine" does not help anyone else run your system. Docker Compose lets you package every microservice, the database, and the frontend into containers that start together with a single command. In this step, you will containerize each service and orchestrate the full system.

In this step, get ready to:
  • Create multi-stage Dockerfiles for each Java microservice and the React frontend.
  • Write a Docker Compose file to orchestrate all services and PostgreSQL together.
  • Test the full containerized system end to end.
Create multi-stage Dockerfiles for each service

A multi-stage Docker build uses two separate stages inside a single Dockerfile. The first stage compiles your code using a full JDK image. The second stage copies only the compiled JAR into a slim JRE image for runtime.

This keeps your final container small and secure because build tools never ship to production.

  • Create a file called Dockerfile in your eureka-server directory with the following content:
# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8761
ENTRYPOINT ["java", "-jar", "app.jar"]

What does this Dockerfile do?

Stage 1 (build) starts from eclipse-temurin:17-jdk, copies all source files into the container, and runs the Maven wrapper to compile a JAR. The -DskipTests flag skips unit tests during the Docker build to save time.

Stage 2 starts fresh from eclipse-temurin:17-jre, which has just enough to run Java. It copies only the compiled JAR from stage 1 and exposes the Eureka port (8761). The final image is much smaller than if you shipped the full JDK.

  • Save the file.

Seeing a permission denied error on mvnw later?

If ./mvnw is not executable, add RUN chmod +x mvnw on a new line directly before the RUN ./mvnw package line in Stage 1.

help me fix a permission denied error when building my eureka-server Dockerfile

The remaining three Java microservices use the exact same Dockerfile structure. The only difference is the EXPOSE port number.

  • Create a Dockerfile in the user-service directory with this content:
# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "app.jar"]
  • Create a Dockerfile in the phishing-service directory with this content:
# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
  • Create a Dockerfile in the api-gateway directory with this content:
# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Why are these Dockerfiles nearly identical?

All four Java microservices follow the same build lifecycle: compile with Maven, package a JAR, and run it. The only difference between them is the EXPOSE port, which matches the server.port in each service's application.yml.

  • Save all three files.

The React frontend needs a different Dockerfile because it is a JavaScript application, not a Java one. The first stage compiles the React app with Node.js. The second stage serves the static output files with Nginx, a lightweight web server.

  • Create a Dockerfile in your frontend directory with this content:
# Stage 1: Build the React application
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Stage 2: Serve the static files with Nginx
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80

What does this Dockerfile do?

Stage 1 uses node:20-alpine to install dependencies and run npm run build, which compiles your React code into static HTML, CSS, and JavaScript files in a build/ folder.

Stage 2 copies those static files into Nginx's default serving directory. Nginx handles serving the files to browsers. The package*.json copy-then-install pattern caches the npm install layer so rebuilds are faster when only source code changes.

  • Save frontend/Dockerfile.
  • Confirm you now have a Dockerfile in each of these five directories: eureka-server, user-service, phishing-service, api-gateway, and frontend.

Missing one of the Dockerfiles?

Make sure each Dockerfile is at the root of its service directory (not inside src/ or any subfolder). The file must be named exactly Dockerfile with a capital D and no file extension.

help me verify my Dockerfile placement across all five service directories

✔️ Awesome, I've got everything!

Great. Double check you have saved all five Dockerfiles before continuing.

ⓧ I'd like to double check the full code

# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8761
ENTRYPOINT ["java", "-jar", "app.jar"]
# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8083
ENTRYPOINT ["java", "-jar", "app.jar"]
# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8082
ENTRYPOINT ["java", "-jar", "app.jar"]
# Stage 1: Build the application with a full JDK
FROM eclipse-temurin:17-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests

# Stage 2: Run with a lightweight JRE-only image
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# Stage 1: Build the React application
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build

# Stage 2: Serve the static files with Nginx
FROM nginx:alpine
COPY --from=build /app/build /usr/share/nginx/html
EXPOSE 80
Orchestrate all services with Docker Compose

Right now, starting the system means launching five separate services plus PostgreSQL manually. Docker Compose lets you define every service, its dependencies, and its configuration in a single YAML file.

Before creating the Compose file, you need a small SQL script. When the PostgreSQL container starts for the first time, it runs any .sql files found in its /docker-entrypoint-initdb.d/ directory. You will use this to create both databases automatically.

  • Create a file called init.sql in the root of your project (the same level as your service directories) with the following content:
-- Create separate databases for each microservice
CREATE DATABASE phishing_db;
CREATE DATABASE user_db;
  • Save init.sql.
  • Create the Docker Compose configuration file in the root of your project by adding this content to a new file called docker-compose.yml:
version: '3.8'

services:
  # PostgreSQL database shared by microservices
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"

  # Service registry for microservice discovery
  eureka-server:
    build: ./eureka-server
    ports:
      - "8761:8761"

  # Handles user registration and JWT authentication
  user-service:
    build: ./user-service
    depends_on:
      - postgres
      - eureka-server
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/user_db
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: password
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/

  # URL phishing detection and risk scoring engine
  phishing-service:
    build: ./phishing-service
    depends_on:
      - postgres
      - eureka-server
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/phishing_db
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: password
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/

  # Routes external traffic to internal microservices
  api-gateway:
    build: ./api-gateway
    depends_on:
      - eureka-server
    ports:
      - "8080:8080"
    environment:
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/

  # React dashboard served by Nginx
  frontend:
    build: ./frontend
    ports:
      - "3000:80"

# Named volume keeps database data across container restarts
volumes:
  postgres-data:

What does this Compose file do?

  • postgres uses the official postgres:16 image. It mounts init.sql into the entrypoint directory so both databases are created on first boot.
  • depends_on controls startup order. The user-service and phishing-service wait for both postgres and eureka-server to start first.
  • Environment variables like SPRING_DATASOURCE_URL override the values in each service's application.yml. The hostname postgres refers to the container name on Docker's internal network, replacing localhost.
  • The postgres-data named volume ensures your database data survives container restarts and rebuilds.
  • The frontend maps host port 3000 to container port 80 (Nginx's default), so you can still access the dashboard at http://localhost:3000.
  • Save docker-compose.yml.
  • Verify your project root now contains docker-compose.yml and init.sql alongside your five service directories by running:
ls

You should see api-gateway, docker-compose.yml, eureka-server, frontend, init.sql, phishing-service, and user-service listed in the output.

Missing docker-compose.yml or init.sql?

Both files must be at the project root, the same directory that contains your service folders. If you created them inside a service directory by mistake, move them up one level.

help me fix my project structure for Docker Compose

Why does depends_on not guarantee the service is ready?

Docker Compose's depends_on only waits for the container to start, not for the application inside it to finish booting. PostgreSQL might still be initializing when user-service tries to connect.

Spring Boot's built-in retry logic handles this gracefully. It will attempt to reconnect to the database several times before giving up. If a service fails on the first attempt, it usually succeeds within a few seconds.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

-- Create separate databases for each microservice
CREATE DATABASE phishing_db;
CREATE DATABASE user_db;
version: '3.8'

services:
  # PostgreSQL database shared by microservices
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"

  # Service registry for microservice discovery
  eureka-server:
    build: ./eureka-server
    ports:
      - "8761:8761"

  # Handles user registration and JWT authentication
  user-service:
    build: ./user-service
    depends_on:
      - postgres
      - eureka-server
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/user_db
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: password
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/

  # URL phishing detection and risk scoring engine
  phishing-service:
    build: ./phishing-service
    depends_on:
      - postgres
      - eureka-server
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/phishing_db
      SPRING_DATASOURCE_USERNAME: postgres
      SPRING_DATASOURCE_PASSWORD: password
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/

  # Routes external traffic to internal microservices
  api-gateway:
    build: ./api-gateway
    depends_on:
      - eureka-server
    ports:
      - "8080:8080"
    environment:
      EUREKA_CLIENT_SERVICEURL_DEFAULTZONE: http://eureka-server:8761/eureka/

  # React dashboard served by Nginx
  frontend:
    build: ./frontend
    ports:
      - "3000:80"

# Named volume keeps database data across container restarts
volumes:
  postgres-data:
Test the full containerized system

Everything is configured. Time to spin up the entire platform in containers and verify it works end to end.

Stop your local services first

If you still have PostgreSQL, Eureka, or any of the microservices running locally, stop them now. Docker Compose will try to bind to the same ports (5432, 8080, 8761, etc.) and will fail if they are already in use.

  • Open a terminal in the root of your project (the directory containing your docker-compose.yml).
  • Build and start all containers by running this command:
docker compose up --build

What does this command do?

docker compose up starts all the services defined in your docker-compose.yml. The --build flag forces Docker to rebuild every image from the Dockerfiles instead of using cached versions. This is important the first time you run it and after any code changes.

The first build will take several minutes because Docker downloads base images and compiles all four Java services. Subsequent builds are faster thanks to layer caching.

Wait for the logs to settle. You will see output from all six containers interleaved in the terminal.

Seeing a port already in use error?

This means a local process is still using one of the ports Docker needs. Stop any locally running PostgreSQL, Spring Boot services, or React dev servers. On macOS/Linux, run lsof -i :5432 (or whichever port is conflicting) to find the process, then kill it.

help me resolve a port conflict when running docker compose up

Once the logs show all services have started, verify each part of the system.

  • Open http://localhost:8761 in your browser.
  • Confirm the Eureka dashboard loads and shows USER-SERVICE, PHISHING-SERVICE, and API-GATEWAY registered under **Instances currently registered with Eureka**.
  • Open http://localhost:3000 in a new tab.
  • Confirm the React login page loads.

Services take a moment to register

It can take 30 to 60 seconds for all three services to appear in the Eureka dashboard after startup. If you only see one or two, wait a moment and refresh the page.

  • Register a new test user through the React registration page.
  • Log in with the new user's credentials.
  • Submit a URL for scanning (try something like http://suspicious-login-verify.xyz/account).
  • Confirm you see a risk score and risk level in the scan result card.
  • Navigate to the scan history page and confirm your scan appears in the table.

React loads but API calls fail?

Open your browser's developer console and check for CORS or network errors. If you see ERR_CONNECTION_REFUSED, the API Gateway container might still be starting. Wait 30 seconds and try again.

If you see CORS errors, confirm that the allowedOrigins in your API Gateway's application.yml includes http://localhost:3000. Also confirm that your React app's axios base URL still points to http://localhost:8080.

help me debug API connection issues between my React frontend and Spring Boot API Gateway in Docker

Finally, verify that your database data persists across container restarts.

  • Press Ctrl+C in the terminal to stop Docker Compose.
  • Start the system again by running:
docker compose up
  • Open http://localhost:3000 and log in with the same user you registered earlier.
  • Confirm your previous scan result still appears in the scan history table.

If your scan history is intact, the named volume is working correctly. Your database data survives container restarts.

Previous user or scan data is missing?

If the databases were re-created from scratch, the volume may not have persisted. Make sure you used docker compose up (without --build for this restart) and that you did not run docker compose down -v. The -v flag deletes volumes, which wipes all stored data.

help me troubleshoot why my Docker volume is not persisting PostgreSQL data

Your entire phishing detection platform now runs with a single docker compose up command. Anyone with Docker installed can clone your repo and spin up the full system. Ready for a stretch challenge? The Secret Mission takes this to the cloud with AWS.

Secret mission

Deploy to AWS ECS with Fargate

Your phishing detection platform runs perfectly on your local machine with Docker Compose. But what if you could make it accessible to anyone on the internet? In this Secret Mission, you will deploy your entire microservices system to AWS using ECS Fargate, a serverless container platform.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The core project runs entirely locally with PostgreSQL, Docker, and the free URLhaus API, so there are no ongoing costs unless you completed the Secret Mission and deployed to AWS.

Cost warning

If you completed the Secret Mission and deployed to AWS, your ECS Fargate tasks, RDS database, Application Load Balancer, and ECR repositories are actively incurring charges. The AWS free tier covers a db.t3.micro RDS instance and some Fargate usage, but you should monitor your billing dashboard closely.

If you are not actively using the deployment, choose Pause or Delete below to avoid unexpected charges.

Resources you used:

  • PostgreSQL databases (phishing_db and user_db) running locally.
  • Docker containers and images for all microservices and the React frontend.
  • Spring Boot microservices: user-service, phishing-service, api-gateway, and eureka-server.
  • React frontend application.
  • URLhaus API Auth-Key (free, no ongoing cost).
  • Docker Compose configuration (docker-compose.yml).
  • Secret Mission only: AWS resources including ECS cluster, Fargate tasks, RDS instance, Application Load Balancer, and ECR repositories.

Keep everything running

No action needed. Choose this if you are still actively building or testing your phishing detection platform.

  • Your local PostgreSQL databases, Docker containers, and microservice code stay as they are.
  • If you completed the Secret Mission, monitor your AWS billing dashboard regularly to avoid surprise charges.
  • The AWS free tier covers a db.t3.micro RDS instance and some Fargate usage for new accounts, but exact Fargate limits vary.

Pause - I'll come back to this later

Shut down running processes to free up memory and reduce costs while keeping your project files intact.

  • Stop your Docker Compose stack by running docker compose down in your project directory. This stops all containers but preserves your images and volumes.
  • Stop your local PostgreSQL service if it is running as a background process.
  • Your project source code, Dockerfiles, and configuration files remain on disk. You can restart everything later with docker compose up --build.
  • Secret Mission only: In the AWS console, set all ECS service desired counts to 0 to stop Fargate tasks. Stop your RDS instance from the RDS dashboard. You will still pay for RDS storage while the instance is stopped.

Delete - I don't want to use this again

Remove all project resources and start fresh. This ensures zero ongoing costs.

  • Stop and remove all Docker containers, images, and volumes by running docker compose down -v --rmi all in your project directory.
  • Drop your local PostgreSQL databases by running psql -U postgres, then DROP DATABASE phishing_db; and DROP DATABASE user_db;.
  • Delete your project folder from your Desktop if you no longer need the source code.
  • Your URLhaus Auth-Key is free and does not need to be revoked. You can leave your account active at no cost.
  • Secret Mission only: Delete all AWS resources to ensure $0 ongoing charges. Delete your ECS services and ECS cluster. Delete the Application Load Balancer. Delete your RDS instance (skip final snapshot if you do not need the data). Delete all ECR repositories. Delete any CloudWatch log groups created by your services.

Mission accomplished

That's a Wrap!

Nice work! You just built a production-grade, enterprise-level URL phishing detection platform from the ground up.

You've learned how to:

  • Built a user-service microservice with Spring Security 7 and JWT authentication, wired up PostgreSQL with Spring Data JPA across multiple microservices, and enforced role-based access control through the API Gateway.
  • Implemented three design patterns (Strategy, Factory, Adapter) in a real phishing detection engine with weighted risk scoring, and integrated live threat data from the URLhaus threat intelligence feed via the Adapter pattern.
  • Built a complete React dashboard with URL scanning, scan history, analytics charts, an admin panel for blacklist/whitelist management via Spring Data REST, and orchestrated the entire system with Docker Compose.
  • Deployed the full microservices system to AWS ECS Fargate with an RDS PostgreSQL instance and an Application Load Balancer.

Ready to quiz yourself?