Skip to content
Computer Science Fundamentals
Source Code
← Home

Security

100 questions

Right
0
Wrong
0
Not answered
100
Difficulty
Number of questions

100 questions match this filter

Theory summary

Imagine your house. It has a front door, windows, a lock, a key under the mat and a neighbour who sometimes borrows sugar. Security is the job of thinking about all of those things: who can come in, what they can take, and what we do so that the wrong person cannot. Software has doors and keys too. A website has a login (the front door), cookies (little tickets that say "this person already came in") and a database (the cupboard where everything valuable is kept).

Most break-ins on the internet are not movie hackers typing fast. They happen because a programmer trusted something they should have checked: a name typed in a box, a number in an address, a file someone uploaded. This subject, called application security, teaches how programs fail when someone uses them in a way nobody expected, so we can build them so they do not fail.

Everything here is defensive. For each flaw we explain why it happens and, always, how to prevent it. Any code you see is the smallest example needed to recognise the problem, and it always comes next to its fix. The ideas follow the guides, the standard reference of the field.

At the end you will know the main families of web flaws (injection, scripts planted in pages, forged requests, weak passwords, stolen sessions, broken tokens, missing permission checks, tricked servers, unsafe uploads), the habits that stop each one, and the basics of cryptography and secrets that hold everything together.

How the web works: the pieces an attacker plays with

Before we study how things break, we need to know how a website normally works. Think of a restaurant. You (the browser) sit at a table and write an order on paper. A waiter carries it to the kitchen (the server). The kitchen cooks and the waiter brings back the dish. The paper order is called an , and the dish is the response.

  Browser                            Server
     |                                  |
     |  GET /invoices/1001  + cookie    |
     | -------------------------------> |  looks up data,
     |                                  |  builds a page
     |  200 OK  + page (HTML)           |
     | <------------------------------- |
     |                                  |
One round trip between browser and server

A few words will come back all the time. A cookie is a small note the server asks the browser to keep and to show again on every later visit, like a wristband at a water park: the staff do not remember your face, they just check the band. An origin is the exact address of a website: the protocol (https), the name (shop.test) and the port. Two pages with different origins are strangers to each other, and the browser enforces this with the .

WordEveryday pictureWhat it is
ClientThe customer at the tableThe browser or app of the user. The user controls it completely
ServerThe kitchenThe program we write and protect
CookieA wristbandA note the browser sends back automatically on each request
OriginA full street addressProtocol + name + port, for example https://shop.test:443
Untrusted inputA stranger's noteAnything that comes from outside: forms, URLs, headers, files, even data read back from the database
↑ Back to the contents

OWASP Top 10, threats and good habits

Where do we start? Doctors keep a list of the most common illnesses. Security people keep one too. The OWASP Top 10 is an awareness document, not a law and not a certificate. It groups flaws into ten risk categories, ordered by how often they appear in real data and by a survey of professionals. It is a good place to begin studying and reviewing, but it is not a complete list of every flaw, and passing a review against it does not prove an application is safe.

In this summary we use the 2021 edition. Its number one category is Broken Access Control: a logged-in person reaches things that are not theirs. Injection, which led the 2017 edition, moved to third place.

OWASP Top 10, 2021 edition
PositionCategoryIn one sentence
A01Broken Access ControlA user reads or changes what they should not
A02Cryptographic FailuresSensitive data is not protected, or is protected badly
A03InjectionData is mixed into code (SQL, shell) and runs as code
A04Insecure DesignThe plan itself lacks a needed control
A05Security MisconfigurationDefaults, open features and noisy errors left in production
A06Vulnerable and Outdated ComponentsOld libraries with known holes
A07Identification and Authentication FailuresWeak passwords, weak sessions, weak login
A08Software and Data Integrity FailuresUpdates and data trusted without checking where they came from
A09Security Logging and Monitoring FailuresNobody notices the break-in
A10Server-Side Request Forgery (SSRF)The server is tricked into calling places it should not

Thinking like a defender: threat modelling

means answering four questions early: What are we building? What can go wrong? What will we do about it? Did we do a good job? It pays the most at design time, before any code exists, because changing a drawing is cheap and changing a finished system is expensive. When the architecture changes, you do it again.

A handy checklist for "what can go wrong" is STRIDE. Each letter is a kind of threat, and each one breaks a property we want to keep. Knowing the broken property tells you which kind of control to use.

STRIDE
LetterThreatProperty brokenTypical control
SSpoofing: pretending to be someone elseAuthenticityStrong authentication
TTampering: changing dataIntegritySignatures, checks on the server
RRepudiation: denying you did itNon-repudiationLogs and audit trails
IInformation disclosure: leaking secretsConfidentialityEncryption, access control
DDenial of service: making it stop workingAvailabilityLimits, capacity, filtering
EElevation of privilege: gaining more rightsAuthorisationPermission checks, least privilege

