Back to Feed
article 3m read

Newer Next.js turned my header read into a promise, and every permission guard had to await it

Reading request headers became async, so every auth helper and guarded page in my staffing app had to await it. The callers that use the guard's answer get caught by the compiler; the ones that call it as a bare line don't, and nothing in the commit tests that a signed-out visit still bounces.

Article DepthPractice

Flow

Moving permission checks to async

  1. 1Make the header read wait

    The bottom helper awaits the headers and returns a promise.

  2. 2Let each guard await the one below

    Every helper above it becomes async in the same change.

  3. 3Await the guard in every page

    Pages and layouts become async and wait before rendering anything.

  4. 4Look hardest at bare guard lines

    A page that ignores the answer still compiles without the await.

  5. 5Prove the bounce

    A signed-out visit to each locked page should end in a redirect. None in this commit.

The staffing app checks who you are by reading the request headers. The newer Next.js changed that read. It used to give you the headers. Now it gives you a promise, and you have to await it. My auth code still acted like it got the headers.

The commit that fixed it just says auth needed fixing for the new header API. I don't know what the broken build looked like in the browser, so I won't guess. The diff is the good part.

Four helpers on one header read

My reader expects the user's ID and role to already be in the headers. I assume something in front of the app puts them there; the diff only shows the read. I have a small stack of helpers on top. One reads the user. One sends you to login if there's no user. Two more check a role or a permission and send you back to the dashboard if you fail. A locked page calls the strictest one before showing anything locked.

Once the bottom helper waits, the ones above it have to wait too. Then every page that calls them has to wait. So the diff is mostly two words, over and over: async on the page, await on the check.

Callers that use the answer, and ones that don't

Some pages use what the check hands back, like the user's permissions. If you forget the await there, TypeScript stops you. Good.

Other pages call the check as a bare line and ignore the answer. Several admin pages did this. That compiles whether you wait or not:

mustHave("reports");   // no await, no guard

What a missing await does to the redirect

The redirect works by throwing. In an async check, that throw doesn't stop the page. It turns into a failed promise. If the page waits, the redirect still works. If it doesn't, my read is that the page goes on and shows the locked content. I didn't test that. It's my reading of how promises work, and it's why I care about those bare lines. Each one got its await.

If you are making the same migration, the safer shape is to have every protected page actually use the user the check returns, because then a forgotten await becomes a build error instead of an open door. I haven't made that change yet. A lint rule that flags promises nobody waits for would catch the bare line too, and I don't know yet if this project has one turned on.

The commit also adjusted a few compiler settings, which I'd guess is the toolchain catching up with the framework. None of that touches permissions.

No test in that commit visits signed out

There's no test in that commit that visits an admin page while signed out and expects to be redirected. And the diff can't tell me whether the layer that sets the headers would turn a signed-out admin request away before my pages ever saw it.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward