CMPUT-404 · Lesson 10 of 12 · 38 min

Securing web applications

Web applications are exposed to everyone on the internet, so security is part of building them, not a later add-on. CMPUT-331 lesson 9 surveys web application security through the OWASP Top 10, including injection, cross-site scripting and request forgery; lesson 7 of this course implemented escaping, secure cookies and CSRF tokens; and lesson 8 used parameterized queries. This lesson builds on those foundations with the controls an application developer configures and codes every day: the same-origin policy and CORS, Content Security Policy, authentication with sessions, tokens, OAuth and passkeys, object-level authorization, rate limiting, security headers, and managing secrets and dependencies. Everything here is defensive: the aim is to understand how protections work so you can apply them correctly to systems you are responsible for.

The same-origin policy and CORS

An origin is the combination of scheme, host and port: https://booking.example.ca and https://staff.example.ca are different origins, as are http and https versions of the same host. Browsers enforce the same-origin policy: scripts on one origin may send requests to another, but may not read the responses unless the other origin permits it. This is what stops a malicious page from reading your webmail through your signed-in browser. Cross-Origin Resource Sharing (CORS) is the mechanism by which a server grants that permission, using response headers such as Access-Control-Allow-Origin.

For requests that a plain HTML form could not send, such as PATCH, or JSON with Content-Type application/json, or custom headers, the browser first sends a preflight OPTIONS request asking whether the real request is allowed, and proceeds only if the server's answer permits the method and headers. The server below decides CORS from an explicit allowlist.

A server-side CORS decision function with preflight handling and an explicit origin allowlist
# CORS from the server's side: decide which cross-origin requests may read responses, including preflights.
ALLOWED_ORIGINS = {"https://booking.example.ca", "https://staff.example.ca"}
ALLOWED_METHODS = {"GET", "POST", "PATCH"}
ALLOWED_HEADERS = {"content-type", "idempotency-key"}

def cors_headers(method, origin, req_method=None, req_headers=""):
    if origin not in ALLOWED_ORIGINS:
        return {}                                            # no CORS headers: the browser blocks the read
    h = {"Access-Control-Allow-Origin": origin, "Vary": "Origin",
         "Access-Control-Allow-Credentials": "true"}
    if method == "OPTIONS":                                  # preflight for a non-simple request
        asked = {x.strip().lower() for x in req_headers.split(",") if x.strip()}
        if req_method not in ALLOWED_METHODS or not asked <= ALLOWED_HEADERS:
            return {}
        h.update({"Access-Control-Allow-Methods": ", ".join(sorted(ALLOWED_METHODS)),
                  "Access-Control-Allow-Headers": ", ".join(sorted(ALLOWED_HEADERS)),
                  "Access-Control-Max-Age": "600"})
    return h

cases = [
    ("GET", "https://booking.example.ca", None, ""),
    ("OPTIONS", "https://booking.example.ca", "PATCH", "Content-Type, Idempotency-Key"),
    ("OPTIONS", "https://booking.example.ca", "DELETE", "Content-Type"),
    ("GET", "https://evil.example", None, ""),
    ("GET", "null", None, ""),                              # sandboxed iframes and file: pages send "null"
]
for c in cases:
    h = cors_headers(*c)
    verdict = "allowed" if h else "blocked by browser"
    print(f"{c[0]:7s} from {c[1]:28s} {c[2] or '':6s} -> {verdict}")
    for k, v in h.items():
        if c[0] == "OPTIONS": print(f"        {k}: {v}")
Output
GET     from https://booking.example.ca          -> allowed
OPTIONS from https://booking.example.ca   PATCH  -> allowed
        Access-Control-Allow-Origin: https://booking.example.ca
        Vary: Origin
        Access-Control-Allow-Credentials: true
        Access-Control-Allow-Methods: GET, PATCH, POST
        Access-Control-Allow-Headers: content-type, idempotency-key
        Access-Control-Max-Age: 600