Principles that appear everywhere

  • Least privilege: each person, program or database account gets only the permissions its job needs. A class pet-sitter needs the key to the classroom, not to the whole school. If the app only reads products, its database account must not be allowed to delete tables.
  • Defence in depth: stack several independent protections, because any one can fail. A castle has a moat, a wall and guards.
  • Secure by default and deny by default: the safe choice is the one you get when you do nothing.
  • Insecure design versus insecure implementation: a bug is code that does not do what the plan said. Insecure design is code that does exactly what the plan said, when the plan forgot a control. Example: a shop lets anyone reserve unlimited stock for 24 hours without paying. Bots reserve everything. The code has no bug; the rule is missing a limit. The fix is to redesign the rule, and the prevention is to write abuse cases next to use cases.
  • Security misconfiguration: the code is fine but the deployment is not. Default admin/admin accounts, directory listings turned on and error pages showing a full stack trace are classic examples. The cure is one repeatable hardening process for every environment.
↑ Back to the contents

Cryptography and TLS: secret codes that protect the road

is the toolbox behind passwords, tokens and the padlock in your browser. You do not need the maths. You need to know which tool answers which question.

Two ways to lock a message

Symmetric cryptography uses the same key to lock and unlock, like a diary with one key that both friends own. It is very fast, but there is a problem: how do the two friends agree on the key without anyone else hearing? Asymmetric cryptography uses a pair of keys. The public key can be shown to the whole world, like a mailbox slot anyone can drop letters into. The private key stays secret, like the key that opens the mailbox. It solves the sharing problem, but it is much slower.

AspectSymmetricAsymmetric
KeysOne shared secret keyA public key and a private key
SpeedVery fastMuch slower
Main problemHow to share the key safelyCost, so it is used only briefly
Used in TLS forEncrypting all the data of the connectionThe handshake: proving who the server is and agreeing on keys

Real systems use both, called hybrid cryptography. TLS uses the slow asymmetric tool once, at the start, to agree on a secret without any earlier secret. After that, everything is protected with the fast symmetric tool.

Hash is not encryption

A hash function is like a meat grinder: any input goes in, a fixed-size "fingerprint" comes out, and you cannot turn the meat back. It is one-way. A cipher is a reversible lock: with the key you get the original back. They answer different needs, and using one in place of the other is a classic mistake.

ToolReversible?Output sizeUse it for
Hash (for example SHA-256)NoAlways the sameChecking that a file did not change
Cipher (for example AES)Yes, with the keyAbout the size of the inputKeeping data secret
MAC / HMACNoFixedChecking that a message was not changed by someone without the key
Digital signatureNoFixedProving who wrote a message when nobody shares a key

Why is a plain hash not enough to protect a message on the road? Because if someone on the path changes the message, they can simply compute a new hash for the new message. The hash has no secret in it. A such as HMAC mixes a secret key into the hash. Without the key, nobody can produce a valid value for a changed message. When the two sides share no key, a digital signature plays the same role.

TLS: what the padlock really means

TLS is the protocol behind https. Locking the conversation is not enough, because you could be locking it for the wrong person. So the server shows a certificate, a document signed by a trusted authority saying "this public key belongs to this domain". The browser validates it. What does that prove? That the public key really belongs to the domain you are visiting, and that the server holds the matching private key. It does not prove that the site is honest. A scam site can have a perfectly valid padlock.

1. Browser: "hello, I want shop.test"
2. Server : certificate (public key + signature)
          browser checks: is it for shop.test?
3. Both   : agree on fresh session keys
          (asymmetric, ephemeral Diffie-Hellman)
4. From now on: fast symmetric encryption
          + integrity check on every message
TLS in three steps

There is one more nice property. Suppose someone records all your encrypted traffic today, and in five years steals the server's private key. Can they read the old recordings? With TLS 1.3, no. This is called forward secrecy. Each connection creates its keys with an Diffie-Hellman exchange, with random numbers discarded at the end. The certificate key is used only to prove identity, not to protect the traffic, so stealing it later opens nothing from the past.

↑ Back to the contents

Injection: when data turns into commands

Imagine you tell a robot waiter: "Bring the soup". Now a mischievous child writes on the order slip: "soup, and also open the cash register". If the robot reads the slip as one long list of instructions, it obeys both. That is injection: the program is supposed to treat what the user wrote as plain data, but it lets that data be read as code. The two got mixed in one piece of text.

In SQL injection the "robot" is the database. The program builds a query by gluing text together, so a value with a quote in it can change the shape of the command. The problem is the glue, not the user.

const email = body.email; // comes from the user
const sql = "SELECT id, name FROM users WHERE email = '" + email + "'";
const result = await db.query(sql);
// an email such as  x' OR '1'='1  changes what the query means
Vulnerable: the value becomes part of the SQL text
const sql = "SELECT id, name FROM users WHERE email = $1";
const result = await db.query(sql, [email]);
// the database fixes the shape of the command first,
// then receives email only as a value
Fixed: a parameterised query keeps text and values apart

Why does the second version work? The database first reads the text with its placeholders ($1, or ? in other databases) and decides what the command is. Only afterwards does it receive the values, and a value can only fill a data position. So x' OR '1'='1 is just an odd email to compare against, not a condition. This is called a , and it is the main defence because it removes the cause.

Other defences and things that do not work

  • Validation (is the value the right length, type and format?) is a good extra layer. It decides whether a value makes sense. It does not replace parameters. A surname field must still accept O'Brien, so you accept the apostrophe and send the value as a parameter.
  • Least privilege: a database account that can only read what it needs limits the damage if something slips.
  • Blocklists and manual escaping (removing quotes, banning words) treat symptoms and are easy to get around. Do not rely on them.
  • ORMs (libraries that write SQL for you) parameterise their normal methods, so they greatly reduce the risk. But raw SQL built with string interpolation inside an ORM is as injectable as any other.
  • Data from your own database is untrusted too. If a name was stored safely and later glued into another query, that second query is vulnerable. Parameterising is a rule for every query, not only for what just arrived.
  • Stored procedures are not magic. If a procedure builds dynamic SQL by gluing its argument into a string, it is injectable. Inside the database, dynamic SQL needs its own parameters (in PostgreSQL, EXECUTE ... USING).

When the structure must change

Placeholders can stand for values, not for column names, table names or ASC/DESC. Suppose a list can be sorted by price or name. Do not paste the received word into the query. Use the word only as a key into a fixed list and take the real column name from that list.

const columns: Record<string, string> = { price: "price", name: "name" };
const column = columns[query.sort];
if (!column) return status(400);
const sql = `SELECT id, name FROM products ORDER BY ${column}`;
// the text comes from our list, never from the user
Allow-list for a structural part

Parameters also do not change what a value means inside a small language of its own. In LIKE '%' || $1 || '%', if the user sends %, there is no injection, but % still works as the wildcard "anything" and matches every row. If the search must be literal, escape % and _ in the value.

The same idea with the operating system

Command injection is the same mistake with another interpreter: the shell. A server that builds the text ping -c 1 plus a host typed by the user and hands it to a shell is in trouble, because characters like ;, | and && mean "run another command" to a shell. The cure: do not use the shell. Start the program directly, giving the executable and a list of arguments, so each argument is delivered as data, and validate the host against a strict format.

// Vulnerable: a shell reads the whole string
exec("ping -c 1 " + host);

// Fixed: no shell, arguments in a list, host validated first
if (!/^[a-zA-Z0-9.-]{1,253}$/.test(host)) return status(400);
spawn("ping", ["-c", "1", host]);
Vulnerable and fixed
↑ Back to the contents

Cross-site scripting (XSS): a stranger's script on your page

Injection has a cousin where the "robot" is the browser. A browser reads HTML text and builds the page from it. Some pieces of HTML are code, such as a <script> block or an attribute like onerror="...". In cross-site scripting (XSS) a script written by someone else ends up inside your page, and the browser cannot tell it was not written by you. It runs with the origin of the vulnerable site, so it can see the page, read what the page can read and act in the name of whoever has the page open.

The root cause is always the same: data was written into a place that is parsed as code without being encoded for that place. There are three classic types, and the difference is only how the data reaches the page.

TypeHow the data arrivesExample
StoredThe server saves it and serves it to everyone who opens the pageA comment with a script tag, shown to every reader of the article
ReflectedThe server echoes something from the request right backA search page that prints your search text unchanged
DOM-basedThe page's own JavaScript reads the data and inserts it into the page. The server may never see itA script that puts the part after # in the address into the page

Defence 1: encode at output

When you write user data into HTML, turn the characters that have special meaning into harmless look-alikes: < becomes &lt;, > becomes &gt;, & becomes &amp;, and quotes also get replaced. The browser then shows the characters as text instead of reading them as tags. Nothing is deleted; the visitor sees exactly what was typed.

Without encoding:
  server writes : <span> <script>bad()</script> </span>
  browser sees  : a script tag  -> RUNS

With encoding:
  server writes : <span> &lt;script&gt;bad()&lt;/script&gt; </span>
  browser sees  : plain text    -> shown, never runs
Why encoding stops the script

Encode at output time, not when saving to the database. The right encoding depends on where the data lands (HTML text, an attribute, a URL, JavaScript, CSS), and you only know that at the moment you write the page. Data stored already encoded for HTML would look wrong in a JSON API and would still be unsafe inside JavaScript. The context matters: something harmless in one place can be code in another. In an href or src, for instance, the URL scheme must also be checked, because a javascript: link runs code when clicked. Accept only http: and https: from an allow-list.

Defence 2: safe browser APIs

For DOM-based XSS the server has nothing to encode, because the dangerous part never travels to it. The fix is inside the script: use APIs that treat the value as text.

const name = decodeURIComponent(location.hash.slice(1));

// Vulnerable: innerHTML parses the text as HTML
// document.querySelector('#greeting').innerHTML = 'Hello, ' + name;

// Fixed: textContent always inserts plain text
document.querySelector('#greeting').textContent = 'Hello, ' + name;
A DOM-based XSS and its fix
Safe: treats the value as textDangerous: parses the value as markup
textContent, createTextNodeinnerHTML, outerHTML
setAttribute for attributes that do not run codedocument.write, insertAdjacentHTML

