What it is
Clickjacking is your page loaded inside a transparent iframe on the attacker's page, layered over decoy content. The user believes they are clicking the attacker's button, but the real click lands on your page — carrying their full session.
Why you should care
This is a vulnerability with a one-line fix, and that is precisely why it persists: one header line lives in nobody's code, so nobody owns it. It sits in the configuration layer — and the configuration layer does not show up in code review.
Three things to grasp:
- It needs no other bug. No XSS, no bypassed CSRF token. A real user performs a real action on your page — they simply do not know what they are doing.
- CSRF tokens do not help. The token is inside the framed page, so it arrives intact. Nor does
SameSite=Lax: this is a click on your own origin. X-Frame-Optionsis obsolete and nobody remembers to setframe-ancestors.X-Frame-Options: ALLOW-FROMwas never supported by Chrome, andSAMEORIGINforbids embedding in a partner site. So a team with a genuine embedding need removes the header rather than moving to CSP.
On impact: it depends entirely on which single-click actions your app has. A "Delete account" button, an "Allow this app access" button (OAuth consent), "Transfer funds", "Disable two-factor" — each is a worthwhile clickjack. And the OAuth consent button is the worst case: one click grants the attacker long-lived access.
How the attack works
The mechanism rests on three CSS properties, with no complicated JavaScript: opacity: 0 makes the iframe invisible, position: absolute layers it on top, and pointer-events decides where the click lands.
flowchart TD A["Attacker page<br/>evil.example"] --> D["Decoy content<br/>#quot;Click to claim your prize#quot;"] A --> I["iframe app.example/settings<br/>opacity: 0<br/>position: absolute<br/>z-index: 999"] I --> P["The iframe sits ABOVE the decoy,<br/>and it is invisible"] D --> C["The user sees the decoy button<br/>and clicks it"] P --> C C --> R["The click lands on the IFRAME,<br/>not on the decoy"] R --> X["POST /settings/delete-account<br/>Cookie: session=... ✓<br/>CSRF token: ✓ (inside the framed page)"]The key point at the last step: the CSRF token arrives intact. It lives inside the framed page, and the click originates from your origin. This is why no CSRF fix touches clickjacking.
Four variants, worth knowing because the frame-ancestors fix closes three of them:
| Variant | Method | Note |
|---|---|---|
| Basic | An opacity: 0 iframe over the decoy button | frame-ancestors closes it |
| Cursor-following | The decoy button follows the pointer, so any click hits | frame-ancestors closes it |
| Multi-step | A chain of iframes, one click per step of a flow | frame-ancestors closes it |
| Drag-and-drop / paste | The victim drags a string into a form inside the iframe | frame-ancestors closes it, but older browsers need X-Frame-Options too |
And the variant frame-ancestors does NOT close: not an iframe at all. window.open with a precisely positioned popup, or abuse of a screen-sharing feature — but both need far more interaction and are far less practical. In practice, frame-ancestors plus X-Frame-Options closes nearly the whole surface.
Diagram description: The diagram shows the attacker page holding two layers. The lower layer is decoy content with a button inviting a click to claim a prize. The upper layer is an iframe loading the real app settings page, set to opacity 0 so it is invisible, with position absolute and a high z-index so it sits above the decoy. The user sees the decoy button and clicks it, but the real click lands on the iframe. A POST request to delete the account is sent with the session cookie and with the CSRF token too, because the token lives inside the framed page.
Concrete example
The complete attack page. Four lines of CSS are the entire technique.
<!doctype html><style> #decoy { position: absolute; top: 300px; left: 200px; font-size: 20px; } #target { position: absolute; top: 0; left: 0; width: 100%; height: 100%; opacity: 0; /* invisible */ z-index: 999; } /* above the decoy */</style> <div id="decoy">🎁 Click here to claim your $20 voucher</div> <!-- The real page, loaded with the victim's session. --><!-- top/left tuned so the "Delete account" button sits under the cursor. --><iframe id="target" src="https://app.example/settings/danger"></iframe># Check whether a page is framable — one command, runnable right now.curl -sI https://app.example/settings/danger | grep -iE 'x-frame-options|content-security-policy'(nothing) ← framable, and every single-click action on this page is clickjackable# After the fix:Content-Security-Policy: frame-ancestors 'none'X-Frame-Options: DENYAnd the cursor-following variant — it kills the "you would have to guess exact coordinates" argument:
<script> // The decoy follows the mouse, so wherever the victim clicks they hit the // real button inside the iframe. No need to know their screen resolution. addEventListener("mousemove", (e) => { const d = document.getElementById("decoy"); d.style.left = (e.clientX - 40) + "px"; d.style.top = (e.clientY - 10) + "px"; });</script>// ── Razor layout ────────────────────────────────────────────────────────────// ❌ Frame-busting bằng JavaScript. Đã bị phá từ 2010, và tác hại lớn nhất của nó// không phải là bị vòng qua — mà là nó TẠO CẢM GIÁC đã vá, nên không ai đặt header.//// Vòng qua: <iframe sandbox="allow-forms"> chặn top.location, nên dòng dưới ném// lỗi và iframe ở lại nguyên vẹn.@section Scripts { <script> if (top !== self) { top.location = self.location; } </script>} // ── Controller ──────────────────────────────────────────────────────────────public class SettingsController : Controller{ [HttpGet("/settings/danger")] public IActionResult Danger() { // ❌ Đặt header theo TỪNG action. Mặc-định-MỞ: action thứ 41 sẽ thiếu, và // trang 404 với trang OAuth consent thì không đi qua đây bao giờ. Response.Headers["X-Frame-Options"] = "SAMEORIGIN"; return View(); } // ❌ Và action này thiếu hẳn — không ai nhận ra vì nó không làm gì hỏng. [HttpGet("/oauth/authorize")] public IActionResult Authorize([FromQuery] string clientId) => View();} // ❌ Hành động một-cú-bấm, không xác nhận gì. Đây là thứ biến "nhúng được" thành// "mất tài khoản" — và nó là phần mà header không chạm tới.[HttpDelete("/api/account")]public async Task<IActionResult> DeleteAccount(CancellationToken ct){ await _users.SoftDeleteAsync(_currentUser.UserId, ct); return NoContent();}What happened in the wild
Adobe Flash Player settings manager, 2008 (Robert Hansen & Jeremiah Grossman). The Flash settings page was framed transparently, and a sequence of clicks enabled access to the victim's webcam and microphone. This is the case that coined the word "clickjacking", and it remains the clearest illustration of block 2's point: no other bug is required — the victim really clicks, on the real page.
Likejacking on Facebook, 2010–2012. The Like button was framed transparently over "watch this video" pages, so every click propagated content to the victim's friends. The scale is what makes it memorable: it spread exponentially like stored XSS, with not one line of JavaScript running on Facebook's origin.
And the most worrying shape today: OAuth consent. Numerous bug bounty reports describe a consent page (/oauth/authorize) missing frame-ancestors, so one click grants the attacker's application long-lived access to the victim's account. This is the worst case because the outcome is not one action — it is an access token.
How to defend
`frame-ancestors` on EVERY HTML response, set in exactly one place
mandatoryThe fix is one line, and the hard part is not writing it but ensuring it is on every response — including error pages, intermediate redirects, and an endpoint added next week.
Content-Security-Policy: frame-ancestors 'none'X-Frame-Options: DENYBoth, not one: frame-ancestors is the current standard and wins when both are present, but X-Frame-Options is still needed for older browsers and some WebViews. They do not conflict.
Choose the value from the real requirement:
'none'— the default to pick. Nobody can frame it, including you.'self'— when the app frames its own pages (a modal iframe, a preview).https://partner.example— listed explicitly, and this is whatX-Frame-Optionscannot do (ALLOW-FROMwas never supported by Chrome), and the reason to move to CSP rather than drop the header.
Set it in one place: one middleware, not an attribute per controller. Same reasoning as AutoValidateAntiforgeryToken in the csrf topic — per-endpoint protection is open-by-default, and endpoint 41 will be missing it. With a reverse proxy, set it there and in the app: the app must protect itself when deployed behind a different proxy.
/// <summary>/// Header chống nhúng, đặt ở MỘT chỗ cho MỌI response HTML.////// Một middleware chứ không một attribute trên từng action, và lý do giống hệt/// AutoValidateAntiforgeryToken ở topic csrf: bảo vệ theo từng endpoint là/// mặc-định-MỞ, nên action thứ 41 sẽ thiếu. Và quan trọng hơn — trang 404, trang/// lỗi 500, và các redirect trung gian KHÔNG đi qua action nào cả, nhưng chúng vẫn/// là trang nhúng được.////// Đặt cả ở đây VÀ ở reverse proxy: app phải tự bảo vệ được khi có ngày nó được/// deploy sau một proxy khác, hoặc chạy trực tiếp trong một môi trường test./// </summary>public sealed class FrameProtectionMiddleware(RequestDelegate next){ public async Task InvokeAsync(HttpContext ctx) { // OnStarting, không đặt trực tiếp: đặt trực tiếp ở đây thì response đã bắt // đầu ghi (một trang lỗi, một stream) sẽ ném InvalidOperationException, và // lúc đó middleware thành nguyên nhân của một sự cố thay vì một bản vá. ctx.Response.OnStarting(() => { var h = ctx.Response.Headers; // frame-ancestors là chuẩn hiện tại và nó THẮNG khi cả hai có mặt. // 'none' là mặc định nên chọn: không ai nhúng được, kể cả chính ta. // // Nếu có nhu cầu nhúng thật (partner, widget): liệt kê tường minh ở đây. // Đây là chỗ X-Frame-Options không làm được — ALLOW-FROM chưa bao giờ // được Chrome hỗ trợ, và đó là lý do người ta BỎ header đi thay vì // chuyển sang CSP. h.Append("Content-Security-Policy", "frame-ancestors 'none'"); // X-Frame-Options vẫn cần: trình duyệt cũ và một số WebView không hiểu // CSP. Hai header không xung đột — cái nào hiểu được thì áp cái đó. h["X-Frame-Options"] = "DENY"; return Task.CompletedTask; }); await next(ctx); }} // Program.cs — đặt SỚM trong pipeline để nó phủ cả trang lỗi do các middleware// sau ném ra. Đặt sau UseExceptionHandler thì đúng trang lỗi lại không có header.app.UseMiddleware<FrameProtectionMiddleware>();app.UseExceptionHandler("/error");app.UseStaticFiles();app.MapControllers(); // ── Lớp 1b · phá "một cú bấm" cho hành động không hồi phục được ─────────────/// <summary>/// Bản vá này không phụ thuộc header, nên nó là bản vá duy nhất còn đứng khi header/// bị một reverse proxy gỡ đi, hoặc khi trang nạp trong một WebView bỏ qua CSP.////// Clickjacking điều khiển được CON TRỎ, không điều khiển được BÀN PHÍM. Nên yêu/// cầu gõ một thứ là biện pháp trực tiếp — và nó chỉ áp cho hành động không hồi/// phục được hoặc cấp quyền, không áp cho mọi nút: mỗi bước xác nhận là ma sát./// </summary>public record DeleteAccountRequest(string CurrentPassword, string ConfirmPhrase); [HttpDelete("/api/account")]public async Task<IActionResult> DeleteAccount( [FromBody] DeleteAccountRequest req, CancellationToken ct){ var user = await _users.GetAsync(_currentUser.UserId, ct) ?? throw new NotFoundException(IdentityErrorsList.USER_NOT_FOUND); if (!_hasher.Verify(user.PasswordHash, req.CurrentPassword)) throw new ApplicationGeneralException(IdentityErrorsList.INVALID_PASSWORD); // Mẫu của GitHub: gõ một chuỗi cụ thể. Nó đòi bàn phím, nên một cú bấm bị // hijack không đi tới được đây. if (req.ConfirmPhrase != user.Handle) throw new ApplicationGeneralException(IdentityErrorsList.CONFIRMATION_MISMATCH, "Type your handle to confirm deletion"); await _users.SoftDeleteAsync(user.Id, ct); return NoContent();}Two-step confirmation for dangerous single-click actions
This fix does not depend on a header, so it is the only one still standing when some reverse proxy strips the header, or when the page loads in a WebView that ignores CSP.
Block 2 argues the impact depends on which single-click actions your app has. So break that:
- Re-enter the password for account deletion, disabling MFA, changing email (see csrf layer 3). An attacker can hijack a click; they cannot hijack typing a password.
- Type a string to confirm — "type the repository name to delete", GitHub's pattern. It requires the keyboard, and clickjacking only controls the pointer.
- For OAuth consent (the worst case in block 5): the
/authorizepage must beframe-ancestors 'none'and require non-single-click interaction. RFC 9700 (the OAuth 2.0 Security BCP) treats this as required.
State the trade-off: each confirmation step is friction. So apply it to actions that are irreversible or that grant access, not to every button.
`SameSite` cookies limit what a framed page can do
A little-known detail that is a genuine layer: a cross-site iframe is a cross-site context, so a SameSite=Strict or Lax cookie is not sent when your page is framed from another site.
The consequence: the framed page renders signed out, and clickjacking has nothing left to hijack — no session, and no dangerous button to click.
Which means if you already set SameSite=Lax (see csrf layer 1) you have an anti-clickjacking layer you may not know about. But do not rely on it alone, for two reasons:
- It disappears the moment somebody sets
SameSite=Nonefor a widget. - It does not protect a page that needs no login but still has actions — a "subscribe to the newsletter" form, or a voting page.
Layer 2 because it is a consequence rather than a deliberate control — and a control you acquire by accident is a control you lose by accident.
# ── nginx ───────────────────────────────────────────────────────────────────# Đặt ở CẢ HAI chỗ (app và proxy), không phải chọn một. Proxy phủ được trang lỗi do# chính nginx sinh ra (502, 504) — những trang mà app không bao giờ chạm tới. Và app# phủ được trường hợp nó được deploy sau một proxy khác, hoặc chạy trực tiếp.nginx: | server { listen 443 ssl http2; server_name app.example; # always là phần quyết định: không có nó, add_header BỎ QUA mọi response # 4xx/5xx — và trang lỗi nhúng được vẫn là một trang nhúng được. add_header Content-Security-Policy "frame-ancestors 'none'" always; add_header X-Frame-Options "DENY" always; location / { proxy_pass http://app:5100; # KHÔNG dùng proxy_hide_header cho hai header này: nếu app cũng đặt chúng # thì có hai giá trị, và trình duyệt lấy giá trị NGHIÊM NGẶT hơn với # frame-ancestors. Trùng lặp ở đây là an toàn, không phải một lỗi. } } # ── Trường hợp có partner nhúng thật ───────────────────────────────────────# Đây là chỗ X-Frame-Options KHÔNG làm được: ALLOW-FROM chưa bao giờ được Chrome# hỗ trợ. Nên khi có nhu cầu nhúng thật, cách đúng là liệt kê trong frame-ancestors# và BỎ X-Frame-Options cho đúng những route đó — không phải bỏ cả hai header.partner_embed: | # Chỉ áp cho route được nhúng, không áp toàn site. location /embed/ { add_header Content-Security-Policy "frame-ancestors https://partner.example https://app.example" always; # X-Frame-Options cố tình KHÔNG đặt ở đây: DENY sẽ chặn partner, và # SAMEORIGIN cũng vậy. Trình duyệt cũ không nhúng được — chấp nhận được, # vì phương án khác là bỏ bảo vệ cho toàn bộ site. proxy_pass http://app:5100; } # ── Kiểm sau mỗi deploy ────────────────────────────────────────────────────verify: | B=https://app.example FAIL=0 # /not-a-real-page và /error trong danh sách là có ý: chúng đi đường khác và # thường là chỗ duy nhất còn thiếu header. for p in / /login /settings /settings/danger /oauth/authorize /not-a-real-page /error; do H=$(curl -sI "$B$p" | tr -d '\r') echo "$H" | grep -qiE 'content-security-policy:.*frame-ancestors' \ || { echo "THIẾU frame-ancestors: $p"; FAIL=1; } echo "$H" | grep -qi 'x-frame-options' \ || echo " (cảnh báo) thiếu X-Frame-Options: $p" done exit $FAILDo not use JavaScript frame-busting
This is a layer whose content is do not do this, and it earns its place because frame-busting is still what people write first.
// DO NOT use. Broken since 2010, and it still appears in new code.if (top !== self) top.location = self.location;Bypassable several ways, each of them one line:
<iframe sandbox="allow-forms allow-scripts">— the sandbox blockstop.location, so the statement throws and the iframe stays.onbeforeunloadon the parent to cancel the navigation.- Navigation counting (
window.locationis throttled after ~200 attempts in some browsers). - Double-nesting the iframe.
And it does specific harm: it creates a feeling of being fixed. A page with frame-busting looks like clickjacking was handled, so nobody sets the header — and the header is the real fix.
If you truly must support a very old browser with no X-Frame-Options, the only usable pattern is hiding the content by default in CSS and revealing it from JS once you confirm you are not framed — but that is a page that does not work with JavaScript disabled.
Verifying the fix
This topic has the cheapest check in the whole catalogue: one curl, runnable against production right now. What keeps it alive is not difficulty of checking, it is that nobody checks.
1. Scan the header on EVERY important path, not just the homepage. The header is typically present on the homepage and absent on error pages, the OAuth consent page, or a new endpoint:
B=https://app.exampleFAIL=0for p in / /login /settings /settings/danger /oauth/authorize \ /account/delete /billing /not-a-real-page; do H=$(curl -sI "$B$p" | tr -d '\r') echo "$H" | grep -qiE 'content-security-policy:.*frame-ancestors' \ || { echo "MISSING frame-ancestors: $p"; FAIL=1; } echo "$H" | grep -qi 'x-frame-options' \ || echo " (warning) X-Frame-Options missing for older browsers: $p"doneexit $FAIL/not-a-real-page is in the list deliberately: the 404 page often takes a different route and skips the middleware — and a framable error page is still a framable page.
2. A counting test: every HTML response must carry the header. This is the check that turns "remember to set it" into "you cannot merge without it". See the csharp / test tab.
3. Check for real in a browser — the only check that proves it is actually unframable:
// Playwright. Load a page you construct with an iframe pointing at the app, and assert it is empty.test("the settings page cannot be framed", async ({ page }) => { await page.setContent(`<iframe id="t" src="${BASE}/settings/danger"></iframe>`); const frame = page.frame({ url: /settings/ }); // The browser refuses to load it → no frame, or an empty frame. expect(frame === null || (await frame.content()) === "").toBeTruthy();});4. A merge-blocking grep for JS frame-busting — it creates a false sense of being fixed and has been broken since 2010:
grep -rnE 'top *!== *self|top *!= *self|self *!== *top|top\.location *=' \ --include='*.ts' --include='*.tsx' --include='*.js' src/ \ && { echo "JS frame-busting — use frame-ancestors instead"; exit 1; }exit 05. Check dangerous actions need more than one click (layer 1b):
# Deleting the account WITHOUT a password must be 4xxcurl -s -o /dev/null -w '%{http_code}\n' -X DELETE "$B/api/account" \ -b "$COOKIES" -H "X-CSRF-Token: $T" # must be 422, not 2046. Enumerate the single-click actions and cross-check. This one needs a person: walk the UI, write down every button causing an irreversible effect or granting access, and assert each has a second confirmation step. That list belongs in the repo.
public class FrameProtectionTests : IClassFixture<ApiFixture>{ private readonly ApiFixture _fx; public FrameProtectionTests(ApiFixture fx) => _fx = fx; /// <summary> /// Danh sách này là DỮ LIỆU, và ba đường cuối là lý do test tồn tại: /// /// /oauth/authorize — trang có hành động đáng giá nhất (một cú bấm = access /// token dài hạn), và là trang hay thiếu header nhất. /// /not-a-real-page — trang 404 đi đường khác, không qua action nào. /// /error — trang 500 render sau khi exception handler chạy. /// /// Kiểm trang chủ rồi kết luận đã vá là sai lầm về phạm vi ở khối 8. /// </summary> [Theory] [InlineData("/")] [InlineData("/login")] [InlineData("/settings")] [InlineData("/settings/danger")] [InlineData("/oauth/authorize?client_id=x&redirect_uri=y")] [InlineData("/not-a-real-page")] [InlineData("/error")] public async Task Every_page_refuses_to_be_framed(string path) { var res = await _fx.Client.GetAsync(path); var csp = res.Headers.TryGetValues("Content-Security-Policy", out var v) ? string.Join("; ", v) : ""; Assert.Contains("frame-ancestors", csp, StringComparison.OrdinalIgnoreCase); Assert.Equal("DENY", res.Headers.GetValues("X-Frame-Options").Single()); } /// <summary> /// Phép ĐẾM, không phép thử: liệt kê MỌI endpoint trả HTML và khẳng định từng cái /// có header. Một trang mới thêm tuần sau tự động nằm trong phạm vi — đây là thứ /// biến "nhớ đặt header" thành "không đặt thì không merge được", và nó cho một /// dòng header vốn không nằm trong code của ai một chủ sở hữu. /// </summary> [Fact] public async Task No_html_endpoint_is_missing_the_header() { var htmlRoutes = _fx.Services.GetRequiredService<EndpointDataSource>().Endpoints .OfType<RouteEndpoint>() .Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods.Contains("GET") == true) // Chỉ route không có tham số: route có {id} cần dữ liệu để gọi được. .Where(e => !e.RoutePattern.RawText!.Contains('{')) .Select(e => "/" + e.RoutePattern.RawText!.TrimStart('/')) .Distinct() .ToList(); var missing = new List<string>(); foreach (var route in htmlRoutes) { var res = await _fx.Client.GetAsync(route); if (res.Content.Headers.ContentType?.MediaType != "text/html") continue; if (!res.Headers.TryGetValues("Content-Security-Policy", out var v) || !string.Join(";", v).Contains("frame-ancestors", StringComparison.OrdinalIgnoreCase)) missing.Add(route); } Assert.Empty(missing); } /// <summary> /// Frame-busting bằng JS đã bị phá từ 2010, và tác hại chính là nó tạo cảm giác /// đã vá. Test này chặn nó quay lại. /// </summary> [Fact] public void No_javascript_frame_busting_in_the_codebase() { var offenders = Directory .EnumerateFiles("src", "*.*", SearchOption.AllDirectories) .Where(f => f.EndsWith(".ts") || f.EndsWith(".tsx") || f.EndsWith(".cshtml")) .Where(f => !f.Contains("node_modules")) .Where(f => Regex.IsMatch(File.ReadAllText(f), @"tops*!==?s*self|selfs*!==?s*top|top.locations*=")) .ToList(); Assert.Empty(offenders); } /// <summary> /// Lớp 1b — clickjacking điều khiển con trỏ, không điều khiển bàn phím. Test này /// khẳng định hành động không hồi phục được cần cả hai thứ phải GÕ. /// </summary> [Theory] [InlineData(null, "bob")] // thiếu mật khẩu [InlineData("correct-horse", null)] // thiếu chuỗi xác nhận [InlineData("correct-horse", "wrong-handle")] // chuỗi sai public async Task Account_deletion_needs_typed_confirmation(string? password, string? phrase) { var req = new HttpRequestMessage(HttpMethod.Delete, "/api/account") { Content = JsonContent.Create(new { currentPassword = password, confirmPhrase = phrase }), }; var res = await _fx.ClientAs(_fx.Bob).SendAsync(req); Assert.Equal(HttpStatusCode.UnprocessableEntity, res.StatusCode); // Và tài khoản còn nguyên — khẳng định ở tầng dữ liệu, không ở status code. Assert.False((await _fx.GetUserFromDbAsync(_fx.Bob.Id)).IsDeleted); }}Common mistakes
| The "fix" | Why it is wrong |
|---|---|
| JavaScript frame-busting | Broken since 2010: sandbox blocks top.location, onbeforeunload cancels navigation. And worse: it creates a false sense of being fixed, so nobody sets the header |
X-Frame-Options: SAMEORIGIN alone | Obsolete, and ALLOW-FROM was never supported by Chrome. You need frame-ancestors for the partner case |
frame-ancestors alone | Older browsers and some WebViews do not understand CSP. Set both; they do not conflict |
| Setting the header on the homepage | Error pages, the OAuth consent page, and new endpoints usually take another route. The header belongs in ONE middleware covering every response |
| Setting it only at the reverse proxy | The app must protect itself when deployed behind a different proxy. Set it in both |
frame-ancestors in a <meta> tag | frame-ancestors is ignored in <meta> — it only works as an HTTP header |
| Relying on the CSRF token | The token is INSIDE the framed page, so it arrives intact. The click originates from your own origin |
| Rating it "low" because it needs user deception | A clickjacked OAuth consent button grants a long-lived access token. That is not low |
| Assuming coordinate alignment makes it impractical | The cursor-following variant in block 4 means the victim hits it wherever they click |
The ownership mistake, and the real reason this bug survives: the fix is one header line, so it lives in nobody's code. Developers think it is infra's job, infra thinks it is the app's. The way to fix that is a test in CI — it gives the job an owner.
The scoping mistake: checking the homepage and declaring victory. The real route is almost always a page the middleware does not touch: an error page, an intermediate redirect, a legacy page, or the consent page — the very page with the most valuable action on it.
Comments
Commenting needs an account with at least one completed lesson. That condition is what keeps this thread worth reading: every point belongs to someone who can be asked back, and reputation accrues over time.
You can still read every comment below without an account. Signing in brings you back to this exact spot, not to the top of the page.
Loading comments…