OPTIONS from https://booking.example.ca   DELETE -> blocked by browser
GET     from https://evil.example                -> blocked by browser
GET     from null                                -> blocked by browser

Three rules avoid most CORS mistakes. Never reflect any incoming Origin back into Access-Control-Allow-Origin, which effectively disables the same-origin policy for your site; compare against an allowlist. Never allow the origin null, which sandboxed documents and local files send. And remember that Access-Control-Allow-Origin: * cannot be combined with credentials: if responses depend on cookies, the server must name specific origins and add Vary: Origin so caches keep responses for different origins apart. CORS is enforced by browsers to protect users; it does not stop scripts, servers or command-line tools from calling your API, so it is never a substitute for authentication.

Sequence diagram with three columns: page script on booking.example.ca, the browser, and api.example.ca. The browser sends OPTIONS with Origin and Access-Control-Request-Method PATCH; the API answers 204 with Allow-Origin and Allow-Methods; the browser then sends the real PATCH with the cookie; the API answers 200 with Allow-Origin and the browser hands the response to the script.
A CORS preflight followed by the actual cross-origin request

Content Security Policy

Escaping output is the primary defense against cross-site scripting, but a single missed spot can be fatal, so a Content Security Policy (CSP) adds a second layer. The Content-Security-Policy response header tells the browser which sources of script, style, images and frames the page may use. The recommended modern approach is a strict policy based on nonces: each response includes a fresh random value in the header and in the nonce attribute of each legitimate script tag, so injected scripts, which cannot know the nonce, are refused. The strict-dynamic keyword lets trusted scripts load further scripts without listing every host.

Generating a nonce-based strict CSP and flagging weak legacy policies
# A strict Content-Security-Policy with a per-response nonce, and a checker for weak policies.
import secrets

def strict_csp():
    nonce = secrets.token_urlsafe(16)
    policy = "; ".join([
        "default-src 'self'",
        f"script-src 'nonce-{nonce}' 'strict-dynamic'",       # only scripts carrying this response's nonce
        "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'",
        "form-action 'self'", "img-src 'self' data:", "style-src 'self'",
    ])
    return nonce, policy

def weaknesses(policy):
    d = {p.split()[0]: p.split()[1:] for p in (x.strip() for x in policy.split(";")) if p}
    script = d.get("script-src", d.get("default-src", []))
    found = []
    if "'unsafe-inline'" in script and not any(s.startswith("'nonce-") for s in script):
        found.append("inline scripts allowed: injected <script> would run")
    if "*" in script or "https:" in script: found.append("scripts from any host")
    if "'unsafe-eval'" in script: found.append("eval() permitted")
    if "object-src" not in d and "'none'" not in d.get("default-src", []): found.append("plugins not disabled")
    if "frame-ancestors" not in d: found.append("page can be framed (clickjacking)")
    return found or ["no common weaknesses"]

nonce, policy = strict_csp()
print("nonce length:", len(nonce))
print("strict policy:", weaknesses(policy))
print("legacy policy:", weaknesses("default-src 'self'; script-src 'self' 'unsafe-inline' https:"))
print(f'<script nonce="{"N" * 4}...">  is allowed;  <script>injected()</script>  is refused')
Output
nonce length: 22
strict policy: ['no common weaknesses']
legacy policy: ['inline scripts allowed: injected <script> would run', 'scripts from any host', 'plugins not disabled', 'page can be framed (clickjacking)']
<script nonce="NNNN...">  is allowed;  <script>injected()</script>  is refused

Deploying CSP on an existing site is easiest in stages: send Content-Security-Policy-Report-Only first, collect violation reports, fix inline handlers such as onclick attributes (which CSP blocks), and then enforce. The frame-ancestors directive also prevents other sites from framing your pages to trick users into clicking (clickjacking), replacing the older X-Frame-Options header.

Authentication