Frameworks, sanitisers and what is left

  • Modern UI frameworks encode automatically the values you interpolate in templates. XSS comes back when you use their "raw HTML" escape hatch with user data, put a user URL in href, or touch the DOM directly.
  • If users must be allowed to send formatting (bold, lists, links), encoding would ruin it. Pass the HTML through a maintained sanitiser that keeps only an allow-list of tags, attributes and URL schemes. Allow-lists fail safe: anything unknown is removed. Do not write a sanitiser yourself; they are very hard to get right.
  • HttpOnly cookies are a mitigation, not a fix. The script cannot read that cookie, but it can still send requests as the victim, because the browser attaches the cookie by itself. The fix is still encoding and safe sinks, with a Content Security Policy as an extra layer (next section).
↑ Back to the contents

Content Security Policy and security headers

Encoding is the main defence against XSS, but programmers forget things. So we add a safety net: we tell the browser, in advance, what the page is allowed to do. Think of a school trip: the teacher gives the bus driver a written list of the places the class may visit. If a stranger tries to add a stop, the driver refuses. That list is a Content Security Policy (CSP).

CSP is an HTTP header from the server. It is an allow-list enforced by the browser: it says from which origins the page may load and run content. It reduces the damage when output encoding fails somewhere, but it does not replace encoding.

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'
A strict policy
DirectiveMeaning
script-src 'self'Only script files from the same origin run. Inline <script> blocks and inline handlers such as onclick are refused
default-src 'self'Everything not listed (images, styles, connections) only from the same origin
object-src 'none'No plugins such as <object> or <embed>
base-uri 'none'No <base> element that could change where relative addresses point
form-action 'self'Forms may only be sent to the same origin
frame-ancestors 'none'Nobody may put this page inside an <iframe>

With script-src 'self', if a badly encoded comment sneaks an inline script into the page, the browser runs /app.js (a file from the same origin) and blocks the inline script. That is the main gain: most injections look like markup inside the page. To benefit, your own JavaScript must live in files, not in event attributes.

Do not break the net

When old code needs inline scripts, someone may be tempted to write script-src 'self' 'unsafe-inline'. The keyword is called that for a reason: it gives the attacker back the easiest path, and the policy becomes worth little against XSS. The right ways to keep a needed inline script are to move it into a file, or to authorise it individually with a nonce or a hash.

A is put both in the header (script-src 'nonce-R4nd0m') and in the nonce attribute of your own script tags. An injected script does not know this response's value and is blocked. If the value is fixed in a config file, it is the same on every page, anyone can read it once and put it on their own tag, and the protection is gone.

Rolling it out safely

On a big old site, switching on a strict policy can break pages that use something you forgot. The safe path is Content-Security-Policy-Report-Only. The browser judges the policy without enforcing it and sends violation reports. Fix what the reports show, and when they are clean, move the same policy to the real Content-Security-Policy header.

Other security headers

HeaderWhat it doesWhy it matters
Strict-Transport-Security (HSTS)Tells the browser to use only HTTPS for this domain during max-ageWithout it, the first request may go over HTTP and someone on the path can stop the upgrade to HTTPS. It only counts when received over HTTPS
X-Content-Type-Options: nosniffThe browser must respect the declared Content-Type and not guessStops an uploaded file full of HTML from being executed because the browser guessed it was a page
frame-ancestors (in CSP)Declares who may frame the pageDefends against clickjacking: your page loaded invisibly in another site's frame to steal clicks. 'none' allows nobody, 'self' only your own origin
↑ Back to the contents

CSRF and SameSite cookies: forged requests

Picture a pizza shop that delivers to anyone holding your loyalty card. A prankster does not need to steal your card. He just calls out, "Hey, shop, deliver 50 pizzas to this address!" in a way that makes your own phone place the order, and the shop sees your card number and delivers. This is cross-site request forgery (CSRF): another website makes the victim's browser send a request that the target site accepts as the victim's own.

Notice what the attacker does not do. He does not read the answer and does not steal the password. He just needs an action to happen with the victim's rights, such as changing an e-mail or transferring money. That is why CSRF targets requests that change state.

It works because of how cookies behave. A session cookie is an ambient credential: the browser attaches it by itself to every request to that site, even when the request was started by a page from a different site. The server gets a perfectly valid request and cannot tell that the user did not mean it.

  evil.test page            Browser              bank.test
      |  hidden form to bank.test  |                    |
      | -------------------------> |                    |
      |                            | POST /transfer     |
      |                            | + bank.test cookie |
      |                            | -----------------> |
      |                            |   server: valid    |
      |                            |   session, do it!  |
How a forged request travels

Defence 1: SameSite cookies

The SameSite attribute tells the browser when the cookie may be sent on cross-site requests. A site is the registrable domain (for example example.com), a bigger idea than an origin.

ValueCookie is sentTrade-off
StrictOnly on requests made from the same siteSafest. A user who arrives from an external link looks "logged out" for the first page
LaxAlso on top-level navigations with a safe method, such as clicking a link (GET)A good default, but it assumes GET has no side effects
NoneAlways, including cross-site; it requires SecureNo protection against CSRF

Lax is not magic. If your site has GET /account/delete, a link on another site that points there will be clicked by the victim and the browser sends the cookie, so the account is deleted. Never change data on GET. Use POST, PUT, PATCH or DELETE, protected by a token.

