- Explain XSS and CSRF and prevent them with
textContent, escaping, CSP,SameSitecookies and CSRF tokens - Validate input on the server with allowlists and keep secrets out of client code and Git
- Set up ESLint and Prettier and move a project to TypeScript gradually
Your site has a comment field. Someone posts <img src=x onerror="stealCookies()"> as a comment. If your code adds comments with innerHTML, that script will run in every visitor's browser — with their session. The OWASP Top 10, a list of the most critical web risks, includes injection attacks, and XSS is one of them. Security is not a feature you add at the end — it is a set of everyday habits.
XSS: cross-site scripting
XSS happens when untrusted data is inserted into the page as HTML and the attacker's script runs in the name of your site: it steals cookies or acts on the user's behalf. Defences: write user text with textContent; if you really need HTML, clean it with a library such as DOMPurify; React escapes {value} by itself (except dangerouslySetInnerHTML); and the Content-Security-Policy header limits where scripts may be loaded from.
const comment = '<img src="x" onerror="stealCookies()">';
// dangerous: the text is parsed as HTML and onerror runs
list.innerHTML += `<li>${comment}</li>`;
// safe: the text stays text
const item = document.createElement('li');
item.textContent = comment;
list.append(item);<img ...> on the screen, and nothing runs.If you must build an HTML string (for example, a template on the server), escape the special characters: < becomes <, & becomes &, and the browser shows them as text instead of treating them as tags.
function escapeHtml(text) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(text).replace(/[&<>"']/g, (char) => map[char]);
}
console.log(escapeHtml('<img src=x onerror="stealCookies()">'));
console.log(escapeHtml('Tom & Jerry say "hi"'));▸ Expected output
<img src=x onerror="stealCookies()"> Tom & Jerry say "hi"
CSRF: cross-site request forgery
A user is logged in to their bank's website, and the session cookie is in the browser. Then they open another site, and that page submits a hidden form to the bank's /transfer address. The browser attaches the cookie to the request automatically, and the bank believes the user did it. Defences: cookies with the SameSite attribute (Chrome treats cookies without it as Lax), a secret random CSRF token in the form (the attacker's site cannot read it), checking the Origin header, and never changing data with a GET request.
Set-Cookie: session=8f3a2c91; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600| Attribute | What it protects against |
|---|---|
HttpOnly | JavaScript cannot read the cookie — XSS cannot steal the session |
Secure | the cookie is sent only over HTTPS |
SameSite=Lax | the cookie is not attached to POST requests from other sites — CSRF gets much harder |
Max-Age | limits the lifetime of the session |
Validate input, protect secrets
Never trust the client: everything that comes from the browser — forms, URLs, headers, JSON — can be forged; an attacker skips your interface and sends requests straight to the API. So validate input on the server: type, length, format and allowed values (an allowlist, not a blocklist). Validation in the browser is only for the user's convenience. Don't build SQL queries by gluing strings — use parameterised queries. In big projects, schema libraries (for example, zod) help.
function validateOrder(input) {
const errors = [];
const allowedSizes = ['S', 'M', 'L'];
if (typeof input.email !== 'string' || !/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test(input.email)) {
errors.push('email: invalid format');
}
if (!Number.isInteger(input.quantity) || input.quantity < 1 || input.quantity > 10) {
errors.push('quantity: must be an integer 1-10');
}
if (!allowedSizes.includes(input.size)) {
errors.push(`size: must be one of ${allowedSizes.join(', ')}`);
}
return errors.length ? { ok: false, errors } : { ok: true };
}
console.log(validateOrder({ email: 'leyla@mail.az', quantity: 2, size: 'M' }));
console.log(validateOrder({ email: 'not-an-email', quantity: '100', size: 'XXL' }));▸ Expected output
{ ok: true }
{ ok: false, errors: [ 'email: invalid format', 'quantity: must be an integer 1-10', 'size: must be one of S, M, L' ] }'100' is a string, so Number.isInteger rejects it — the type must be checked too. Returning all errors at once helps the user fix the form quickly.Secrets (API keys, passwords, tokens) become public the moment they reach browser code — any user can see them in DevTools. Your server should call the paid API, while the browser talks only to your server. Keep secrets in a .env file and add the file to .gitignore. In Vite, variables with the VITE_ prefix are built into the client code — which means they are public.
Code quality: ESLint, Prettier, TypeScript
ESLint finds bugs and bad patterns without running the code: unused variables, ==, undeclared names. Prettier formats code automatically, so the team never argues about style. Run both on save in the editor and in CI. To add ESLint to a project, npm init @eslint/config@latest is enough; for Prettier, npm install -D prettier.
// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
export default [
js.configs.recommended,
{
languageOptions: { globals: globals.browser },
rules: {
eqeqeq: 'error',
'no-unused-vars': 'warn',
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
];npx eslint . checks the whole project.const user={name:"Aysel",age:15,city:'Baku'}
function greet(u){return "Hi, "+u.name}
if(user.age>=14){console.log(greet(user))}npx prettier --write --single-quote . it looks like the block below.const user = { name: 'Aysel', age: 15, city: 'Baku' };
function greet(u) {
return 'Hi, ' + u.name;
}
if (user.age >= 14) {
console.log(greet(user));
}TypeScript adds static types to JavaScript: mistakes show up in the editor before the code runs. You don't have to convert a big project in one day — the move is done gradually:
- 1Add tsconfig.json
With the
allowJsandcheckJsoptions — TypeScript starts checking your existing JS files too. - 2Write JSDoc types
Comments such as
/** @param {number} price */add types without renaming the file. - 3Rename files to
.tsone by oneStart with the simplest modules that have few dependencies.
- 4Turn on
strictFinally,
"strict": truecatches the most bugs, for example values that may benull.
// shop.ts
interface Product {
id: number;
title: string;
price: number;
}
function total(products: Product[]): number {
return products.reduce((sum, p) => sum + p.price, 0);
}
total([{ id: 1, title: 'Pen', price: '2' }]);
total([{ id: 2, title: 'Notebook' }]);shop.ts(12,31): error TS2322: Type 'string' is not assignable to type 'number'.
shop.ts(13,8): error TS2741: Property 'price' is missing in type '{ id: number; title: string; }' but required in type 'Product'.npx tsc --noEmit: both bugs were found without running the code. In JavaScript, '2' would silently cause string concatenation, and the missing price would give NaN.Write escapeHtml(text): it replaces &, <, >, " and ' with &, <, >, " and ' respectively. One replace with a map of values is enough.
function escapeHtml(text) {
// replace & < > " ' with HTML entities
}
const comments = ['<b>Hello</b>', 'Tom & Jerry', '<img src=x onerror="steal()">'];
for (const comment of comments) {
console.log(escapeHtml(comment));
}▸ Expected output
<b>Hello</b> Tom & Jerry <img src=x onerror="steal()">
Prevent open redirects: isSafeRedirect(target) returns true in only two cases — a relative path that starts with a single / not followed by another / or \, or an https:// address whose hostname is exactly app.example.com.
function isSafeRedirect(target) {
// 1) relative path: /^\/(?![\/\\])/
// 2) otherwise parse with new URL() inside try...catch
return true;
}
const targets = [
'/lessons/42',
'//evil.example/login',
'/\\evil.example',
'https://app.example.com/profile',
'https://app.example.com.evil.example/',
'javascript:stealCookies()',
];
for (const target of targets) {
console.log(`${target} -> ${isSafeRedirect(target)}`);
}▸ Expected output
/lessons/42 -> true //evil.example/login -> false /\evil.example -> false https://app.example.com/profile -> true https://app.example.com.evil.example/ -> false javascript:stealCookies() -> false
Key points
- XSS: never insert user data as HTML — use
textContent, escaping or sanitising, and add aContent-Security-Policy. - CSRF: cookies are sent automatically;
SameSite, CSRF tokens,Originchecks and no changes via GET protect you. - Never trust the client: validate on the server with allowlists and use parameterised SQL queries.
- Secrets live only on the server and in a
.envlisted in.gitignore; revoke a leaked key at once. - ESLint finds bugs, Prettier formats, and TypeScript catches type errors before running — adopt it gradually with
checkJs.
Check yourself
10 questions. Every correct answer earns XP.