Skip to content
Educora
Advanced24 min27 / 27

Security and best practices

Protect users and code: XSS and CSRF, input validation, keeping secrets out of the browser, ESLint and Prettier, and moving to TypeScript step by step.

Check yourself
In this lesson you will learn
  • Explain XSS and CSRF and prevent them with textContent, escaping, CSP, SameSite cookies 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.

JavaScript
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);
In the safe version the user simply sees the text <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 &lt;, & becomes &amp;, and the browser shows them as text instead of treating them as tags.

JavaScript
function escapeHtml(text) {
  const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
  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
&lt;img src=x onerror=&quot;stealCookies()&quot;&gt;
Tom &amp; Jerry say &quot;hi&quot;

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.

Text
Set-Cookie: session=8f3a2c91; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=3600
AttributeWhat it protects against
HttpOnlyJavaScript cannot read the cookie — XSS cannot steal the session
Securethe cookie is sent only over HTTPS
SameSite=Laxthe cookie is not attached to POST requests from other sites — CSRF gets much harder
Max-Agelimits 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.

JavaScript
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.

JavaScript
// 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'] }],
    },
  },
];
ESLint's modern “flat config” file: the recommended rules plus three of our own. npx eslint . checks the whole project.
JavaScript
const user={name:"Aysel",age:15,city:'Baku'}
function greet(u){return "Hi, "+u.name}
if(user.age>=14){console.log(greet(user))}
Unformatted code. After running npx prettier --write --single-quote . it looks like the block below.
JavaScript
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:

  1. 1
    Add tsconfig.json

    With the allowJs and checkJs options — TypeScript starts checking your existing JS files too.

  2. 2
    Write JSDoc types

    Comments such as /** @param {number} price */ add types without renaming the file.

  3. 3
    Rename files to .ts one by one

    Start with the simplest modules that have few dependencies.

  4. 4
    Turn on strict

    Finally, "strict": true catches the most bugs, for example values that may be null.

TypeScript
// 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' }]);
Expected output
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'.
Running 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.
Exercise

Write escapeHtml(text): it replaces &, <, >, " and ' with &amp;, &lt;, &gt;, &quot; and &#39; respectively. One replace with a map of values is enough.

Exercise · JavaScript
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
&lt;b&gt;Hello&lt;/b&gt;
Tom &amp; Jerry
&lt;img src=x onerror=&quot;steal()&quot;&gt;
Exercise

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.

Exercise · JavaScript
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 a Content-Security-Policy.
  • CSRF: cookies are sent automatically; SameSite, CSRF tokens, Origin checks 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 .env listed 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.

1 / 10
Which way of showing a user's comment on the page is safe?