Authentication establishes who the user is. Lesson 7's server-side sessions remain the simplest robust choice for a web application served from one site: the cookie holds only a random identifier, and signing out deletes the session on the server immediately. Token-based authentication is common for APIs used by mobile apps and other services: the client presents a bearer token in the Authorization header. JSON Web Tokens (JWTs, RFC 7519) are signed, self-contained tokens carrying claims such as subject, audience, scope and expiry, which any service holding the key can verify without a database lookup.

Verifying an HS256 JWT: signature, fixed algorithm, expiry and audience
# Verifying a JSON Web Token (HS256) correctly, and the classic mistakes a verifier must reject.
import base64, hashlib, hmac, json, time

KEY = b"server-secret-from-environment-32b"
b64 = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
unb64 = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

def issue(claims):
    head = b64(json.dumps({"alg": "HS256", "typ": "JWT"}).encode())
    body = b64(json.dumps(claims).encode())
    sig = b64(hmac.new(KEY, f"{head}.{body}".encode(), hashlib.sha256).digest())
    return f"{head}.{body}.{sig}"

def verify(token, now, audience="booking-api"):
    try:
        head, body, sig = token.split(".")
        header = json.loads(unb64(head))
        if header.get("alg") != "HS256":                 # never let the token choose the algorithm
            return "rejected: unexpected alg " + str(header.get("alg"))
        good = b64(hmac.new(KEY, f"{head}.{body}".encode(), hashlib.sha256).digest())
        if not hmac.compare_digest(sig, good):
            return "rejected: bad signature"
        c = json.loads(unb64(body))
        if c["exp"] <= now: return "rejected: expired"
        if c["aud"] != audience: return "rejected: wrong audience"
        return f"valid for {c['sub']} with scope {c['scope']}"
    except (ValueError, KeyError):
        return "rejected: malformed"

now = 1_790_000_000
good = issue({"sub": "user-17", "aud": "booking-api", "scope": "bookings:write", "exp": now + 900})
print("good token:     ", verify(good, now))
h, b, s = good.split(".")
forged_body = b64(json.dumps({"sub": "admin", "aud": "booking-api", "scope": "admin", "exp": now + 900}).encode())
print("edited claims:  ", verify(f"{h}.{forged_body}.{s}", now))
none_head = b64(json.dumps({"alg": "none"}).encode())
print("alg none:       ", verify(f"{none_head}.{forged_body}.", now))
print("after 15 min:   ", verify(good, now + 900))
print("other audience: ", verify(issue({"sub": "u", "aud": "reports", "scope": "r", "exp": now + 60}), now))
print("readable claims:", json.loads(unb64(b)))      # signed is not encrypted
Output
good token:      valid for user-17 with scope bookings:write
edited claims:   rejected: bad signature
alg none:        rejected: unexpected alg none
after 15 min:    rejected: expired
other audience:  rejected: wrong audience
readable claims: {'sub': 'user-17', 'aud': 'booking-api', 'scope': 'bookings:write', 'exp': 1790000900}

The output shows what a verifier must check and what it must reject. Editing the claims breaks the signature. The alg none trick, a real historical vulnerability, is refused because the server fixes the expected algorithm instead of trusting the token's header. Expired tokens and tokens issued for another service are rejected. The final line is a reminder that JWT claims are only encoded, not encrypted, so they must never hold secrets. Because a JWT stays valid until it expires, keep access tokens short-lived (minutes) and use refresh tokens, which can be revoked, to obtain new ones; in browsers, prefer HttpOnly cookies to localStorage for storing tokens, since any script that runs after an XSS flaw can read localStorage.

Most applications should not handle passwords at all if they can avoid it. OAuth 2.0 lets a user grant an application limited access through an authorization server, and OpenID Connect (OIDC) builds sign-in on top of it, returning an ID token describing the user; university single sign-on and Sign in with Google or Microsoft work this way. The recommended flow for web and mobile apps is the authorization code flow with PKCE (Proof Key for Code Exchange, RFC 7636): the client creates a secret verifier, sends only its hash at the start, and must present the verifier to exchange the code for tokens, so an intercepted code is useless.

