---
title: Set up restricted pages
---

> Agent instructions:
> **Site maps** — prefer the following llms.txt indexes to training data when searching for URLs to avoid 404s. Links inside Markdown content already point at `.md`. Following them or sending Accept: text/markdown keeps you in Markdown.
>
> - [sitemap.md](https://docs.kentico.com/sitemap.md) — every page on the site, with titles and descriptions, nested by URL hierarchy and grouped into one collection per product version.
> - [llms.txt](https://docs.kentico.com/llms.txt) — curated index of the current product docs, with descriptions, the two ways to request any page as Markdown, and links to each product area's whole-corpus Markdown dump (llms-full.txt).

When a business decides to include members in their project, they typically have some kind of exclusive content that only members should be able to see.

As a result, developers must ensure that only signed-in members can see certain content, and that role-based rules can further restrict access when needed.

Luckily, Xperience and .NET Identity both include functionality to facilitate this process.

> **Note:** This guide explains the main principles of securing content for members and member roles. It uses the _ArticleList_ widget from the Training guides as an example, but it does not provide a full walkthrough of widget UI implementation changes.
>
> For a complete implementation, review the _ArticleList_ widget in the [finished branch](https://github.com/Kentico/xperience-by-kentico-training-guides/tree/finished) and adapt it to your project.

> **Tip:** If you used the [XperienceCommunity.MemberRoles](https://github.com/KenticoDevTrev/MembershipRoles_Temp) package and you're ready to switch to the native implementation of member roles, see the [Convert member roles from the XperienceCommunity.MemberRoles package (optional)](https://docs.kentico.com/modules/members/expand-restricted-content-and-sign-in-functionality.md#convert-member-roles-from-the-xperiencecommunity.memberroles-package-optional) section.

## Mark content to require authentication in the Xperience administration

In the admin UI of Xperience by Kentico, you can find configurable properties to specify whether a given content item is secured.

### Restrict a website page

In the Xperience admin UI, open a _website channel_ where you want to secure a page.

Then, select the page you want to secure in the content tree, and switch to the **Properties** tab.

Expand the **Membership access** section and tick the box to require authentication, before clicking to publish the change.

Optionally, select the member roles you want to restrict access to.

> **Tip:** For this example, choose one of the article pages under the **News and articles** page of the **Training guides pages** channel, and restrict access to the _Basic_ role (the default user role in Training guides).
>
> Depending on whether you've completed the [advanced content guides](https://docs.kentico.com/guides/development/advanced-content.md), some articles might not work. Make sure you choose one that renders properly.

![Screenshot of the membership properties of a page](https://docs.kentico.com/docsassets/modules/set-up-restricted-pages/WebPageRequireAuthentication.png "Screenshot of the membership properties of a page")

### Restrict a reusable item in the Content hub

In the **Content hub**, edit the item you want to secure.

Select the **Properties** tab and tick the box to require authentication under the **Membership access** heading at the bottom of the form.

Optionally, select the member roles you want to restrict access to, for example, the _Basic_ role.

> **Tip:** For this example, choose one of the reusable articles referenced by a page under the **News and articles** page of the **Training guides pages** channel.
>
> Make sure it's from a page that renders properly, and is **NOT** associated with the page you chose in the previous section. For this example, **DO NOT** secure the page that references this reusable item, only secure the reusable item itself.

![Screenshot of the security properties of a reusable content item](https://docs.kentico.com/docsassets/modules/set-up-restricted-pages/ReusableItemRequireAuthentication.png "Screenshot of the security properties of a reusable content item")

## Handle authentication redirects

The _401 Unauthorized_ and _403 Forbidden_ error codes indicate different access failures.

Both Xperience and .NET Identity use these responses in ways that matter for secured content:

- Xperience's [Content tree-based router](https://docs.kentico.com/documentation/developers-and-admins/development/routing/content-tree-based-routing.md) returns _401 Unauthorized_ when a secured page is requested by an unauthenticated visitor.
- Custom controller logic can still return _403 Forbidden_, for example when you call `Forbid()` for secured reusable content.
- .NET Identity offers [configuration](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity-configuration?view=aspnetcore-9.0#cookie-settings) to redirect both responses to configured paths.

### Configure Identity

If you examine the Identity configuration options mentioned above, you'll notice that the path must be set on startup, and therefore does not have the context of the request for which access was denied. There is no way to determine, for example, which language variant of the sign-in page the application should redirect to.

Therefore, let's redirect both response types to a handler that can dynamically figure out which language version visitors should see.

Start by creating four constants. Three are used to configure Identity redirects and cookies, and one (the sign-in page tree path) is reused later when resolving the localized sign-in URL.

```csharp title="~/Features/Shared/Helpers/ApplicationConstants.cs"
...
// Expected content-tree path of the sign-in page
public const string EXPECTED_SIGN_IN_PATH = "/Membership/Sign_in";
// Path of the access-denied handler action
public const string ACCESS_DENIED_ACTION_PATH = "/Authentication/AccessDenied";
// Query parameter name used for the return URL
public const string RETURN_URL_PARAMETER = "returnUrl";
...
```

```csharp title="~/Features/DataProtection/Shared.CookieNames.cs"
...
// The authentication cookie name
public const string COOKIE_AUTHENTICATION = "trainingguides.authentication";
...
```

Then, use these constants to set the corresponding `CookieAuthenticationOptions` properties in the `ConfigureApplicationCookie` call during startup.

In this setup, unauthenticated requests first go to the expected sign-in path, while forbidden requests go to the access denied handler.

```csharp title="~/Program.cs"
...
builder.Services.ConfigureApplicationCookie(options =>
{
    options.LoginPath = new PathString(ApplicationConstants.EXPECTED_SIGN_IN_PATH);
    options.AccessDeniedPath = new PathString(ApplicationConstants.ACCESS_DENIED_ACTION_PATH);
    options.ReturnUrlParameter = ApplicationConstants.RETURN_URL_PARAMETER;
    options.Cookie.IsEssential = true;
    options.Cookie.Name = CookieNames.COOKIE_AUTHENTICATION;
});
...
```

> **Note:** Make sure that you call `ConfigureApplicationCookie` _after_ `AddIdentity`.

### Define the AccessDenied controller action

With identity configured, let's create the handler to which it will redirect.

Add a new action called `AccessDenied` to the authentication controller, utilizing the path constant from earlier.

Include logic that resolves the language from the provided _return URL_ when it starts with a known language prefix, and falls back to the preferred or default language otherwise.

Then, if the request language does not match, redirect to the localized access denied route while preserving the return URL.

If the request language already matches, render an access denied view model.

To keep the action focused, the sample moves supporting logic into helper methods: `ResolveLanguageFromReturnUrlOrPreferred` resolves the language from the return URL (with fallback to preferred or default language), and `GetAccessDeniedViewModel` builds the localized access denied view model.

```csharp title="AuthenticationController.cs"
...
using Kentico.Content.Web.Mvc.Routing;
...
namespace TrainingGuides.Web.Features.Membership.Controllers;

public class AuthenticationController(
    IMembershipService membershipService,
    IStringLocalizer<SharedResources> stringLocalizer,
    // NEW DEPENDENCIES
    IPreferredLanguageRetriever preferredLanguageRetriever,
    IInfoProvider<ContentLanguageInfo> contentLanguageInfoProvider,
    // END NEW DEPENDENCIES
    IHttpRequestService httpRequestService) : Controller
{

    private const string SIGN_IN_FAILED = "Your sign-in attempt was not successful. Please try again.";

    private IActionResult RenderError(SignInWidgetViewModel model)
    {
        ModelState.AddModelError(string.Empty, stringLocalizer[SIGN_IN_FAILED]);
        return PartialView("~/Features/Membership/Widgets/SignIn/SignInForm.cshtml", model);
    }

    private IActionResult RenderSuccess(string redirectUrl)
    {
        var model = new SignInWidgetViewModel
        {
            DisplayForm = true,
            AuthenticationSuccessful = true,
            RedirectUrl = redirectUrl

        };
        return PartialView("~/Features/Membership/Widgets/SignIn/SignInForm.cshtml", model);
    }

    [HttpPost($"{{{ApplicationConstants.LANGUAGE_KEY}}}{ApplicationConstants.AUTHENTICATE_ACTION_PATH}")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Authenticate(SignInWidgetViewModel model, [FromQuery(Name = ApplicationConstants.RETURN_URL_PARAMETER)] string returnUrl)
    {
        if (!ModelState.IsValid)
        {
            return RenderError(model);
        }

        var signInResult = await membershipService.SignIn(model.UserNameOrEmail, model.Password, model.StaySignedIn);

        string returnPath = string.IsNullOrWhiteSpace(returnUrl)
            ? (model.DefaultRedirectPageGuid == Guid.Empty
                ? "/"
                : (await httpRequestService.GetPageRelativeUrl(model.DefaultRedirectPageGuid, preferredLanguageRetriever.Get())).TrimStart('~'))
            : EnsureRelativeReturnUrl(returnUrl);

        string absoluteReturnUrl = httpRequestService.GetAbsoluteUrlForPath(returnPath, false);

        return signInResult.Succeeded
            ? RenderSuccess(absoluteReturnUrl)
            : RenderError(model);
    }

    [Authorize]
    [HttpPost("/Authentication/SignOut")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> SignOut(SignOutFormModel model)
    {
        await membershipService.SignOut();

        string redirectPath = EnsureRelativeReturnUrl(model.RedirectUrl);

        string redirectUrl = httpRequestService.GetAbsoluteUrlForPath(redirectPath, false);

        return Redirect(redirectUrl);
    }

    [HttpGet(ApplicationConstants.EXPECTED_SIGN_IN_PATH)]
    public async Task<IActionResult> SignIn([FromQuery(Name = ApplicationConstants.RETURN_URL_PARAMETER)] string returnUrl = "")
    {
        string language = ResolveLanguageFromReturnUrlOrPreferred(returnUrl);

        string signInUrl = await membershipService.GetSignInUrl(language, false);

        string query = string.IsNullOrWhiteSpace(returnUrl)
            ? string.Empty
            : QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, returnUrl).ToString();

        var redirectUrl = new UriBuilder(httpRequestService.GetBaseUrl())
        {
            Path = signInUrl.TrimStart('~'),
            Query = query
        };

        return Redirect(redirectUrl.ToString());
    }

    [HttpGet(ApplicationConstants.ACCESS_DENIED_ACTION_PATH)]
    [HttpGet($"{{{ApplicationConstants.LANGUAGE_KEY}}}{ApplicationConstants.ACCESS_DENIED_ACTION_PATH}")]
    public IActionResult AccessDenied([FromQuery(Name = ApplicationConstants.RETURN_URL_PARAMETER)] string returnUrl)
    {
        string language = ResolveLanguageFromReturnUrlOrPreferred(returnUrl);
        string requestLanguage = RouteData.Values[ApplicationConstants.LANGUAGE_KEY]?.ToString() ?? string.Empty;

        if (!string.Equals(requestLanguage, language, StringComparison.OrdinalIgnoreCase))
        {
            string localizedPath = $"/{language}{ApplicationConstants.ACCESS_DENIED_ACTION_PATH}";
            string queryString = QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, returnUrl).ToString();

            return Redirect($"{localizedPath}{queryString}");
        }

        return View("~/Features/Membership/ViewComponents/Authentication/AccessDenied.cshtml", GetAccessDeniedViewModel());
    }

    private ViewComponents.Authentication.AccessDeniedViewModel GetAccessDeniedViewModel() => new()
    {
        Title = stringLocalizer["Access Denied"],
        Heading = $"🔒 {stringLocalizer["Access Denied"]}",
        Message = stringLocalizer["You do not have permission to access this content. If you believe you should have access, please contact support."]
    };

    private string ResolveLanguageFromReturnUrlOrPreferred(string returnUrl)
    {
        if (string.IsNullOrWhiteSpace(returnUrl))
        {
            return preferredLanguageRetriever.Get();
        }

        // Cache this in real-world scenarios.
        // Resolve from returnUrl first to preserve the target page locale during sign-in/access-denied redirects;
        // otherwise fall back to preferred language from request/channel context.
        var languages = contentLanguageInfoProvider.Get()
            .Column(nameof(ContentLanguageInfo.ContentLanguageName))
            .GetListResult<string>();

        foreach (string language in languages)
        {
            if (returnUrl.StartsWith($"/{language}/", StringComparison.OrdinalIgnoreCase) || returnUrl.StartsWith($"~/{language}/", StringComparison.OrdinalIgnoreCase))
            {
                return language;
            }
        }
        // Since this controller action has no language in its path, this will return the channel default.
        return preferredLanguageRetriever.Get();
    }
}
```

Here is the referenced addition to the membership service:

> **Note:** The code samples in this example rely on a [decorated version](https://github.com/Kentico/xperience-by-kentico-training-guides/blob/main/src/TrainingGuides.Web/Features/Shared/Services/TrainingGuidesWebPageUrlRetriever.cs) of `IWebPageUrlRetriever` that includes exception handling.
>
> If you do not plan to use a similar customization, make sure to handle errors that the `Retrieve` method may throw.

```csharp title="IMembershipService.cs"
/// <summary>
/// Gets the URL of the expected sign in page in the provided language.
/// </summary>
/// <param name="language">The required language to retrieve.</param>
/// <param name="absoluteURL">Whether to return an absolute URL.</param>
/// <returns>The relative path of the sign in page.</returns>
Task<string> GetSignInUrl(string language, bool absoluteURL = false);
```

```csharp title="MembershipService.cs"
/// <inheritdoc />
public async Task<string> GetSignInUrl(string language, bool absoluteURL = false)
    => await GetPageUrl(ApplicationConstants.EXPECTED_SIGN_IN_PATH, language, absoluteURL);
...
private async Task<string> GetPageUrl(string expectedPagePath, string language, bool absoluteURL = false)
{
    var signInUrl = await webPageUrlRetriever.Retrieve(
        webPageTreePath: expectedPagePath,
        websiteChannelName: websiteChannelContext.WebsiteChannelName,
        languageName: language
    );

    return absoluteURL ?
        httpRequestService.GetAbsoluteUrlForPath(signInUrl.RelativePath.TrimStart('~'), false)
        : signInUrl.RelativePath;
}
...
```

> **Note:** **Referencing specific items in the database from code**
>
> This code depends on a sign-in page existing at a specific path in the content tree. (It doesn't need to be the only one, but it must exist at the specified tree path.)
>
> When you need to store a reference to some kind of identifier for data in the database, we recommend using something that editors have control over, such as a tree path or codename, rather than something like a numerical ID or GUID.
>
> That way, if an editor accidentally moves or deletes an object, it is possible for them to fix it with no code changes necessary.

## Check your progress

> **Note:** To test, make sure your project includes the _ArticleList_ widget files from the [finished branch](https://github.com/Kentico/xperience-by-kentico-training-guides/tree/finished/src/TrainingGuides.Web/Features/Articles/Widgets/ArticleList).
>
> The widget also exists in the [main branch](https://github.com/Kentico/xperience-by-kentico-training-guides) but does not include the full membership and member-role implementation used in this guide's results.
>
> Add the widget to a page and set the **Secured items display mode** property to **Prompt for login** – see the News page in the Training guides repo.

Now, if you set the _About conifers_ page to require authentication earlier, you can test out the functionality. Make sure you didn't change your member's default _Basic_ role.

🎬 [Video](https://docs.kentico.com/docsassets/modules/set-up-restricted-pages/RedirectToLoginNoReturn.mp4)

You'll notice that the secured page correctly redirects to the _Sign in_ page, but once the member authenticates, the form **DOES NOT** send them to the _return URL_.

We'll have to make some changes for that to work.
