-
Notifications
You must be signed in to change notification settings - Fork 29.7k
-
Overview
If you’re familiar with the App Router, you’ve likely used notFound() to trigger 404 behavior alongside the customizable not-found.tsx file. In Next 15, we're actively exploring extending this approach to authorization errors:
• forbidden() triggers a 403 error with customizable UI via forbidden.tsx.
• unauthorized() triggers a 401 error with customizable UI via unauthorized.tsx.
Good to know: As with notFound() errors, the status code will be a 200 if the error is triggered after initial response headers have been sent. Learn more.
Enabling the feature
As this feature is still experimental, you’ll need to enable it in your next.config.js file:
const nextConfig = { experimental: { authInterrupts: true, }, }; export default nextConfig;
Using forbidden() and unauthorized()
You can use forbidden() and unauthorized() in Server Actions, Server Components, Client Components, or Route Handlers. Here’s an example:
import { verifySession } from '@/app/lib/dal'; import { forbidden } from 'next/navigation'; export default async function AdminPage() { const session = await verifySession(); // Check if the user has the 'admin' role if (session.role !== 'admin') { forbidden(); } // Render the admin page for authorized users return <h1>Admin Page</h1>; }
Creating custom error pages
To customize the error pages, create the following files:
// app/forbidden.tsx import Link from 'next/link'; export default function Forbidden() { return ( <div> <h2>Forbidden</h2> <p>You are not authorized to access this resource.</p> <Link href="/">Return Home</Link> </div> ); }
// app/unauthorized.tsx import Link from 'next/link'; export default function Unauthorized() { return ( <div> <h2>Unauthorized</h2> <p>Please log in to access this page.</p> <Link href="/login">Go to Login</Link> </div> ); }
You can read more about the APIs in the forbidden and unauthorized documentation.
Note: before we stabilize this feature for 15.2, we're planning on adding more capabilities and improvements to the APIs to support a wider range of use cases.
We're actively iterating on this API and would love to hear your feedback about how it could be improved. Please feel free to leave any comments/suggestions/concerns in this thread. Thank you!
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 70 -
😄 8 -
❤️ 12 -
🚀 11
Replies: 17 comments 25 replies
-
A common pattern for handling unauthenticated access is to redirect the user to the sign-in page, and then make a successful sign-in redirect back to the original starting point. Can that pattern be replicated with unauthorized()?
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 5
-
Sadly, the http 401 standard does not mention anything of supporting a new location. A client, of course, could implement it themself, but is not expected to - some clients may only redirect on 301, 302, 307 & 308. In such case you could use redirect() .
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 2
-
see the updated docs in this PR for an example of redirecting on error:
This does however not handle the case of redirecting back to the start point.
Beta Was this translation helpful? Give feedback.
All reactions
-
Here's an example which does that: https://gfcw3s-3001.csb.app/examples/redirect
The way it works is that unauthorized.js renders a client component which redirects the user to `/sign-in?redirectUrl=${window.location.href}`. Once the user signs-in, they are redirected back to the original page.
Alternatively, we should be able to just render <SignInPage> inline inside unauthorized.js. As the URL doesn't change, once the user signs-in then the page should just reload and be authorized. But that doesn't seem to be working for me. Here's an example: https://gfcw3s-3001.csb.app/examples/inline
It seems that, after setting the session cookie, the page does a soft-reload but remains on unauthorized.js. It's not until I do a hard-reload that I see page.tsx. My expectation is that setting a cookie should reset the interrupt, and return to page.tsx. Any ideas, @ztanner?
Beta Was this translation helpful? Give feedback.
All reactions
-
Thank you for the replies! 😃 Actually, I think what is important is that the user is shown the sign-in page (without any extra clicks) upon unauthenticated access, and is shown the original page (without any extra clicks) upon successful authentication.
In particular, I am concerned about obtaining a reference to the original page in generic way. Accessing the current URL is a known issue (see also #43704, #46618, #58212, #65282, #73800).
It seems that, after setting the session cookie, the page does a soft-reload but remains on
unauthorized.js. It's not until I do a hard-reload that I seepage.tsx. My expectation is that setting a cookie should reset the interrupt, and return topage.tsx.
I think that might be expected behavior. I think it might be the same as revalidatePath ("only invalidates the cache when the included path is next visited"), so a reload or redirect is needed.
I put together a similar demo with inline sign-in, but using a redirect: https://next-sign-in-in-place.vercel.app/ (source code).
For the redirect URL, I used the HTTP referer header. However, that header generally isn't reliable, so it would be good to have an alternative mechanism.
Another issue is the flash of white between redirects. That's to be expected, but it would be nice to eliminate it.
I think if Next.js provided a server-side refresh() function to trigger a client-side refresh, it could potentially address both problems.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
One thing that is still missing for me with not-found.tsx is the ability to set metadata (see #45620).
Now that there are more pages with forbidden.tsx and unauthorized.tsx it would be a good time add the ability to export metadata from these error pages.
Beta Was this translation helpful? Give feedback.
All reactions
-
Probably a good idea to also document on how streaming (loading.tsx) will affect this flow. Because to my understanding you will get status 200 instead of 401/403.
Beta Was this translation helpful? Give feedback.
All reactions
-
So this is very nice and long awaited (no pun intended), but I'm wondering why we're sugaring over status codes instead of just letting us return status(401); or something to that effect. Chaining it with redirect would be nice too return status(401).redirect('/');
I get the advantage of custom error pages (unauthorized.tsx), but we're adding cruft to web standards by not just leaning on them and creating our own language. If we can put in any status code, we can do 401.tsx or _401.tsx?
Can you explain the reasoning behind not just opening up any status code to be sent?
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 3
-
Regarding the redirect, see #73753 (reply in thread)
For webpages I think they now covered all possible return status codes that could render complete pages differently, other status codes are more meant for API endpoints? Instead of using the HTTP code as file name, it's easier to know what the file is for, instead of mapping the HTTP code to the correct meaning in your head or having to search what the HTTP code meant again.
Beta Was this translation helpful? Give feedback.
All reactions
-
What about a 500 or 503 if my database is down? Return a 201 from a server action? Creating a new set of web standards that are not portable and only specific to next.js is weird, and one of the common criticisms of this framework. Just let us lean on the standards and re-use our existing knowledge.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 7
-
You can throw a 500 by throwing an error (throw new Error()), which will then render the error.tsx. Gotta agree on the server-actions part, that is weird
Beta Was this translation helpful? Give feedback.
All reactions
-
Maybe the possibility of passing down a payload to the unauthorized / forbidden page? e.g. for info on which scope a user is missing.
Also a retry/reset function similar to error pages could be nice for forbidden, in case a colleague added the scope a few seconds later?
Beta Was this translation helpful? Give feedback.
All reactions
-
Passing down a cause will be possible once this is merged:
Edit: after reading your question and the PR again, the payload might not be available on the error page, but only when catching the error 🤔
Beta Was this translation helpful? Give feedback.
All reactions
-
❤️ 1
-
You could technically stringify some data into the error message then parse it out. The error passed into the function will be passed down as the cause prop to the error page
Beta Was this translation helpful? Give feedback.
All reactions
-
I wrote a feature request proposing this.
I like the approach in the PR: the payload gets attached as the cause.
@ztanner, is the intention to allow cause to be read from unauthorized.js and forbidden.js, presumably with a reset function as well?
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
Is it possible to globally check whether a user is authenticated and invoke unauthorized() without having to implement the check on every individual route or page? Based on the current documentation, it seems necessary to perform this authentication check separately for each route.
Is there a more efficient method or best practice for handling authentication across the entire application?
Thank you for your assistance!
Beta Was this translation helpful? Give feedback.
All reactions
-
This could be a possible solution:
Beta Was this translation helpful? Give feedback.
All reactions
-
Thats great!. This is exactly what i am looking for. Is this feature already available in the current canary build?
Thanks for sharing these details.
Beta Was this translation helpful? Give feedback.
All reactions
-
I'd like to suggest also exposing the Errors that are thrown by these methods or some function that can "identify" them.
The reason is that these control flows are realised via JS errors that are thrown and sometimes in a catch block, you need to differentiate if it's a not found/unauthorized/forbidden error and then re-throw it while handling other application errors.
So some method to identify these re-throwable errors would be nice.
It seems like library authors like for next-safe-action are reaching very deep into undocumented files to make this happen right now: https://github.com/TheEdoRan/next-safe-action/blob/86f00f422c6799e81c57d402dfc40354cf78db17/packages/next-safe-action/src/utils.ts#L5
Beta Was this translation helpful? Give feedback.
All reactions
-
See this PR:
Beta Was this translation helpful? Give feedback.
All reactions
-
Yes that might help but is not quite solving the problem yet.
Beta Was this translation helpful? Give feedback.
All reactions
-
I wonder... why instead of making specific functions for "popular" http response types, better a generic function that allows us to respond with any type of http response?
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 3 -
❤️ 3
-
Because the response has already been sent (is being sent, as part of the streaming). These methods cause the rendering to be interrupted and a soft-redirect is made from the client, but the status is always 200.
Beta Was this translation helpful? Give feedback.
All reactions
-
Well but we don't care so much about the status code but about the error message we display to the user.
For example, there is currently no chance to properly display 50x messages to the user.
Beta Was this translation helpful? Give feedback.
All reactions
-
Although they are useful, I wonder if it is appropriate to use them in server components. For me it makes more sense from an earlier stage, the middleware, to check this and return the Response if necessary with the appropriate status. In the end if you are already rendering the page in streaming the headers have already been sent and using these methods cause a soft-redirect from the client, where the status is always 200, which is fine to give feedback to the users, but I have my doubts if it is good practice.
Beta Was this translation helpful? Give feedback.
All reactions
-
ok, can make sense to use it inside server actions
Beta Was this translation helpful? Give feedback.
All reactions
-
Could you share the suggested approach for implementing reverification? (e.g. user is allowed to perform, but needs to reverify their credentials before the sensitive action is allowed). We are unsure how to solve it with this API, primarily because we think we'd need "cause" on the client.
We're particularly interested in how we can do it as a library, but happy to try and derive from the individual developer example.
The specific examples we'd like to understand are:
- A client component is using useActionState to call a server action. In the server action we want to call
forbidden({cause: "needs_reverification"})and somehow get the cause from the state - A client component is calling fetch() to a route handler. In the route handler, we want to call
forbidden({cause: "needs_reverification"})and determine the cause from the response
Alternatively - is the advice not to use these APIs and return early instead? If so, can you provide a mental model for when a throw-based flow is suggested (redirect/forbidden/notFound) vs when a early return flow is suggested?
Beta Was this translation helpful? Give feedback.
All reactions
-
Hi @colinclerk , the general idea is that these interrupt style functions like notFound and forbidden are maybe best reserved for invariant cases. For instance if you have no recourse (like reverifying the user) and we just need to render some UI which communicates the intent. They're a decent proxy for when that same request would be a 401/403/404 for a traditional HTTP server serving static files.
So you might use forbidden in a protect API which terminates the current code (like don't actually execute this query b/c you aren't allowed to access this data) but you would want to catch this and handle it to enrich the situation.
function protect(fn) {
// figure out if this users is allowed to do the thing
if (!allowed) {
const cause = {
code: NEEDS_REVERIFICATION,
details: ...// whatever you need here
}
forbidden(cause)
}
return fn()
}
// user code
async function getSensitiveData() {
return protect(() => db.query(...))
}
// server function using getSensitiveData indirectly
async function doAction() {
"use server"
return protectAction(() => {
return doStuffThatDeeplyCallsGetSensitiveData()
})
}
// protectAction definition
function protectAction(fn) {
try {
return await fn()
} catch (e) {
let cause = e.cause
while(cause) {
if (isProtectedCause(cause)) {
return ... // something that the auth framework on the client can understand
}
if (typeof cause !== 'object') {
break
}
// go deeper maybe something wrapped our protect cause
cause = cause.cause
}
}
// if we got here then the error isn't some protection error, we can let it bubble up
throw e
}
On the client you'd need to have a way to interpret the protection return value. It might look like
"use client"
function Page({ serverAction }) {
const [protectedState, formAction] = useActionState(serverAction, initialState)
// We need to handle the protected return value here
// If the protected state were something exceptional like needs reverification then you'd
// maybe throw here and have some auth library error handler handle it. crucially you have whatever data
// you needed to pass through to this consumer such as the `{ code: NEEDS_REVERIFICATION, details: ... }` from above
const state = useProtectedState(protectedState)
// At this point state is now just the normal value returned by the server action inner function
}
So if you use forbidden in protect in this example but you forget to use protectAction on the server as a safety fallback the forbidden boundary will be triggered on the client. But this very likely isn't the best UX and users will want to implement that better UX using the protectAction and useProtectedState hooks to give them more control over handling exceptional cases based on the details relevant to that case. This is just a sketch of how you might implement auth utils library. Certainly there are other valid ways to set this up but using the return style rather than throw style is how we imagine the "good UX" pattern would be implemented.
One thing that I'm curious about from your specific example is why the reverification needs to happen on the client. if all that needs to happen to make this action possible is a refresh then performing said refresh within the action itself seems preferable. If that's not possible then using forbidden (or even the aforementioned useProtectedState) is a little tricky because it'll destroy the local state of the page to render the forbidden page and so even if the error boundary ends up reverifying the user when the components remount the local state will have been lost. Solvable with things like controlled components maybe but not an insigificant consequence of relying on the throwing behavior of forbidden.
Beta Was this translation helpful? Give feedback.
All reactions
-
cc @ztanner
Beta Was this translation helpful? Give feedback.
All reactions
-
From what I can tell, forbidden() is used for 403 responses (ie. unauthorized), whereas the unauthorized() function is actually used for 401 responses. Would it not be more accurate to name unauthorized(), unauthenticated(), since that is what it is used for?
I am aware that the 401 HTTP response is also named "Unauthorized", which is probably where your naming convention is taken from.
Beta Was this translation helpful? Give feedback.
All reactions
-
In this component, can we know which page the user is coming from? I like to redirect users to the page they were trying to access after signing in. Currently doing it with cookies in a dirty way. I'd like to replace it with this api, I could save the pathname to localStorage and then I can ask if they like to go to that page after they sign in.
app/unauthorized.tsx
import Link from 'next/link'; export default function Unauthorized() { return ( <div> <h2>Unauthorized</h2> <p>Please log in to access this page.</p> <Link href="/login">Go to Login</Link> </div> ); }
Beta Was this translation helpful? Give feedback.
All reactions
-
I agree, this is a very common use case. We were discussing it in #73753 (comment).
My take is that, instead of a link to a sign-in form, you could show the sign-in form in place of the restricted content. Thus the browser would still be at the desired URL. Then on a successful sign-in (via a server action), you could somehow refresh the page and show the actual content. The question is: how should the refresh be triggered?
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 3
-
Would it be possible to create a NextJS API for doing this with any HTTP status code? Or at least some more of the SEO related ones? Our sites heavily rely on SEO and we sometimes want to utilize 410 (Gone) and other more "edge case" statuses.
There are a few more discussions on this:
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 12 -
❤️ 6
-
This RFC is definitely a positive step forward, this has been a pain point throughout my time using Next App Router - but I need to raise a use case that isn't covered.
I'm using the casl library to handle permissions checking, which has a ForbiddenError type which can be thrown when a user is missing a permission:
import { ForbiddenError } from "@casl/ability"; import { getAbilityForUser } from "@/lib/auth"; export default async function FooPage() { const ability = await getAbilityForUser(); ForbiddenError.from(ability).throwUnlessCan("read", "Post"); // throws a ForbiddenError if the user doesn't have the right permission }
However, this is still caught as a normal error. I can work around this by doing something like
export default async function FooPage() { const ability = await getAbilityForUser(); if (!ability.can("read", "Post")) { forbidden(); } }
However, one case where I can't use this workaround is when using the casl Prisma integration and its accessibleBy API, because in case user doesn't have ability [...], accessibleBy throws ForbiddenError, so be ready to catch it! source
import { accessibleBy } from "@casl/prisma"; export default async function FooPage() { const ability = await getAbilityForUser(); // This throws a casl ForbiddenError if the user cannot access any Posts const posts = await prisma.post.findMany({ where: accessibleBy(ability).Post, }); }
I can work around this by catching the error but it gets ugly quick, particularly if you have multiple database reads in a component:
export default async function FooPage() { const ability = await getAbilityForUser(); let posts; try { posts = await prisma.post.findMany({ where: accessibleBy(ability).Post, }); } catch (e) { if (e instanceof ForbiddenError) { notFound(); } throw e; } }
Beta Was this translation helpful? Give feedback.
All reactions
-
This might be better as a separate feature request, but I wonder if it's worth generalising this: in next.config.js specify a list of error types for which the name (and no other properties) are exposed to an error boundary:
module.exports = { exposeErrorNames: ["ForbiddenError"], };
"use client"; export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { if (error.name === "ForbiddenError") { // render forbidden UI } // render internal server error UI }
Beta Was this translation helpful? Give feedback.
All reactions
-
I think this is an important use case, especially because Next.js advocates pushing auth logic into the data layer, as accessibleBy does in your example.
Referring to the error by class name might be too fragile though. What if different libraries use error classes with the same name? Or what if an error class is renamed?
I think what we want is to abstract the try / catch in your first post. I wonder if the proposed request interceptors could help? There was a suggestion for interceptors to accept an argument that wraps the rendering process. So perhaps your example could be handled with something like:
export default async function intercept(request: NextRequest, complete: Thenable<void>) { try { await complete; } catch (e) { if (e instanceof ForbiddenError) { notFound(); } throw e; } }
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
Shouldn't this be an early return? return forbidden() or alternatively a throw
export default async function AdminPage() { const session = await verifySession(); // Check if the user has the 'admin' role if (session.role !== 'admin') { forbidden(); } // Render the admin page for authorized users return <h1>Admin Page</h1>; }
There's nothing in the code that indicates that the final Admin Page return won't be reached.
Beta Was this translation helpful? Give feedback.
All reactions
-
forbidden() will be like notFound(), which has the never return type. Behind the scenes, it throws a special object that Next.js knows to catch and handle.
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 2
-
I understand that's how it works under the hood, I'm talking purely from a DX perspective, I'm expecting some terminating thing to appear in the code (either a return or a throw)
Beta Was this translation helpful? Give feedback.
All reactions
-
You can return forbidden(); if you want, same as you can return notFound(); - it won't do anything
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 3
-
We came across the same thing in our projects and would love the find a solution for handling even more cases like 50x errors.
Imho inventing more and more different functions/templates for setting up error UI is not the right way.
One possible solution is, that we get rid of all the different functions and templates and have a more generic error function we can pass the status code to:
// page.tsx import { error } from 'next/navigation'; export default async function AdminPage() { const session = await verifySession(); if (session.role !== 'admin') { error(403); } const User = await getUser(session.userId).catch((error) => { error(error.status); // API requests always throw ApiError with status code attached in our case }); return <h1 user={user}>Admin Page</h1>; }
// error.tsx export default function Error({ error, code, reset, }: { error: Error & { digest?: string }, code?: number, reset: () => void }) { return ( <div> <h2>{code}: Something went wrong!</h2>{/* here we now can granually tell the user what went wrong */} <button onClick={ // Attempt to recover by trying to re-render the segment () => reset() } > Try again </button> </div> ) }
see also #77065
Beta Was this translation helpful? Give feedback.
All reactions
-
👍 1
-
Hey, definitely would be great to see more status codes supported, or some kind of generic mechanism to allow developers to define their own status code in parameters. There are more SEO important status codes which are missing like HTTP 410 Gone for example.
Beta Was this translation helpful? Give feedback.