PubTrivia generates its own link preview cards. Fourteen files across the app directory draw them at request-independent build time, so every section of the site has its own card.
For a while every one of them answered a crawler with 307 Temporary Redirect and a Location of /login.
The site looked fine. Every share of it, in Slack, on social, in a search result, rendered as a card with no image.
Why this class of bug is hard to see
Two things conspire.
The first is that a metadata image in the App Router is not an asset, it is a route. Drop opengraph-image.tsx into a segment and the framework creates an endpoint that returns the image and injects the meta tag pointing at it. That is the whole feature and it is lovely, right up to the moment you have middleware.
The second is that you are signed in. All of your testing, all of your clicking around, every screenshot you take of your own site, happens with a session cookie. The only client that hits the broken path is one with no cookie at all, and those clients are crawlers, which do not file bug reports. The card renders correctly in your browser while being unreachable for every consumer that matters.
Our middleware matcher excludes the usual set: the static chunks, the image optimiser, the favicon, the web manifest, robots.txt, sitemap.xml, the webhook and API prefixes, and anything whose path ends in an image extension.
Look at that last one against what the framework actually renders. From the live homepage:
curl -s https://pub-trivia.app/ | grep -o 'og:image[^>]*'
og:image" content="https://pub-trivia.app/opengraph-image?aadf49872615f2d6"
No .png anywhere in it. The path is /opengraph-image, followed by a generated cache-busting token. An exclusion that keys off a trailing image extension does not match, so the request falls through to the auth gate like any page, and the gate does the thing it was built to do.
The list that cannot be kept
The first fix was to add the paths to the public-route allowlist, which is a single array this codebase already uses to tell middleware what may be fetched without a session.
It does not work, for three reasons that each look small.
There are fourteen of these files, at the root and in six content sections, two per section. That is already a list nobody will maintain by hand.
The URL is not stable in the way a list needs. It carries a generated token, and where that token goes depends on the environment and on route configuration: in the query string here, in the filename in other setups. A list of literal paths is matching against a string the framework reserves the right to change.
And the failure is silent in the worst way. Add a new content section with its own card next week, forget the allowlist entry, and nothing breaks. No error, no failed build, no 404. Just one more section of the site whose shares are blank, discovered whenever somebody happens to paste that URL into a chat window.
Match the shape, and only the last segment
So the rule is structural rather than enumerated:
const METADATA_IMAGE_SEGMENT =
/^(opengraph-image|twitter-image|icon|apple-icon)(-[\w-]+)?(\.(png|jpg|jpeg|gif|svg|ico))?$/
export function isMetadataImageRoute(pathname: string): boolean {
const lastSegment = pathname.split('/').pop() ?? ''
return METADATA_IMAGE_SEGMENT.test(lastSegment)
}
Four names, because icon and apple-icon are the same feature wearing a different hat and would have broken the same way. An optional suffix and an optional extension, because both are things the framework may add.
The important line is the second one in the function. It tests the final path segment only, and that is what stops this being a hole punched through the auth gate.
Consider what the alternative would do. A prefix or substring match on opengraph-image anywhere in the path makes any URL containing that string public. Match on the last segment and the most an attacker can reach is a card: a private section could declare its own metadata image, that image becomes fetchable, and nothing else about the section changes. The parent path is unaffected, because the parent path's last segment is not in the regex.
And a metadata image is the one thing on a site that is published to crawlers by definition. An endpoint whose entire purpose is to be fetched by anonymous robots and rendered in other people's chat clients is not a thing to be protecting with a session check. Making the category public is not a concession, it is the correct classification that the middleware matcher had failed to express.
Prove it from outside
The cards are prerendered at build time, so once past the gate the only question is whether a cookie-less request gets a redirect or a PNG:
curl -sI https://pub-trivia.app/opengraph-image | head -2
curl -sI https://pub-trivia.app/compare/opengraph-image | head -2
Both answer 200 with content-type: image/png, around 77 to 92 KB each. You can also paste any page of the site into a social preview debugger and see the card come back, which is the test that actually matches the use case, since it exercises a real third-party fetcher with no cookie rather than your own curl.
For the specific cards, try the compare section or the guides, each of which has its own.
The general form
If you have middleware doing anything per request, go through the list of things your framework serves from code rather than from a static directory, and ask whether each one survives an anonymous request. On this site that list was longer than it looked: robots.txt and sitemap.xml are generated routes too, and had the same bug first. Every one of them is fetched exclusively by clients with no session, and every one of them fails in a way that is invisible from a signed-in browser.
The pattern I would offer is: route-shaped assets need a positive rule in the auth layer, and that rule should match on structure, not on a list, because the list is maintained by the thing you forget.
Everything above is checkable: pull the homepage and grep for og:image, then fetch the URL it gives you and look at the status. The site those cards link to runs live pub quiz nights, and the free tier does not ask for a card of the other kind.
Top comments (0)