Also remember that SameSite protects against other sites, not against other origins of the same site. If app.example.com and pages.example.com share the registrable domain example.com, and customers publish their own HTML at pages, then a page published there is same-site with your application. SameSite=Strict does not hold the cookie back. Put untrusted content on a separate registrable domain, and keep the token as the main defence.

Defence 2: the anti-CSRF token

In the synchroniser token pattern, the server makes a random value tied to the session, places it in a hidden field of its forms and checks it on every POST. The cookie goes by itself, but the token is not sent automatically. The forged page cannot read your real page to copy the token, because the forbids it.

<form method="post" action="/transfer">
  <input type="hidden" name="csrf" value="9f3a...random-per-session">
  <input name="amount">
</form>
<!-- server: reject the POST if csrf is missing or does not match -->
A form carrying a token

This also explains why XSS defeats the token: an injected script is in the same origin, so it is allowed to read the token.

Two common misunderstandings

  • "We did not configure CORS, so other sites are blocked." Wrong. The same-origin policy and CORS protect the reading of responses across origins. They do not stop a simple form POST from being sent and processed. CSRF needs to read nothing. The defence must live on the server, with a token and SameSite.
  • "Our API uses a Bearer token, are we exposed?" Little. If the token is kept in memory and sent explicitly in the Authorization header, the browser does not attach it to forged requests from another site. CSRF is a problem of ambient credentials: cookies, Basic authentication, client certificates. The price is that scripts on the page can reach the token, so defending against XSS matters even more.
↑ Back to the contents

Authentication: passwords, hashing and more than one factor

Two words are easy to mix up. Authentication answers "who are you?". Authorisation answers "are you allowed to do this?". At a cinema, showing your ticket at the door is authentication; being allowed into room 3 only, not room 5, is authorisation. A system can do the first perfectly and the second badly, and the other way round. They are different controls with different flaws.

How to store a password

The server never needs to know your password, only to recognise it. So it stores the output of a one-way function and, at login, repeats the computation on what you typed and compares. Passwords are not encrypted (whoever gets the key would get every password); they are hashed.

Two ingredients make a hash fit for passwords. First, a salt: a random value, different for each password and stored next to the hash. Without it, two users with the same password have the same hash, and an attacker can use a precomputed table of common passwords to break many accounts at once. With a salt, every row is a separate problem. The salt is not secret. Second, the hash must be slow on purpose.

Why slow? Fast hashes such as MD5 or SHA-256 were made to check files quickly, which is wonderful for files and terrible for passwords, because someone with a stolen table can try millions of guesses per second. Argon2id, scrypt and bcrypt add a cost that you can raise over time; Argon2id and scrypt also demand lots of memory, which makes graphics cards (GPUs) expensive to use. A salted SHA-256 is still inadequate: the salt stops the bulk attack, but only the cost slows each single account.

Guesses per second on one CPU core, from this repository's benchmark (passwords and sessions lab)
MD5, no salt
843,636 hashes per second
SHA-256 + 16-byte salt
548,724 hashes per second
Argon2id 19 MiB, t=2
22 hashes per second
Argon2id 64 MiB, t=3
3.5 hashes per second

Read the chart as the number of guesses per second an attacker can try on a stolen table, per core. Argon2id costs each guess about 45 milliseconds and tens of megabytes of memory, roughly 38,000 times more than MD5 in that run. A user pays that cost once per login and does not notice. Machine, versions and command are recorded in the lab results.

SchemeOne-waySaltSlow and memory-hard
Plain textnonono
MD5yesnono
Salted SHA-256yesyesno
Argon2idyesyesyes

A pepper is a different thing from a salt. The pepper is a secret shared by the whole application and kept outside the database (in a vault or environment). It helps when only the database leaks, for instance through a SQL injection or a stolen backup. If the whole server is taken, the pepper leaks as well, so it is an extra layer, not a replacement.

What if an old system stores only MD5(password) and we want everyone protected by Argon2id now, without knowing the passwords? Store Argon2id(old MD5) for every account at once, and record which scheme each row uses. At each user's next successful login, you have the real password in hand, so store plain Argon2id and drop the wrapper.

Login screens and policies

  • Say the same thing for every failure. If the screen answers "user not found" for unknown e-mails and "wrong password" for known ones, you hand out a list of who has an account. Answer "invalid credentials" every time, with the same status code and similar time. The same applies to sign-up and password-recovery screens.
  • Limit attempts, but think about who controls the counter. Locking an account forever after 3 wrong passwords lets anybody lock out anybody. Use short locks that grow, limits per account and per source, and a second factor.
  • Follow current guidance (NIST SP 800-63B). Require a minimum length, accept long phrases and any character, allow pasting, refuse passwords already found in leaks, and do not force periodic changes without reason. Length and uniqueness beat odd complexity rules.

Factors

A factor is a category of proof. There are three: something you know (password, PIN), something you have (a phone, a security key) and something you are (fingerprint, face). Real multi-factor authentication combines different categories, so stealing a password is not enough. A password plus a temporary code from an authenticator app is two factors. A password plus a security question is not: both are "something you know".

