Rebounder Tech Blog

Written by the people who actually run these systems in production.

Auth Middleware Redirects robots.txt to the Login Page

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

Auth middleware decides public and private by a path allow-list, so forgetting well-known files like robots.txt or sitemap.xml redirects them to /login and crawlers receive the login page body.

Conclusion

Middleware that sends unauthenticated requests to /login tends to treat well-known files like robots.txt and sitemap.xml the same way. Crawlers receive a redirect to the login page instead of the file, and the Lighthouse SEO audit fails.

The cause is in two layers.

  1. robots.txt and friends are missing from the middleware allow-list → treated as unauthenticated and 307’d to /login
  2. Public pages added later inherit the root layout’s noindex → stopping the redirect still leaves them out of search results

Fix one and the other’s symptom remains.

Symptom

In a setup where public pages (an application form, say) were added later on top of an internal business system, running a Lighthouse audit drops the SEO score sharply.

Tracing it, requests to /robots.txt hit the middleware’s auth check and are 307’d to /login. What the crawler receives is not robots rules but a nudge towards the login screen.

manifest.webmanifest was caught the same way. The browser tries to parse it as JSON, so the returned login-page HTML is a syntax error, and the console fills with errors. Lighthouse’s “are there console errors” check loses points there too.

Cause

Layer one — well-known files missing from the allow-list

This app’s middleware inspects the path to decide “may this be accessed without logging in”. It is a whitelist, enumerating the public paths.

const isPublic =
  path.startsWith("/p/") ||
  path === "/apply" ||
  path.startsWith("/apply/") ||
  path === "/terms" ||
  // ...

It looks exhaustive, but /robots.txt, /sitemap.xml and /manifest.webmanifest are absent. These are not files you think of as pages. Enumerating “which paths should be public” naturally means listing the pages you actually open in a browser, and the well-known files that crawlers and browsers fetch implicitly fall outside that.

A path not on the allow-list falls into the auth branch and is redirected to /login.

Layer two — public pages inherit the root layout’s noindex

The root layout defaulted to robots: { index: false, follow: false }. With the internal business system making up most of the app, not wanting it in search is a sound judgement.

export const metadata: Metadata = {
  // ...
  robots: { index: false, follow: false },
};

The problem is that public pages added later inherit that default. Next.js merges metadata from nested layouts, but unless robots is explicitly overridden, the root’s noindex stays in effect on the page.

Fixing only the layer-one 307 makes robots.txt return correctly, but a public page that inherited noindex still will not appear in search even though robots now permits it. The two causes are independent, and fixing one does not fix the other.

The fix

Layer one — add the well-known files to the allow-list

const isPublic =
  path === "/robots.txt" ||
  path === "/sitemap.xml" ||
  path === "/manifest.webmanifest" ||
  path.startsWith("/p/") ||
  path === "/apply" ||
  // ...

Explicit robots.ts and sitemap.ts were also added, permitting only what should be public and explicitly denying authenticated paths and URLs containing tokens.

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: "*",
      allow: ["/apply", "/terms"],
      disallow: "/",
    },
    sitemap: `${APP_URL}/sitemap.xml`,
  };
}

Putting disallow: "/" first means that as paths grow, only what is explicitly allowed becomes public, so a missed allow-list entry fails closed rather than exposing something.

Layer two — opt public pages back into indexing

The root layout keeps noindex; the public page’s metadata overrides it.

export const metadata: Metadata = {
  title: "...",
  description: "...",
  robots: { index: true, follow: true },
};

That override has to be written every time a public page is added. Flipping the root default to index: true would instead risk exposing internal pages to search, so the default stays private with individual opt-ins.

Why it went unnoticed

Neither cause shows up in ordinary browsing. A logged-in developer opening /apply sees the page correctly, and opening /robots.txt by hand hides the 307 because the browser follows the redirect silently.

It only surfaces from a “logged out, does not follow redirects” viewpoint like Lighthouse’s. The middleware allow-list and the layout’s robots inheritance are both accumulations of changes that look reasonable line by line in review. Unless adding a public page comes with a per-page check for “do the well-known files pass” and “did I override noindex”, the same gap keeps reappearing.

For another case where a fix in one layer just revealed the next, see Astro 7 breaking content collections in three places at once.

よくある質問

Q1How do I check whether robots.txt is being redirected?

Request /robots.txt while logged out and look at the status code. A 307 or 302 with a Location header pointing at /login is the case. A browser follows the redirect automatically, so use something like curl -I that does not follow it.

Q2Does excluding static files in matcher fix it?

Not on its own. Adding robots.txt to the middleware allow-list is layer one and stops the 307. But if the root layout defaults to noindex, public pages still need to opt back in, or they stay out of search results even once the redirect is gone.

Q3Is defaulting the root layout to noindex wrong?

No. For an internal business system where most pages should not be searchable, it is a sensible default. The problem is forgetting that noindex is inherited when a public page is added later. That page has to override it explicitly.

Q4Is the redirect the only thing lowering the Lighthouse SEO score?

No. Besides the redirect being detected as a robots.txt problem, manifest.webmanifest also gets caught by the middleware and returns login-page HTML, which the browser then fails to parse as JSON. That console error costs points in a separate audit as well.

この記事の根拠

  • TypeScriptファイル 44〜74行目コミット 7ca4aab
  • TypeScriptファイル 1〜20行目コミット 7ca4aab
  • TypeScriptファイル 12〜25行目コミット 7ca4aab

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。