PKCE: computing the S256 challenge, checked against the RFC 7636 example, and how the server uses it
# OAuth 2.0 authorization code flow with PKCE: the client proves it started the flow.
import base64, hashlib, secrets

def challenge(verifier):
    return base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()

# The example verifier from RFC 7636, Appendix B, reproduces the published challenge exactly.
rfc_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
print("RFC 7636 challenge:", challenge(rfc_verifier))

# A real client generates a fresh verifier and state for every sign-in.
verifier, state = secrets.token_urlsafe(48), secrets.token_urlsafe(16)
auth_url = ("https://id.example.ca/authorize?response_type=code&client_id=booking-spa"
            "&redirect_uri=https%3A%2F%2Fbooking.example.ca%2Fcallback&scope=openid%20bookings"
            "&state=(random)&code_challenge=(S256 of verifier)&code_challenge_method=S256")
print("1. redirect user to:", auth_url)
print("2. user signs in at the identity provider; browser returns to /callback?code=...&state=...")
print("3. client checks state, then POSTs code + code_verifier to /token")

# The authorization server recomputes the challenge from the verifier it receives.
stored = challenge(verifier)                       # saved by the server at step 1
attacker_guess = secrets.token_urlsafe(48)          # has the intercepted code, must invent a verifier
print("   verifier length:", len(verifier), "characters (RFC 7636 allows 43 to 128)")
print("   genuine client's verifier matches:", challenge(verifier) == stored)
print("   attacker's guessed verifier matches:", challenge(attacker_guess) == stored)
Output
RFC 7636 challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
1. redirect user to: https://id.example.ca/authorize?response_type=code&client_id=booking-spa&redirect_uri=https%3A%2F%2Fbooking.example.ca%2Fcallback&scope=openid%20bookings&state=(random)&code_challenge=(S256 of verifier)&code_challenge_method=S256
2. user signs in at the identity provider; browser returns to /callback?code=...&state=...
3. client checks state, then POSTs code + code_verifier to /token
   verifier length: 64 characters (RFC 7636 allows 43 to 128)
   genuine client's verifier matches: True
   attacker's guessed verifier matches: False

Passkeys, built on the WebAuthn standard and supported by all major browsers and operating systems since 2023, replace passwords with public-key credentials stored on the user's device or synchronized through their platform account. The site stores only a public key, so a database breach reveals nothing reusable, and because the browser binds each credential to the site's origin, passkeys cannot be phished on a look-alike domain. Where passwords remain, hash them with Argon2id, scrypt or bcrypt (CMPUT-331), check new passwords against lists of breached passwords, offer multi-factor authentication, and rate-limit sign-in attempts.

Authorization

Authorization decides what an authenticated user may do, and broken access control is consistently ranked the most serious web application risk. The most common flaw is the insecure direct object reference: an endpoint such as GET /bookings/102 checks that the user is signed in but not that booking 102 is theirs, so changing the number reveals other people's data. The remedy is to check permission on every object in every handler, ideally through one central policy so the rules are consistent and testable. Role-based access control (RBAC, CMPUT-331 lesson 7) assigns permissions to roles; attribute-based rules such as owner equals user express ownership.

A central policy table enforcing object-level authorization in every handler
# Object-level authorization: every handler checks that this user may act on this record.
BOOKINGS = {101: {"owner": "amelie", "room": "L201"}, 102: {"owner": "jordan", "room": "S105"}}
ROLES = {"amelie": {"student"}, "jordan": {"student"}, "priya": {"staff"}}
POLICY = {                                     # action -> rule(user, booking)
    "view":   lambda u, b: b["owner"] == u or "staff" in ROLES[u],
    "cancel": lambda u, b: b["owner"] == u or "staff" in ROLES[u],
    "export": lambda u, b: "staff" in ROLES[u],
}

def handle(user, action, booking_id):
    b = BOOKINGS.get(booking_id)
    if b is None or not POLICY[action](user, b):
        # Same answer for "missing" and "not yours" when hiding existence matters.
        return 404 if action == "view" else 403
    return 200