↑ Back to the contents

Sessions and cookies: staying logged in

After you log in, you do not want to type your password for every page. So the server gives your browser a session identifier inside a cookie, like a wristband at a theme park: show it and you go through. This also means the wristband is a temporary password. Whoever presents it is treated as you.

That is why the identifier must be long, random and meaningless: generated by a cryptographic random generator with at least 128 bits of entropy (unpredictability), and only a key to data the server keeps. A guessable identifier is a guessable password. Here is a good cookie:

Set-Cookie: __Host-sid=<32 random bytes>; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=28800
A well-protected session cookie
AttributeWhat it closes
HttpOnlyPage scripts reading the cookie through document.cookie
SecureThe cookie travelling over plain HTTP, where it could be captured. Even a single request for an image would leak it. It goes together with HSTS
SameSite=LaxThe cookie being attached to background requests started by other sites
__Host- prefixAnother subdomain or an HTTP page setting or overwriting your cookie. The browser accepts the name only if the cookie has Secure, Path=/ and no Domain
Max-AgeThe browser keeping the cookie longer than you want (the server enforces the limit as well)

The __Host- prefix needs explanation. Cookies do not follow the same idea of origin as the rest of the web: a subdomain is allowed to set a cookie for the parent domain and thereby override the application's own. The prefix closes that door and defends against fixation from less trusted subdomains.

Session fixation

Here the attacker does not steal the wristband; he hands you one he already knows and waits for you to log in. If the server keeps the same identifier after login, your authentication promotes the session the attacker is holding.

attacker -> victim browser : session id X (planted)
victim   -> server         : POST /login + cookie X
server                     : X is now "alice"  (same id kept)
attacker -> server         : GET /me + cookie X  => alice's data

Fix: on login, create a NEW id and destroy the old one.
Session fixation

The rule: change the identifier on every change of privilege (login, logout, raising access).

Ending a session and its limits

  • Logout is a server operation. Erasing the cookie in the browser is not enough. If the record stays valid on the server, anyone who copied the identifier before (from a shared computer, a log, a script) can still use it. Delete or invalidate the record.
  • Never put the identifier in the URL (/orders?sid=...). URLs get copied, stored in browser history and in server and proxy logs, and sent to other sites in the Referer header. Cookies were designed to carry it without those leaks.
  • Use two timeouts. The idle timeout (for example 15 minutes without activity) shrinks the window for an abandoned device. The absolute timeout (for example 8 hours total) caps the life of a captured identifier, because a stolen id used continuously never becomes idle. Sensitive applications use both and renew the id from time to time.
↑ Back to the contents

JWT, OAuth 2.0 and OpenID Connect: tokens and delegated login

Some systems do not keep a session on the server. Instead they give you a signed note, a JSON Web Token (JWT), and trust what is written on it, as long as the signature checks out. It is like a concert wristband with a hologram: the guard does not call the box office, he just checks the hologram. A JWT is three texts joined by dots:

  base64url(header) . base64url(payload) . signature
       |                   |                  |
  how it was signed     the claims        proof the issuer
  {"alg":"HS256"}     {"sub":"bob",      wrote the first
                        "exp":1800000900}  two parts
Anatomy of a JWT

Two facts shape everything. First, the payload is only encoded with base64url, not encrypted. Anyone with the token can read it with any tool, so never put secrets or unneeded personal data in it. The signature guarantees that nothing was changed and who issued it, not secrecy. Second, the header is written by the sender. Whatever the verifier reads from it before the signature is checked is a suggestion from a stranger.

The checks a verifier must do

CheckWhy
The alg in the header matches the algorithm the server expectsOtherwise the sender decides whether any checking happens at all. The algorithm none means "no signature"; a verifier that obeys the token accepts a forged note
The signature is validProves it was issued by the key holder and was not changed
exp is later than now (a small tolerance is fine)Expiry limits the damage of a leaked token. A verifier that never compares exp accepts old tokens forever
iss is the issuer you trustThe token comes from the right place
aud includes this serviceA token made for the reports service must not work on the payments service, even with a correct signature

A valid signature alone is not enough: the token may be authentic but expired, or authentic but meant for someone else. Mature libraries check all of this by default and you pass them an explicit list of allowed algorithms (usually one). Hand-written verifiers tend to forget steps.

Another trap is the secret. With HS256 the same key signs and verifies, so all the strength is the key's randomness. If the key is a human word such as secret, an attacker who has one captured token can try guesses on their own computer, offline, without touching the server and without any lockout, until one reproduces the signature. Then they can sign any payload. An HS256 key needs at least 256 randomly generated bits, never a memorable word.

Also, a stateless token cannot be taken back: after logout or theft it works until exp. The usual design is a short-lived access token (minutes) plus a server-side refresh token that is rotated and revocable.

OAuth 2.0 and OpenID Connect

Imagine you give a valet the car key, but a special one that only opens the doors and starts the engine, not the glovebox or the trunk. OAuth 2.0 is that idea: it lets an app act on your behalf with limited rights (the scopes), without ever seeing your password. It solves delegated authorisation: "may this app access that resource for the user?" The result is an access token meant for an API.

