Require authentication for certain content

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.

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 and adapt it to your project.

Before you start

This guide requires the following:

Code samples

The code samples in this guide rely on code from previous guides in the Members series.

You can find a project with completed, working versions of code samples from this guide and others in the finished branch of the Training guides repository.

The main branch of the repository provides a starting point to code along with the guides.

The code samples in this guide are for .NET 8 only.

They come from a project that uses implicit using directives. You may need to add additional using directives to your code if your project does not use this feature.

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.

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, some articles might not work. Make sure you choose one that renders properly.

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.

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

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 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 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.

C#
~/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";
...
C#
~/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.

C#
~/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;
});
...

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.

C#
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:

The code samples in this example rely on a decorated version 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.

C#
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);
C#
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;
}
...

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

To test, make sure your project includes the ArticleList widget files from the finished branch.

The widget also exists in the main branch 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.

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.

Add redirect logic to the Sign-in widget

Let’s expand the Sign in widget view component to redirect a member back to the provided return URL after successful authentication.

It needs to find the returnUrl value in the query string of the request, so we should use the GetQueryStringValue method from our HttpRequestService.

C#
~/Features/Shared/Services/HttpRequestService.cs

/// <inheritdoc/>
public string GetQueryStringValue(string parameter) => httpContextAccessor.HttpContext?.Request.Query[parameter].ToString() ?? string.Empty;

Then, expand the BuildWidgetViewModel method of the sign-in widget view component to check the query string for a returnUrl value, falling back to the previous logic if it finds none.

C#
SignInWidgetViewComponent.cs

using TrainingGuides.Web.Features.Shared.Helpers;
...
public class SignInWidgetViewComponent : ViewComponent
{
...
    public async Task<SignInWidgetViewModel> BuildWidgetViewModel(SignInWidgetProperties properties) => new SignInWidgetViewModel
    {
        ActionUrl = GetActionUrl(),
        DefaultRedirectPageGuid = properties.DefaultRedirectPage.FirstOrDefault()?.Identifier ?? Guid.Empty,
        DisplayForm = !await membershipService.IsMemberAuthenticated(),
        FormTitle = properties.FormTitle,
        SubmitButtonText = properties.SubmitButtonText,
        UserNameOrEmailLabel = properties.UserNameLabel,
        PasswordLabel = properties.PasswordLabel,
        StaySignedInLabel = properties.StaySignedInLabel
    };

    private string GetActionUrl()
    {
        // New code: retrieve and use the return URL if it exists
        string? returnUrl = GetReturnUrlFromQueryString();
        QueryString? queryString = string.IsNullOrWhiteSpace(returnUrl) ? null : QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, returnUrl);

        return httpRequestService.GetAbsoluteUrlForPath(ApplicationConstants.AUTHENTICATE_ACTION_PATH, true, queryString);
    }

    // New code: use the new HttpRequestService method to retrieve the query string
    private string? GetReturnUrlFromQueryString()
    {
        string returnUrl = httpRequestService.GetQueryStringValue(ApplicationConstants.RETURN_URL_PARAMETER);

        // If there is no return URL or it is not a relative URL, return null
        if (string.IsNullOrWhiteSpace(returnUrl) || !returnUrl.StartsWith("/"))
        {
            return null;
        }

        return returnUrl;
    }
}

See this section of the language selector guide or the Training guides repository for details about how to implement the GetBaseUrlWithLanguage method.

If you ensure the return URL only works with relative paths, you can prevent attackers from exploiting the query string to redirect members to phishing sites after successful authentication on your site.

See the redirect in action

Now the sign in form should correctly redirect you to the return URL after you authenticate.

However, you may notice that even if you followed along earlier and set a reusable item to require authentication, it is still visible to signed out users on the page.

Screenshot of the ‘About frogs’ article displaying on the site even though the underlying reusable item is secured

This happens because the web page that references this reusable article is not secured. The content tree-based router only knows to check security of the pages it is retrieving, and not any reusable content they might reference.

Handle secured reusable content

Ideally, content editors should mark all pages that reference secured items to require authentication, but we shouldn’t assume that they will never make mistakes.

When pages display reusable content, you need one of the following approaches for security:

  1. Set the query that retrieves the content to filter out secured items.
  2. Programmatically check whether the current user can access each linked item, and react accordingly. For example, you can redirect or display an error message.

In this case, let’s use the latter approach. We’ll add a new method, CanCurrentUserAccessContentItem, to the membership service. It relies on the HasAccess extension method, which is available out of the box on each content item and returns true when the current user has access, including role-based checks.

We’ll then centralize access checks in the article page service and return 403 Forbidden when the current visitor cannot access the page or its referenced items.

Add the following method to the membership service so access checks are reusable wherever you need to evaluate secured content.

C#
IMembershipService.cs

/// <summary>
/// Checks whether the current user can access the provided content item.
/// </summary>
/// <param name="contentItem">The content item to evaluate.</param>
/// <returns>True when access is allowed for the current user; otherwise false.</returns>
bool CanCurrentUserAccessContentItem(IContentItemFieldsSource? contentItem);
C#
MembershipService.cs

/// <inheritdoc />
public bool CanCurrentUserAccessContentItem(IContentItemFieldsSource? contentItem) =>
    contentItem?.HasAccess(contextAccessor.HttpContext?.User) ?? false;

Go to the ~/Features/Articles/Services folder and add a new method to the article page service that checks whether the current user can access the article page and at least one referenced article item.

C#
IArticlePageService.cs

...
/// <summary>
/// Determines whether the current user can access the article page and its referenced article content.
/// </summary>
/// <param name="articlePage">The article page.</param>
/// <returns>True if the current user can access the page and at least one referenced article item.</returns>
bool CanCurrentUserAccessArticlePage(ArticlePage articlePage);
...
C#
ArticlePageService.cs

...
/// <inheritdoc/>
public bool CanCurrentUserAccessArticlePage(ArticlePage articlePage)
{
    bool pageAccessible = membershipService.CanCurrentUserAccessContentItem(articlePage);

    if (!pageAccessible)
        return false;

    bool oldArticleAccessible = articlePage.ArticlePageContent
        .Any(article => membershipService.CanCurrentUserAccessContentItem(article));

    bool newArticleAccessible = articlePage.ArticlePageArticleContent
        .OfType<IContentItemFieldsSource>()
        .Any(article => membershipService.CanCurrentUserAccessContentItem(article));

    bool articleAccessible = oldArticleAccessible || newArticleAccessible;

    return pageAccessible && articleAccessible;
}
...

Depending on whether you’ve followed along with the Advanced content series, your page may use legacy (ArticlePageContent) or newer (ArticlePageArticleContent) fields. The access check should account for both.

Then, use your new method in the article page controller, returning the 403 Forbidden status code when the current visitor lacks access.

C#
ArticlePageController.cs

using Kentico.Content.Web.Mvc.Routing;
using Kentico.PageBuilder.Web.Mvc.PageTemplates;
using Microsoft.AspNetCore.Mvc;
using TrainingGuides;
using TrainingGuides.Web.Features.Articles.Services;
using TrainingGuides.Web.Features.Shared.Services;

[assembly: RegisterWebPageRoute(
    contentTypeName: ArticlePage.CONTENT_TYPE_NAME,
    controllerType: typeof(TrainingGuides.Web.Features.Articles.ArticlePageController))]

namespace TrainingGuides.Web.Features.Articles;

public class ArticlePageController(
    IContentItemRetrieverService contentItemRetrieverService,
    IArticlePageService articlePageService) : Controller
{

    public async Task<IActionResult> Index()
    {
        var articlePage = await contentItemRetrieverService.RetrieveCurrentPage<ArticlePage>(2);

        if (articlePage is not null
            && !articlePageService.CanCurrentUserAccessArticlePage(articlePage))
        {
            return Forbid();
        }

        var model = articlePageService.GetArticlePageViewModel(articlePage);
        return new TemplateResult(model);
    }
}