for req in [("amelie", "view", 101), ("amelie", "view", 102), ("amelie", "cancel", 102),
            ("priya", "cancel", 102), ("jordan", "export", 102), ("priya", "export", 101)]:
    print(f"{req[0]:7s} {req[1]:7s} booking {req[2]} -> {handle(*req)}")
Output
amelie  view    booking 101 -> 200
amelie  view    booking 102 -> 404
amelie  cancel  booking 102 -> 403
priya   cancel  booking 102 -> 200
jordan  export  booking 102 -> 403
priya   export  booking 101 -> 200

Note the design choice for viewing: a student asking for someone else's booking receives 404 rather than 403, so the response does not confirm that the booking exists. Authorization checks must be on the server; hiding a button in the user interface is a usability feature, not a security control, because anyone can send the request directly.

Rate limiting

Rate limits protect sign-in forms from password guessing, APIs from abusive clients, and expensive endpoints from overload. The token bucket is the most widely used algorithm: each client has a bucket holding up to a maximum number of tokens, refilled at a steady rate; each request takes one token, and a request finding the bucket empty is refused with 429 Too Many Requests and a Retry-After header. The capacity allows short bursts while the refill rate bounds the long-run average. The simulation uses explicit times so its output is deterministic.

A token-bucket limiter allowing bursts of five and an average of one request every two seconds
# A token-bucket rate limiter: steady refill, bursts up to the bucket size, 429 with Retry-After.
import math

class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate, self.capacity = rate, capacity        # tokens per second, maximum burst
        self.buckets = {}                                 # key -> (tokens, last_time)
    def allow(self, key, now):
        tokens, last = self.buckets.get(key, (self.capacity, now))
        tokens = min(self.capacity, tokens + (now - last) * self.rate)
        if tokens >= 1:
            self.buckets[key] = (tokens - 1, now); return True, 0
        self.buckets[key] = (tokens, now)
        return False, math.ceil((1 - tokens) / self.rate)  # seconds until a token is available

limiter = TokenBucket(rate=0.5, capacity=5)             # 1 request per 2 s on average, bursts of 5
timeline = [0, 0, 0, 0, 0, 0, 0.5, 1, 2, 4, 4, 30, 30, 30]   # one client's request times in seconds
for t in timeline:
    ok, retry = limiter.allow("203.0.113.7", t)
    print(f"t={t:>4}s  " + ("200 OK" if ok else f"429 Too Many Requests  Retry-After: {retry}"))
print("another client at t=0:", "200 OK" if limiter.allow("198.51.100.4", 0)[0] else "429")
Output
t=   0s  200 OK
t=   0s  200 OK
t=   0s  200 OK
t=   0s  200 OK
t=   0s  200 OK
t=   0s  429 Too Many Requests  Retry-After: 2
t= 0.5s  429 Too Many Requests  Retry-After: 2
t=   1s  429 Too Many Requests  Retry-After: 1
t=   2s  200 OK
t=   4s  200 OK
t=   4s  429 Too Many Requests  Retry-After: 2
t=  30s  200 OK
t=  30s  200 OK
t=  30s  200 OK
another client at t=0: 200 OK

The client's first five requests succeed as a burst, the sixth is refused, and later requests succeed only as tokens refill; after a long pause the bucket is full again. Other clients have their own buckets. In production, buckets live in a shared store such as Redis so that all application servers enforce the same limits, and limits are keyed by user or API key where possible, since many users can share one IP address behind a campus or mobile carrier network. Gateways and reverse proxies such as nginx provide rate limiting at the edge (lesson 11).

Security headers, secrets and dependencies

A handful of response headers harden every page. Strict-Transport-Security tells browsers to use only HTTPS for the site for a long period; X-Content-Type-Options: nosniff stops browsers guessing content types; Referrer-Policy limits URL leakage to other sites; Permissions-Policy disables browser features the site does not use. Headers that announce exact server and language versions help attackers match known vulnerabilities and should be removed.