OAuth does not say who the user is. OpenID Connect (OIDC) is a thin layer on top of OAuth that adds authentication. It delivers an ID token, a signed JWT with the identity of the user, and a user-info endpoint.

AspectAccess tokenID token
AnswersMay this app call that API, with which scopes?Who logged in?
Meant forThe API (resource server)The client app itself (its aud is the client id)
Sent to the API?Yes, in Authorization: BearerNo. An API that accepts ID tokens stops checking that permission was granted for it

Apps such as mobile apps and single-page applications cannot keep a client secret. The recommended flow for every client today is the authorisation code flow with PKCE, not the older implicit flow. With the app creates a random code_verifier, sends its hash (code_challenge) when asking for the code, and sends the original value when trading the code for tokens. Someone who intercepts the authorisation code but did not start the flow does not have the verifier, so the code is useless to them.

↑ Back to the contents

Access control: IDOR, roles and mass assignment

In a school, your student card lets you into the building (authentication). But it does not open the teachers' room or other students' lockers (authorisation). Broken access control happens when a program forgets to check the second part. It was the number one category in the OWASP Top 10 (2021).

The most famous form is IDOR (insecure direct object reference). A logged-in user changes /invoices/1001 to /invoices/1002 in the address bar and sees someone else's invoice. The number in the URL, body or hidden field is only a request. The server must decide, on every request, whether this user may access that object. Being logged in is not enough.

// Vulnerable: knows who you are, never asks whether it is yours
const invoice = await db.invoices.findById(params.id);

// Fixed: the owner is part of the query itself
const invoice = await db.invoices.findOne({
	id: params.id,
	ownerId: session.user.id,
});
if (!invoice) return status(404);
Vulnerable and fixed handler

Putting the owner in the query (WHERE id = $1 AND owner_id = $2), or going through a single central policy function, means no route can forget the rule.

Two directions of the same flaw

DirectionWhat the user reachesExample
HorizontalData of another user at the same levelIDOR: reading another customer's invoice
VerticalA function of a higher roleA regular user types /admin/users and the page answers

Hiding the "Manage users" link from regular users is only navigation. The route must check the role itself. Everything on the client (pages, apps, hidden fields) is under the user's control. The interface can hide things to be friendly, but the security decision happens on the server, on every request, preferably in one reused mechanism.

Good rules

  • Deny by default. Refuse everything except what a rule explicitly allows. With an allow-list, a forgotten rule blocks a legitimate use, which someone complains about quickly. With a blocklist, a forgotten rule silently exposes data. You are choosing the way the system fails.
  • Unpredictable ids are not access control. Swapping sequential numbers for random UUIDs is a useful extra against guessing, but an identifier is designed to circulate. If another person gets the id by any means, a missing check still lets them in.
  • Check mass assignment. If a route lets users edit their own profile and does update(session.user.id, body), a user may add "role": "admin" to the body and promote themselves, even though ownership was checked. Authorisation applies to fields too. Validate the body with a schema listing the allowed fields and refuse the rest.
  • Choose the refusal code on purpose. A private-repository service may answer 404 instead of 403 when the repository exists but is not yours, because a 403 confirms it exists. Both are valid refusals; the 404 hides the existence of the resource. Either way, the decision is on the server and the answer is consistent, timing included.
↑ Back to the contents

SSRF, path traversal and file upload

This section is about three ways in which a server is tricked into using something it should not: a web address, a file path or a file name that came from outside.

SSRF: the helpful server

Many features make the server act as a browser: link previews, "import from URL", webhooks, image fetchers. Imagine asking a friendly office worker, "please call this number for me". If you choose the number, you may ask him to call the boss's private line, which only the people inside the office can reach. In server-side request forgery (SSRF) the user supplies a URL and the server fetches it from inside the company network, so it can reach localhost, internal services and the cloud metadata address that have no password because "only internal machines can connect". It has its own OWASP category (A10, 2021).

The fix follows the cause: the application must decide where it is willing to connect, and do the check on what the connection really uses, the numeric address, at every hop.

  • A blocklist of text does not work. Rejecting URLs that contain localhost or 127.0.0.1 fails, because the same address has many spellings and any DNS name can point inward. Parse the URL, accept only http and https, resolve the name and refuse loopback, private and link-local addresses. When the set of destinations is known, an allow-list of hosts is stronger than any blocklist.
  • Redirects are a trap. A permitted site can answer 302 with a Location that points to an internal service, and HTTP clients follow redirects by default. Turn automatic redirects off, follow them yourself up to a small limit, and re-check every new destination.
  • Check and use must be the same lookup. If you resolve the name, check it, then give the name to the client and it resolves again, a malicious DNS server can answer with a public address the first time and an internal one the second time (DNS rebinding). Resolve once, validate, connect to that same address, and keep the name only for the Host header and for TLS.
  • Add network rules too. Run the component that fetches user URLs in an isolated segment or behind an egress proxy, and authenticate between internal services. Application checks are one layer.
  • Hiding the response is not a fix. The request is still made (blind SSRF).