Handle signed-in visitors without access in listing UI

With membership and roles in place, each item can end up in one of three states:

  1. Items the current visitor can access.
  2. Items that require sign-in.
  3. Items the visitor still cannot access even when signed in.

This is especially visible in listings, such as the ArticleList widget.

To account for all three states, we need to distinguish an unauthenticated visitor from an authenticated member who still lacks the required member role.

To handle these states consistently, we’ll extend our ArticlePageViewModel with two boolean properties that carry this state: Restricted (the item can’t be viewed in the current context) and RequiresSignIn (the current visitor is not authenticated).

Using these two fields, you can build the final per-item presentation in listings, for example by setting CTAText to either a default article CTA or a sign-in prompt and rendering matching locked-content messaging.

C#
ArticlePageViewModel.cs

using Microsoft.AspNetCore.Html;
using TrainingGuides.Web.Features.Shared.Models;

namespace TrainingGuides.Web.Features.Articles;

public class ArticlePageViewModel
{
    public string Title { get; set; } = string.Empty;
    public HtmlString SummaryHtml { get; set; } = HtmlString.Empty;
    public HtmlString TextHtml { get; set; } = HtmlString.Empty;
    public AssetViewModel? TeaserImage { get; set; } = null;
    public DateTime CreatedOn { get; set; }
    public List<ArticlePageViewModel> RelatedNews { get; set; } = [];
    public string Url { get; set; } = string.Empty;
    public bool Restricted { get; set; } = false;
    public bool RequiresSignIn { get; set; } = false;
    public string CTAText { get; set; } = string.Empty;
}

Then populate these fields in GetArticlePageViewModel. This method maps both reusable-schema and legacy article data, then sets security flags using CanCurrentUserAccessArticlePage.

C#
ArticlePageService.cs

...
public ArticlePageViewModel GetArticlePageViewModel(ArticlePage? articlePage)
{
    if (articlePage == null)
    {
        return new ArticlePageViewModel();
    }

    string articleUrl = GetArticlePageRelativeUrl(articlePage);
    var articleSchema = articlePage.ArticlePageArticleContent.FirstOrDefault();

    if (articleSchema != null)
    {
        var articleSchemaTeaserImage = articleSchema.ArticleSchemaTeaser.FirstOrDefault();

        return new ArticlePageViewModel
        {
            Title = articleSchema.ArticleSchemaTitle,
            SummaryHtml = new HtmlString(articleSchema?.ArticleSchemaSummary),
            TextHtml = new HtmlString(articleSchema?.ArticleSchemaText),
            CreatedOn = articlePage.ArticlePagePublishDate,
            TeaserImage = AssetViewModel.GetViewModel(articleSchemaTeaserImage),
            Url = articleUrl,
            Restricted = !CanCurrentUserAccessArticlePage(articlePage),
            RequiresSignIn = false
        };
    }

    var article = articlePage.ArticlePageContent.FirstOrDefault();
    var articleTeaserImage = article?.ArticleTeaser.FirstOrDefault();

    return new ArticlePageViewModel
    {
        Title = article?.ArticleTitle ?? string.Empty,
        SummaryHtml = new HtmlString(article?.ArticleSummary),
        TextHtml = new HtmlString(article?.ArticleText),
        CreatedOn = articlePage.ArticlePagePublishDate,
        TeaserImage = AssetViewModel.GetViewModel(articleTeaserImage),
        Url = articleUrl,
        Restricted = !CanCurrentUserAccessArticlePage(articlePage),
        RequiresSignIn = false
    };
}
...

In listing scenarios, such as the ArticleList widget, use GetArticlePageViewModelWithSecurity to produce the final per-item output for the current visitor. That method builds on GetArticlePageViewModel and adjusts item messaging and links based on authentication and access state.