Auditing a response against a baseline of security headers
# Audit a response's security headers against a recommended baseline.
RECOMMENDED = {
    "Strict-Transport-Security": "max-age=63072000; includeSubDomains",
    "Content-Security-Policy": "(strict policy with nonces)",
    "X-Content-Type-Options": "nosniff",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
    "Cross-Origin-Opener-Policy": "same-origin",
}
response = {"Content-Type": "text/html; charset=utf-8", "X-Content-Type-Options": "nosniff",
            "Strict-Transport-Security": "max-age=300", "Server": "nginx/1.18.0 (Ubuntu)",
            "X-Powered-By": "PHP/7.4.3"}

for name, advice in RECOMMENDED.items():
    v = response.get(name)
    status = "ok" if v else "MISSING"
    if name == "Strict-Transport-Security" and v and int(v.split("=")[1].split(";")[0]) < 31536000:
        status = "too short"
    print(f"{status:9s} {name}: {v or advice}")
for leak in ("Server", "X-Powered-By"):
    if any(ch.isdigit() for ch in response.get(leak, "")):
        print(f"{'reveals':9s} {leak}: {response[leak]}  (remove version numbers)")
Output
too short Strict-Transport-Security: max-age=300
MISSING   Content-Security-Policy: (strict policy with nonces)
ok        X-Content-Type-Options: nosniff
MISSING   Referrer-Policy: strict-origin-when-cross-origin
MISSING   Permissions-Policy: camera=(), microphone=(), geolocation=()
MISSING   Cross-Origin-Opener-Policy: same-origin
reveals   Server: nginx/1.18.0 (Ubuntu)  (remove version numbers)
reveals   X-Powered-By: PHP/7.4.3  (remove version numbers)

Secrets such as database passwords, API keys and signing keys must never be committed to version control, where they persist in history even after deletion; load them from environment variables or a secrets manager, and rotate any secret that leaks. Automated secret scanning, offered by GitHub and other platforms, catches mistakes before they spread. Modern applications include hundreds of third-party packages, so use lock files, run dependency vulnerability scanners such as npm audit, pip-audit or Dependabot in continuous integration, update regularly, and remove packages you do not need; the 2024 xz-utils backdoor and repeated malicious npm packages show the supply chain is a real target.

In the United States, the Federal Trade Commission treats unreasonable data security as an unfair practice and has brought many enforcement actions over basic failures such as storing passwords in plain text; state laws such as New York's SHIELD Act and California's CCPA require reasonable security, and all fifty states require breach notification. CISA, the Cybersecurity and Infrastructure Security Agency, publishes free guidance, including its Secure by Design principles for software makers.

Worked examples

Worked problem

Reviewing a CORS configuration

An API responds to every request with Access-Control-Allow-Origin set to the value of the request's Origin header and Access-Control-Allow-Credentials: true. What is the risk and the fix?

Show solution
  1. Any website the user visits can make credentialed requests and read the responses, because every origin is reflected as allowed.
  2. An attacker page could read the user's bookings or profile using the user's cookies.
  3. Fix: compare Origin against an explicit allowlist, add Vary: Origin, and never allow null.

Answer: Reflected origins with credentials defeat the same-origin policy; use an allowlist.

Worked problem

Sizing a sign-in rate limit

Allow a person who mistypes a few times to keep trying, but slow a password-guessing script to at most 60 guesses per hour per account. Choose bucket parameters.

Show solution
  1. Average rate: 60 per hour = 1 per minute, so rate = 1/60 tokens per second.
  2. Burst: capacity 5 lets a person make five quick attempts.
  3. After the burst, a script gets one attempt per minute: 60 per hour, plus the initial 5.
  4. Key the bucket by account and by IP address separately, and add multi-factor authentication for stronger protection.

Answer: Capacity 5, refill 1 token per 60 s, applied per account and per IP.

Worked problem

Finding an access control flaw

An endpoint is PATCH /api/bookings/{id} and its handler checks only that the session has a user. Describe a test that exposes the flaw and the fix.