URL from user
  -> parse: scheme is http/https? host on allow-list?
  -> resolve host once -> every address public?
  -> connect to THAT address (Host header = name)
  -> redirect? start again from the top (max 3 hops)
Validation that follows the real connection

Path traversal: walking out of the folder

A download route joins the uploads folder with the file name from the request. A name like ../../etc/something uses the sequence .., which means "go up one folder". The operating system resolves it as usual, and the file read is outside the uploads folder. This breaks the assumption that a name stays inside a folder.

// Vulnerable: join trusts the name
const target = path.join(UPLOADS, name);

// Fixed: validate the RESULT, not the input
const target = path.resolve(UPLOADS, name);
if (!target.startsWith(UPLOADS + path.sep)) return status(400);
Vulnerable and fixed

Validate the final absolute path (resolving symbolic links too) and check it is inside the allowed root. Better still, never accept paths from the client: receive an identifier and look it up in an index. Run the process with few permissions, because the reach of the flaw is whatever the process may read.

File upload: the name, the type and the content are claims

When a user sends a file, the name, the extension and the declared Content-Type are all just text the client chose. A header saying image/png proves nothing. A safe upload combines several measures.

MeasureWhy
Generate a random name on the server, keep the original name as metadataThe client's name is never used in the file system, so it cannot traverse or overwrite
Allow-list of types, and check the leading bytes (the file signature)The declared type is only a claim
Size limitAvoids filling the disk or the memory
Store outside executable or directly served folders; send X-Content-Type-Options: nosniffA file full of HTML must not be run as a page or a program
↑ Back to the contents

Secrets, dependencies and the supply chain

A secret is anything that proves who you are to another system: an API key, a database password, a private key. Think of the keys of the school. You do not tape them to the front door, and you do not make a photocopy for every visitor. Secrets are treated the same way.

Where secrets live

A secret is configuration, not code. It enters the process at run time, from environment variables or from a secrets manager (a vault) that controls who may read it, logs each use and makes rotation easy. The repository keeps only the names of the expected variables, never the values.

What if a developer commits a key by mistake and then deletes the line in a new commit? The key is still in the history, and copies of the repository spread quickly. So the first effective action is to revoke the key and issue a new one (rotation). Only then clean the history, check whether the key was used, and prevent it from happening again with secret scanning before commits.

  1. Revoke the leaked key and create a new one.
  2. Check the logs for any use of the old key.
  3. Clean the history and the copies, if possible.
  4. Add secret scanning before commits so it does not happen again.

Dependencies: other people's code

Most of your application is code written by others: libraries, and the libraries of those libraries. This is the supply chain. Two ideas protect it.

  • Lockfile. Files such as bun.lock or package-lock.json record the exact version and the hash of every dependency. Without one, a range such as ^1.2.0 installs whatever is newest on that day, and different machines may run different code. Commit the lockfile and use it in strict mode in builds.
  • Keep checking, even if you change nothing. An application released a year ago with every dependency audited can be vulnerable today, because new flaws are disclosed in versions you already installed. Know what you have installed (indirect dependencies included), follow security advisories, update often and remove what you do not use. This is the OWASP category Vulnerable and Outdated Components.
↑ Back to the contents

Putting it together: one flaw, one habit

All these flaws look different, but most share one of two roots. Either the program mixed up data and code (injection, XSS), or the program trusted a claim from the outside (an id, a role, a file name, a URL, a token header) without checking it on the server. The table below connects each flaw to the habit that stops it.

Flaw, cause and main defence
FlawRoot causeMain defenceExtra layers
SQL / command injectionData glued into command textParameters, argument listsValidation, least privilege, allow-list for structure
XSSData parsed as markup or scriptOutput encoding, textContentSanitiser, CSP, URL scheme check
CSRFBrowser sends cookies by itselfAnti-CSRF tokenSameSite, no state change on GET
Weak passwordsFast or unsalted hashingArgon2id / bcrypt with saltPepper, MFA, attempt limits
Session theft / fixationPredictable or reused id, server keeps session after logoutRandom id, new id at login, server-side logoutHttpOnly, Secure, __Host-, timeouts
JWT mistakesTrusting the header and skipping claimsPinned algorithm, strong keyexp, iss, aud checks
IDOR / mass assignmentNo ownership or field checkAuthorise on the server, every requestDeny by default, field allow-list
SSRFServer fetches user-chosen addressAllow-list + resolved-address checkManual redirects, pinned connection, network egress rules
Path traversal / uploadClient name or type trustedValidate resolved path, random file nameContent signature check, nosniff
Leaked secretSecret in the repositoryEnvironment or vault, rotateSecret scanning
Outdated componentKnown holes in old librariesLockfile + advisories + updatesRemove unused packages

Common mistakes to avoid

  • Fixing the symptom (banning some characters) instead of the cause (keeping code and data apart).
  • Hiding things in the interface and calling it access control.
  • Using one defence and stopping. Layers fail one at a time; stack them.
  • Treating the OWASP Top 10 as a complete list or a certificate.
  • Believing that encrypting or hashing something is the same as authenticating it.
↑ Back to the contents