C#
ArticlePageService.cs

...
public async Task<ArticlePageViewModel> GetArticlePageViewModelWithSecurity(ArticlePage? articlePage)
{
    var originalViewModel = GetArticlePageViewModel(articlePage);

    if (articlePage is null)
    {
        return originalViewModel;
    }

    bool userHasAccess = CanCurrentUserAccessArticlePage(articlePage);

    if (userHasAccess)
    {
        return originalViewModel;
    }

    bool isAuthenticated = await membershipService.IsMemberAuthenticated();

    // If not authenticated, show sign-in prompt
    if (!isAuthenticated)
    {
        string language = preferredLanguageRetriever.Get();
        string signInUrl = await membershipService.GetSignInUrl(language);
        string relativePath = originalViewModel.Url.TrimStart('~');

        string baseUrl = httpRequestService.GetBaseUrl();

        var signInUri = new UriBuilder(baseUrl)
        {
            Path = signInUrl.TrimStart('~'),
            Query = QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, relativePath).ToString()
        };

        string messageWithLinkString = $"<a href=\"{signInUri}\">{stringLocalizer["Sign in"]}</a> {stringLocalizer["to view this content."]}";

        var message = new HtmlString(stringLocalizer["Sign in to view this content."]);
        var messageWithLink = new HtmlString(messageWithLinkString);

        return new ArticlePageViewModel
        {
            Title = $"{stringLocalizer["(🔒 Locked)"]} {originalViewModel.Title}",
            SummaryHtml = message,
            TextHtml = messageWithLink,
            CreatedOn = articlePage.ArticlePagePublishDate,
            TeaserImage = originalViewModel.TeaserImage,
            Url = signInUri.ToString(),
            Restricted = true,
            RequiresSignIn = true
        };
    }
    // If authenticated but no access, show access denied message
    else
    {
        string deniedReturnPath = originalViewModel.Url.TrimStart('~');
        string accessDeniedUrl = $"{ApplicationConstants.ACCESS_DENIED_ACTION_PATH}{QueryString.Create(ApplicationConstants.RETURN_URL_PARAMETER, deniedReturnPath)}";

        var accessDeniedMessage = new HtmlString(stringLocalizer["You do not have permission to access this content. Upgrade to our higher tier."]);
        return new ArticlePageViewModel
        {
            Title = $"{stringLocalizer["(🔒 Locked)"]} {originalViewModel.Title}",
            SummaryHtml = accessDeniedMessage,
            TextHtml = accessDeniedMessage,
            CreatedOn = articlePage.ArticlePagePublishDate,
            TeaserImage = originalViewModel.TeaserImage,
            Url = accessDeniedUrl,
            Restricted = true,
            RequiresSignIn = false
        };
    }
}
...

See the results

Now, if you try to access a secured article, Identity should handle the 403 status through the same redirect handler it uses for secured page requests.

If you sign in as a user with a role different from Basic (for example, Enthusiast), you should still see access denied even after signing in.

Screenshot of a signed-in user seeing access denied when opening a secured article

As a reminder, this guide focuses on access-control principles. The ArticleList widget in the Training guides finished branch demonstrates the full UI behavior and the three secured-item display modes:

  • IncludeEverything (Include everything)
  • PromptForLogin (Prompt for login)
  • HideSecuredItems (Hide secured items)

Use that implementation as a reference when applying these concepts to your project.

What’s next?

The finished branch of the Training guides repository contains a demonstration of two approaches to handle secured content in listings:

  • Display a message explaining that the item is locked and linking to the sign-in page.
  • Hide secured items completely from the listing by filtering them out of the query.

If you’re looking to expand upon the lesson we’ve just covered, try to implement this functionality in your own project.

Specifically, check out:

The branch also contains other useful features for your reference, like password reset functionality and a dynamic widget that shows a sign-out button or a link depending on whether or not the current visitor is authenticated.