Show solution
  1. Sign in as user A and create booking 501; sign in as user B in another session.
  2. As B, send PATCH /api/bookings/501 changing the slot.
  3. If the response is 200 and A's booking changed, the endpoint lacks an object-level check.
  4. Fix: load the booking, apply the central policy (owner or staff), and return 403 or 404 otherwise; add this case to the automated tests.

Answer: A two-user test reveals the insecure direct object reference; enforce ownership on the server.

Common mistakes

  • MistakeCORS protects my API from unauthorized clients.

    InsteadCORS only governs which web pages browsers let read responses; servers and scripts ignore it. Authenticate and authorize every request.

  • MistakeEchoing the request Origin into Access-Control-Allow-Origin is a convenient default.

    InsteadIt allows every site to read credentialed responses; use an explicit allowlist.

  • MistakeJWTs are encrypted, so claims are private.

    InsteadStandard JWTs are signed and base64url-encoded; anyone can read the claims.

  • MistakeTrust the alg field in a token's header.

    InsteadFix the expected algorithm on the server; accepting none or switching algorithms enables forgery.

  • MistakeHiding the delete button prevents unauthorized deletion.

    InsteadAuthorization must be enforced on the server for every request.

  • MistakeA CSP with unsafe-inline still stops XSS.

    InsteadAllowing inline scripts without nonces lets injected scripts run; use nonce-based strict CSP.

Summary and key terms

  • Browsers block cross-origin reads; CORS headers grant exceptions, which must come from an allowlist.
  • Nonce-based Content Security Policy is a strong second layer behind output escaping.
  • Sessions suit single-site apps; short-lived tokens suit APIs; verify JWTs strictly.
  • Use OAuth 2.0 and OIDC with PKCE for delegated sign-in, and passkeys for phishing resistance.
  • Enforce object-level authorization on the server through a central policy.
  • Token buckets limit request rates while allowing bursts; respond 429 with Retry-After.
  • Set security headers, keep secrets out of code, and scan dependencies continuously.
Origin
The scheme, host and port of a URL.
Same-origin policy
The browser rule preventing scripts from reading responses from other origins.
CORS
Cross-Origin Resource Sharing: headers by which a server permits cross-origin reads.
Preflight
An OPTIONS request a browser sends to ask whether a cross-origin request is allowed.
Content Security Policy
A header restricting the sources from which a page may load resources.
JWT
JSON Web Token: a signed, self-contained set of claims.
PKCE
Proof Key for Code Exchange: binds an OAuth authorization code to the client that requested it.
Passkey
A phishing-resistant public-key credential based on WebAuthn.
IDOR
Insecure direct object reference: accessing others' records by changing an identifier.
Token bucket
A rate-limiting algorithm with steady refill and a maximum burst.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Are https://example.ca and https://example.ca:8443 the same origin?

    Show answer

    No; the port differs.

  2. When does a browser send a CORS preflight?

    Show answer

    For requests a plain form could not send, such as PATCH, JSON bodies or custom headers.

  3. Why add Vary: Origin to CORS responses?

    Show answer

    So caches do not serve a response allowed for one origin to another.

  4. How does a nonce-based CSP stop injected scripts?

    Show answer

    Injected script tags lack the per-response nonce, so the browser refuses to run them.

  5. Name three checks a JWT verifier must make.

    Show answer

    Signature with the expected algorithm, expiry, and audience (and issuer).

  6. What does PKCE protect against?

    Show answer

    Use of an intercepted authorization code by someone who lacks the code verifier.

  7. Why are passkeys resistant to phishing?

    Show answer

    The browser only uses a credential for the origin it was created for.

  8. In the token-bucket run, why did the request at t = 4 s succeed?

    Show answer

    Tokens had refilled at 0.5 per second since the earlier requests.

  9. Why might the view handler return 404 rather than 403 for someone else's booking?

    Show answer

    To avoid revealing that the booking exists.

  10. Why remove version numbers from the Server header?

    Show answer

    They help attackers match the server to known vulnerabilities.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz