Gated Media Without Fragile Redirects in Optimizely CMS

Restricted media usually looks simple on the whiteboard. If the user can read the asset, serve it. If they cannot, redirect them to sign in.

In practice, document protection breaks in much more interesting ways than that. The rendered page may expose a direct asset URL before the request pipeline ever sees it. A redirect response may get cached by the browser or intermediary infrastructure. A sign-in flow may return the user to an unsafe or malformed location. And a CMS-generated media link may bypass the same access assumptions used elsewhere in the application.

The stronger pattern is to protect restricted media in two places: once when the URL is generated, and again when the request actually arrives. This implementation does exactly that in Optimizely CMS.

Layer 1: Rewrite Restricted Asset URLs at Generation Time

The first layer hooks into Optimizely URL generation. Whenever the CMS creates a media URL for contentassets, siteassets, or globalassets, the code checks whether an anonymous visitor has read access. If not, it swaps the media URL for a sign-in redirect instead of exposing the raw asset path.

private void OnGeneratedUrl(object sender, UrlGeneratorEventArgs e)
{
    if (e.Context.ContextMode != ContextMode.Default || e.Context.Url == null)
        return;

    var path = !string.IsNullOrEmpty(e.Context.Url.Path)
        ? e.Context.Url.Path.Remove(0, 1)
        : string.Empty;

    if (!RestrictedMediaHelper.IsMediaAssetPath(path))
        return;

    if (!_loader.TryGet(e.Context.ContentLink, out IContent content))
        return;

    var anonymousVisitor = new GenericPrincipal(
        new GenericIdentity(string.Empty),
        new[] { "Anonymous", "Everyone" });

    var level = _accessEvaluator.GetAccessLevel(content, anonymousVisitor);
    var aclAllowsRead = (level & AccessLevel.Read) == AccessLevel.Read;

    if (aclAllowsRead)
        return;

    var currentPathAndQuery = e.Context.Url.ToString();
    e.Context.Url = new UrlBuilder(
        RestrictedMediaHelper.BuildSignInRedirect(currentPathAndQuery));
    e.State = RoutingState.Done;
}

This solves a surprisingly important problem: anonymous users never see a “clean” document URL in the rendered markup if they are not supposed to access the file in the first place.

That matters because once a protected asset URL lands in the browser, many things can happen outside your intended flow: bookmarking, sharing, prefetching, download managers, or retries that skip the page where the original authorization logic lived.

Layer 2: Re-check Access on the Incoming Request

URL rewriting alone is not enough. Somebody can still request the asset directly, paste an old URL, or hit a link that was generated before access rules changed. That is why the second layer lives in middleware and inspects the actual incoming request.

public async Task InvokeAsync(HttpContext context)
{
    if (!HttpMethods.IsGet(context.Request.Method) &&
        !HttpMethods.IsHead(context.Request.Method))
    {
        await _next(context);
        return;
    }

    if (context.User?.Identity?.IsAuthenticated == true)
    {
        await _next(context);
        return;
    }

    var pathWithBase = (context.Request.PathBase + context.Request.Path).ToString();
    if (!RestrictedMediaHelper.IsMediaAssetPath(pathWithBase))
    {
        await _next(context);
        return;
    }

    var routeData = _urlResolver.Route(new UrlBuilder(pathWithBase));
    if (ContentReference.IsNullOrEmpty(routeData?.ContentLink))
    {
        await _next(context);
        return;
    }

    if (!_contentLoader.TryGet<IContent>(routeData.ContentLink, out var content))
    {
        await _next(context);
        return;
    }

    var level = _accessEvaluator.GetAccessLevel(content, AnonymousVisitor);
    var anonymousCanRead = (level & AccessLevel.Read) == AccessLevel.Read;

    if (anonymousCanRead)
    {
        await _next(context);
        return;
    }

    var currentPathAndQuery =
        (context.Request.PathBase + context.Request.Path + context.Request.QueryString).ToString();
    var redirect = RestrictedMediaHelper.BuildSignInRedirect(currentPathAndQuery);

    RestrictedMediaHelper.ApplyNoStoreHeaders(context.Response);
    context.Response.Redirect(redirect);
}

This middleware is doing exactly what good security middleware should do: narrow entry conditions aggressively, rehydrate the real content object, evaluate anonymous read access against the CMS ACL, and redirect only when the request is definitely for a protected asset.

The early returns are not just code style. They keep the middleware cheap for requests that are irrelevant: non-GET traffic, authenticated users, non-media paths, unresolved routes, and publicly readable assets all fall straight through.

The Redirect Helper Deserves More Credit Than It Gets

One of the cleanest parts of this pattern is that the sign-in redirect is built centrally:

public static string BuildSignInRedirect(string currentPathAndQuery)
{
    var callbackUrl = "/MyAccountApi/Callback?page=" +
                      HttpUtility.UrlEncode(currentPathAndQuery) +
                      "&id=" + HttpUtility.UrlEncode(currentPathAndQuery);

    return "/MyAccountApi/SignIn?hashedUrl=" +
           HttpUtility.UrlEncode(SecurityHelper.EncryptData(callbackUrl));
}

There are two good ideas here.

  • The target document path is preserved so the user can land back on the file they wanted.
  • The callback payload is encrypted before it is sent through the sign-in URL.

That second decision matters because redirect plumbing often becomes an accidental transport for data you did not intend to expose or trust directly.

Do Not Let Redirect Responses Get Cached

One of the quieter bugs in document gating is cache pollution. If an anonymous request for a protected asset returns a redirect and that redirect is cached too aggressively, the next request can inherit the wrong behavior. The helper addresses that too:

public static void ApplyNoStoreHeaders(HttpResponse response)
{
    response.Headers["Cache-Control"] =
        "private, no-store, no-cache, max-age=0, must-revalidate";
    response.Headers["Pragma"] = "no-cache";
    response.Headers["Expires"] = "0";

    const string varyByCookie = "Cookie";

    if (response.Headers.TryGetValue("Vary", out var varyValue) &&
        !string.IsNullOrWhiteSpace(varyValue.ToString()) &&
        !varyValue.ToString().Contains(varyByCookie, StringComparison.OrdinalIgnoreCase))
    {
        response.Headers["Vary"] = $"{varyValue}, {varyByCookie}";
        return;
    }

    response.Headers["Vary"] = varyByCookie;
}

This is exactly the kind of implementation detail that distinguishes “works in a demo” from “works in production.” Without these headers, a secure redirect can still create confusing and inconsistent behavior.

Finish the Flow by Sanitizing the Return Path

Safe gating also depends on what happens after authentication. In the sign-in controller, the return path is decrypted, callback values are parsed, and the final redirect is constrained to local URLs before execution.

That is an important counterweight to the convenience of redirect-based workflows. Every access flow that preserves user intent is also a potential place to reintroduce open redirects or malformed callback state if the final hop is not validated carefully.

In other words, secure media delivery is not one condition statement. It is a chain:

  • generate safe links
  • intercept direct requests
  • disable unsafe caching
  • sanitize the post-login destination

Why the Two-Layer Model Works

I like this design because it accepts that content delivery and access control rarely happen in one place. The CMS generates URLs. The HTTP pipeline serves requests. The authentication system handles sign-in. The redirect controller restores intent. If you only secure one of those seams, the others become escape hatches.

By placing one check in the URL-generation phase and another in the request phase, the application protects both the markup it emits and the raw asset paths it receives. That is the right mental model for restricted media in CMS platforms: protect what you publish, and protect what you receive.

That is how you build gated documents that feel seamless to legitimate users without becoming fragile under real-world traffic, caching, and link sharing.

 

Leave a comment