OWASP Top 10 Security Priorities for Umbraco 13 Developers

By , Senior Full-Stack Engineer14 min read

Security in Umbraco 13 applications is paramount for protecting content, user data, and maintaining the integrity of your content management system. The OWASP Top 10 provides a comprehensive framework for understanding critical web application vulnerabilities.

This guide translates these security priorities into practical Umbraco 13 development practices, helping developers build secure CMS implementations from the ground up using modern .NET patterns and Umbraco's security features.

Security in web applications requires systematic attention to common vulnerabilities and secure coding practices. This guide demonstrates practical approaches to identifying and mitigating security risks, using the OWASP Top 10 as foundational guidelines for building applications that are secure by design.

1. Broken Access Control

Access control vulnerabilities in Umbraco 13 occur when users can access content or backend functionality beyond their permissions. This often manifests through inadequate member/user role configuration, missing authorization checks, or improper content restrictions.

Umbraco 13 Implementation Example

// Services/UmbracoSecurityService.cs
using Umbraco.Cms.Core.Security;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Web.Common.Security;

public class UmbracoSecurityService
{
    private readonly IMemberService _memberService;
    private readonly IMemberManager _memberManager;
    private readonly IContentService _contentService;

    public UmbracoSecurityService(
        IMemberService memberService,
        IMemberManager memberManager,
        IContentService contentService)
    {
        _memberService = memberService;
        _memberManager = memberManager;
        _contentService = contentService;
    }

    public async Task<bool> CanAccessContent(int contentId, string username)
    {
        var member = await _memberManager.FindByNameAsync(username);
        if (member == null) return false;

        var content = _contentService.GetById(contentId);
        if (content == null) return false;

        // Check member group permissions
        var memberGroups = await _memberManager.GetRolesAsync(member);
        var allowedGroups = content.GetValue<string>("allowedMemberGroups")
            ?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty<string>();

        return memberGroups.Any(g => allowedGroups.Contains(g));
    }
}
// Controllers/SecureContentController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Umbraco.Cms.Core.Web;
using Umbraco.Cms.Web.Common.Controllers;

[ApiController]
[Route("api/secure")]
public class SecureContentController : UmbracoApiController
{
    private readonly IUmbracoContextAccessor _umbracoContextAccessor;
    private readonly UmbracoSecurityService _securityService;

    [HttpGet("content/{id}")]
    [Authorize] // Requires authentication
    public async Task<IActionResult> GetSecureContent(int id)
    {
        if (!_umbracoContextAccessor.TryGetUmbracoContext(out var umbracoContext))
            return StatusCode(500);

        var username = User.Identity?.Name;
        if (string.IsNullOrEmpty(username))
            return Unauthorized();

        // Check access permissions
        if (!await _securityService.CanAccessContent(id, username))
            return Forbid();

        var content = umbracoContext.Content?.GetById(id);
        if (content == null)
            return NotFound();

        return Ok(new
        {
            Id = content.Id,
            Name = content.Name,
            Content = content.Properties
                .Where(p => !p.Alias.StartsWith("internal"))
                .ToDictionary(p => p.Alias, p => p.GetValue())
        });
    }
}

Security Best Practices

  • Always validate permissions on the server side - client-side checks are for UX only
  • Use Umbraco's built-in member groups and roles for access control
  • Implement custom authorization attributes for specific requirements
  • Secure both content access and API endpoints
  • Regular audit of user permissions and member group assignments

📚 Learn More: Umbraco Security Documentation | OWASP: Broken Access Control

2. Cryptographic Failures

Cryptographic failures in Umbraco 13 applications expose sensitive data through weak encryption, improper password handling, or insecure data transmission. This includes member passwords, API keys, and sensitive content data.

Secure Data Handling in Umbraco 13

OWASP A02 explicitly warns against rolling your own crypto. ASP.NET Core ships Data Protection for exactly this case — it's already wired into Umbraco, handles AES-GCM under the hood, rotates keys, and stores them in a configurable repository (file system, Azure Key Vault, Redis, etc.). Use it instead of touching Aes.Create() directly.

// Program.cs - configure Data Protection (Umbraco wires the rest in for you)
using Microsoft.AspNetCore.DataProtection;

builder.Services.AddDataProtection()
    .SetApplicationName("MyUmbracoSite")
    .PersistKeysToFileSystem(new DirectoryInfo("/var/keys"))
    // In production prefer a managed key store, e.g.:
    // .PersistKeysToAzureBlobStorage(...)
    // .ProtectKeysWithAzureKeyVault(...)
    .SetDefaultKeyLifetime(TimeSpan.FromDays(90));
// Services/SensitivePropertyProtector.cs
using Microsoft.AspNetCore.DataProtection;

public class SensitivePropertyProtector
{
    private readonly IDataProtector _protector;

    public SensitivePropertyProtector(IDataProtectionProvider provider)
    {
        // The purpose string scopes the key — different purposes get different
        // derived keys, so a leaked ciphertext from one context can't be
        // decrypted in another.
        _protector = provider.CreateProtector("MyUmbracoSite.SensitiveProperty.v1");
    }

    public string Protect(string plainText) => _protector.Protect(plainText);

    public string Unprotect(string cipherText) => _protector.Unprotect(cipherText);
}

If you genuinely need raw AES (interop with another system that already has a key), the contract is fragile — use AES-GCM and pass byte counts, not character counts:

// Services/AesGcmEncryptionService.cs - last-resort raw crypto
using System.Security.Cryptography;
using System.Text;

public class AesGcmEncryptionService
{
    private readonly byte[] _key; // 32 bytes for AES-256
    private const int NonceSize = AesGcm.NonceByteSizes.MaxSize; // 12
    private const int TagSize = AesGcm.TagByteSizes.MaxSize;     // 16

    public AesGcmEncryptionService(IConfiguration configuration)
    {
        var keyString = configuration["Encryption:Key"]
            ?? throw new InvalidOperationException("Encryption key not configured");
        _key = Convert.FromBase64String(keyString);
    }

    public string Encrypt(string plainText)
    {
        var plainBytes = Encoding.UTF8.GetBytes(plainText);
        var nonce = RandomNumberGenerator.GetBytes(NonceSize);
        var cipher = new byte[plainBytes.Length];
        var tag = new byte[TagSize];

        using var aes = new AesGcm(_key, TagSize);
        aes.Encrypt(nonce, plainBytes, cipher, tag);

        // [nonce || tag || ciphertext]
        var output = new byte[NonceSize + TagSize + cipher.Length];
        Buffer.BlockCopy(nonce, 0, output, 0, NonceSize);
        Buffer.BlockCopy(tag, 0, output, NonceSize, TagSize);
        Buffer.BlockCopy(cipher, 0, output, NonceSize + TagSize, cipher.Length);
        return Convert.ToBase64String(output);
    }

    public string Decrypt(string cipherText)
    {
        var input = Convert.FromBase64String(cipherText);
        var nonce = input.AsSpan(0, NonceSize);
        var tag = input.AsSpan(NonceSize, TagSize);
        var cipher = input.AsSpan(NonceSize + TagSize);
        var plain = new byte[cipher.Length];

        using var aes = new AesGcm(_key, TagSize);
        aes.Decrypt(nonce, cipher, tag, plain);

        return Encoding.UTF8.GetString(plain);
    }
}
// appsettings.json - Secure Configuration
{
  "Umbraco": {
    "CMS": {
      "Security": {
        "AuthCookieSecurePolicy": "Always",
        "KeepUserLoggedIn": false,
        "HideDisabledUsersInBackOffice": true,
        "UserPassword": {
          "RequiredLength": 10,
          "RequireNonLetterOrDigit": true,
          "RequireDigit": true,
          "RequireLowercase": true,
          "RequireUppercase": true
        }
      },
      "Global": {
        "UseHttps": true
      }
    }
  },
  // Store encryption keys in Azure Key Vault or similar
  "ConnectionStrings": {
    "umbracoDbDSN": "Data Source=server;Initial Catalog=db;Integrated Security=true;Encrypt=True;TrustServerCertificate=False"
  }
}

Security Best Practices

  • Always use HTTPS in production with proper SSL certificates
  • Store secrets in Azure Key Vault, AWS Secrets Manager, Doppler, or env vars — never in source
  • Use ASP.NET Core Data Protection (IDataProtectionProvider) for at-rest secrets in your app, not hand-rolled Aes.Create()
  • Use Umbraco's built-in password hashing for member accounts — don't reimplement it
  • Enable SQL Server connection encryption with Encrypt=True and TrustServerCertificate=False

📚 Learn More: ASP.NET Core Data Protection | OWASP: Cryptographic Failures

3. Injection Attacks

Injection flaws in Umbraco 13 occur when untrusted data is processed without proper validation. This includes SQL injection through custom queries, XSS through content rendering, and command injection through file operations.

SQL Injection Prevention

// Services/SecureDataService.cs
using NPoco;
using Umbraco.Cms.Infrastructure.Scoping;

public class SecureDataService
{
    private readonly IScopeProvider _scopeProvider;

    public SecureDataService(IScopeProvider scopeProvider)
    {
        _scopeProvider = scopeProvider;
    }

    // SECURE: Using parameterized queries
    public async Task<IEnumerable<CustomData>> GetDataSecurely(string searchTerm, int userId)
    {
        using var scope = _scopeProvider.CreateScope();

        // Never concatenate user input into SQL
        var sql = @"SELECT * FROM CustomTable
                    WHERE Name LIKE @0
                    AND UserId = @1
                    AND IsDeleted = 0";

        // NPoco automatically parameterizes queries
        var results = await scope.Database.FetchAsync<CustomData>(
            sql,
            $"%{searchTerm}%", // Safe parameterization
            userId
        );

        scope.Complete();
        return results;
    }

    // INSECURE: Example of what NOT to do
    // public async Task<IEnumerable<CustomData>> GetDataInsecurely(string searchTerm)
    // {
    //     // NEVER DO THIS - SQL Injection vulnerability
    //     var sql = $"SELECT * FROM CustomTable WHERE Name LIKE '%{searchTerm}%'";
    //     return await scope.Database.FetchAsync<CustomData>(sql);
    // }
}
// Views/SecureContentRendering.cshtml
@using Umbraco.Cms.Core.Models.PublishedContent
@using System.Web
@inherits Umbraco.Cms.Web.Common.Views.UmbracoViewPage

@{
    // Get user-generated content
    var userContent = Model.Value<string>("userGeneratedContent");
    var richTextContent = Model.Value<IHtmlEncodedString>("richTextEditor");
}

<!-- SECURE: Automatic HTML encoding -->
<div class="user-content">
    <p>@userContent</p>
</div>

<!-- SECURE: Rich text with built-in sanitization -->
<div class="rich-content">
    @richTextContent
</div>

<!-- SECURE: Manual encoding when needed -->
<div data-content="@Html.Encode(userContent)">
    <!-- Content safely encoded in attribute -->
</div>

@* INSECURE: Never use Html.Raw with user content
<div>
    @Html.Raw(userContent) <!-- XSS vulnerability! -->
</div>
*@

Security Best Practices

  • Always use parameterized queries with NPoco or Entity Framework
  • Never use Html.Raw() with user-generated content
  • Validate all input using data annotations and custom validators
  • Use Umbraco's built-in sanitization for rich text editors
  • Implement Content Security Policy (CSP) headers

📚 Learn More: OWASP: Injection

4. Insecure Design

Insecure design in Umbraco 13 represents missing or ineffective security controls in the CMS architecture. This includes inadequate authentication flows, missing rate limiting, and poor security boundaries between public and private content.

Secure Umbraco Architecture Patterns

A useful CSP rejects inline scripts. The path the standard recommends is a per-request nonce: middleware generates a fresh random value, stamps it into the CSP script-src, and your views echo the same nonce on every legitimate <script>. Anything else the browser refuses to execute.

// Middleware/SecurityHeadersMiddleware.cs
using System.Security.Cryptography;

public class SecurityHeadersMiddleware
{
    private readonly RequestDelegate _next;

    public SecurityHeadersMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context)
    {
        // Per-request nonce. Stash it on HttpContext.Items so views and tag
        // helpers can read it back when emitting <script> tags.
        var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(16));
        context.Items["csp-nonce"] = nonce;

        var headers = context.Response.Headers;
        headers.Append("X-Content-Type-Options", "nosniff");
        headers.Append("X-Frame-Options", "DENY");
        headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
        headers.Append("Permissions-Policy", "camera=(), microphone=(), geolocation=()");

        // CSP. `'strict-dynamic'` lets a nonced script load further scripts
        // without each one needing its own nonce — useful for bundlers.
        // The `'unsafe-inline'` keyword in script-src is ignored when a nonce
        // is present, but is kept as a fallback for browsers without nonce
        // support. Style-src still needs `'unsafe-inline'` for most apps;
        // remove it if you can audit every inline style.
        headers.Append("Content-Security-Policy",
            "default-src 'self'; " +
            $"script-src 'self' 'nonce-{nonce}' 'strict-dynamic'; " +
            "style-src 'self' 'unsafe-inline'; " +
            "img-src 'self' data: https:; " +
            "font-src 'self'; " +
            "connect-src 'self'; " +
            "frame-ancestors 'none'; " +
            "base-uri 'self'; " +
            "form-action 'self'; " +
            "object-src 'none'");

        await _next(context);
    }
}
// Helpers/CspNonceTagHelper.cs - emit nonce on every <script> automatically
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Razor.TagHelpers;

[HtmlTargetElement("script")]
public class CspNonceTagHelper : TagHelper
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CspNonceTagHelper(IHttpContextAccessor httpContextAccessor)
        => _httpContextAccessor = httpContextAccessor;

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        var ctx = _httpContextAccessor.HttpContext;
        if (ctx?.Items["csp-nonce"] is string nonce)
        {
            output.Attributes.SetAttribute("nonce", nonce);
        }
    }
}
// Program.cs - register the middleware and tag helper
builder.Services.AddHttpContextAccessor();

builder.Services.AddUmbraco(builder.Environment, builder.Configuration)
    .AddBackOffice()
    .AddWebsite()
    .AddComposers()
    .Build();

var app = builder.Build();

// Security headers BEFORE static files / Umbraco — so every response gets them
app.UseMiddleware<SecurityHeadersMiddleware>();
@* _ViewImports.cshtml *@
@addTagHelper *, YourAssemblyName

@* Any view *@
<script src="~/js/site.js"></script>
<script>
  // The tag helper attaches the nonce attribute automatically
  console.log('hello');
</script>

The Umbraco backoffice is a separate concern — it currently still requires some inline scripts in /umbraco/*, so consider scoping the strict CSP to your public-facing routes (e.g. via app.UseWhen(ctx => !ctx.Request.Path.StartsWithSegments("/umbraco"), ...)) rather than serving it everywhere on day one.

.NET 8 ships Microsoft.AspNetCore.RateLimiting, so the right answer is the built-in middleware. Skip the custom IMemoryCache wrapper — it doesn't handle multi-instance deployments and reinvents the partitioner, queue, and Retry-After header logic the framework already gets right.

// Program.cs - rate limiting policies
using System.Threading.RateLimiting;

builder.Services.AddRateLimiter(options =>
{
    // Global default: 100 requests / minute, partitioned by remote IP.
    // Behind a load balancer make sure ForwardedHeaders middleware runs first
    // so RemoteIpAddress reflects the client, not the proxy.
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 100,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0,
            }));

    // Stricter policy for auth endpoints
    options.AddPolicy("auth", ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 5,
                Window = TimeSpan.FromMinutes(15),
                QueueLimit = 0,
            }));

    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.OnRejected = async (ctx, token) =>
    {
        if (ctx.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter))
        {
            ctx.HttpContext.Response.Headers.RetryAfter =
                ((int)retryAfter.TotalSeconds).ToString();
        }
        await ctx.HttpContext.Response.WriteAsync("Too many requests", token);
    };
});

var app = builder.Build();
app.UseRateLimiter(); // place before endpoint routing
// Apply per-controller or per-action with the attribute
using Microsoft.AspNetCore.RateLimiting;

[ApiController]
[Route("api/auth")]
[EnableRateLimiting("auth")]
public class AuthController : UmbracoApiController
{
    // ...
}

For multi-instance deployments (Azure App Service slots, Kubernetes), back the limiter with a distributed store — the Microsoft.AspNetCore.RateLimiting.Redis community package or a sliding-window counter in Redis is the usual route. In-memory limiters are per-instance, which is fine for a single VM but trivially bypassed by load balancing across pods.

Security Best Practices

  • Implement defense in depth with multiple security layers
  • Use anti-forgery tokens for all state-changing operations
  • Apply rate limiting to prevent abuse of APIs and forms
  • Implement proper session timeout and management
  • Separate public and authenticated areas with clear boundaries

📚 Learn More: ASP.NET Core Security | OWASP: Insecure Design

5. Security Misconfiguration

Security misconfiguration in Umbraco 13 occurs through inadequate server configuration, exposed development features, default credentials, or missing security patches. Proper configuration is essential for a secure CMS deployment.

Production-Ready Umbraco Configuration

// appsettings.Production.json
{
  "Umbraco": {
    "CMS": {
      "Global": {
        "UseHttps": true,
        "ShowTopLevelNodeInPath": false,
        "DisableElectionForSingleServer": false
      },
      "Security": {
        "AllowPasswordReset": true,
        "AuthCookieSecurePolicy": "Always",
        "KeepUserLoggedIn": false,
        "UsernameIsEmail": true,
        "HideDisabledUsersInBackOffice": true,
        "UserPassword": {
          "RequiredLength": 12,
          "RequireNonLetterOrDigit": true,
          "RequireDigit": true,
          "RequireLowercase": true,
          "RequireUppercase": true,
          "MaxFailedAccessAttemptsBeforeLockout": 5
        }
      },
      "Content": {
        "Error404Collection": [
          {
            "Culture": "default",
            "ContentId": 1234
          }
        ]
      },
      "WebRouting": {
        "DisableRedirectUrlTracking": false,
        "DisableFindContentByIdPath": true
      },
      "Hosting": {
        "Debug": false
      },
      "ModelsBuilder": {
        "ModelsMode": "SourceCodeAuto",
        "AcceptUnsafeModelsDirectory": false
      }
    }
  },
  "Serilog": {
    "MinimumLevel": {
      "Default": "Warning",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System": "Warning"
      }
    }
  }
}
// Composers/SecurityComposer.cs
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;

public class SecurityComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        // Remove version header
        builder.Services.Configure<UmbracoRenderingDefaultsOptions>(options =>
        {
            options.DisableVersionHeader = true;
        });

        // Configure authentication
        builder.Services.ConfigureOptions<ConfigureUmbracoBackOfficeSecurityOptions>();

        // Add custom security services
        builder.Services.AddSingleton<IStartupFilter, SecurityStartupFilter>();
    }
}

public class SecurityStartupFilter : IStartupFilter
{
    public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next)
    {
        return app =>
        {
            // Remove server header
            app.Use(async (context, next) =>
            {
                context.Response.Headers.Remove("Server");
                context.Response.Headers.Remove("X-Powered-By");
                await next();
            });

            // Force HTTPS
            app.UseHttpsRedirection();
            app.UseHsts();

            next(app);
        };
    }
}

Security Best Practices

  • Disable debug mode and detailed errors in production
  • Remove or secure the Umbraco backoffice path (/umbraco)
  • Configure strong password policies for all users
  • Keep Umbraco and all packages updated to latest versions
  • Implement proper logging without exposing sensitive data
  • Use environment-specific configuration files

📚 Learn More: OWASP: Security Misconfiguration

6. Vulnerable and Outdated Components

Using components with known vulnerabilities puts your Umbraco 13 application at risk. This includes the Umbraco CMS itself, NuGet packages, client-side libraries, and server dependencies that require regular security updates.

Dependency Security Management

<!-- .csproj - Security-focused package management -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <AnalysisMode>AllEnabledByDefault</AnalysisMode>
  </PropertyGroup>

  <ItemGroup>
    <!-- Keep Umbraco on the latest 13.x patch (LTS) -->
    <PackageReference Include="Umbraco.Cms" Version="13.*" />

    <!-- Security analyzers. Microsoft's own NetAnalyzers ship with the SDK
         (CA2xxx rules) and cover most of what SecurityCodeScan.VS2019 did.
         The latter is no longer maintained at the same cadence. -->
    <PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0">
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>

    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.*" />
  </ItemGroup>

  <!-- Enable NuGet audit on build -->
  <PropertyGroup>
    <NuGetAudit>true</NuGetAudit>
    <NuGetAuditLevel>moderate</NuGetAuditLevel>
  </PropertyGroup>
</Project>
# PowerShell script for security auditing
# CheckDependencies.ps1

# Check for vulnerable packages
Write-Host "Checking for vulnerable packages..." -ForegroundColor Yellow
dotnet list package --vulnerable --include-transitive

# Check for outdated packages
Write-Host "`nChecking for outdated packages..." -ForegroundColor Yellow
dotnet list package --outdated

# Update packages with known vulnerabilities
Write-Host "`nUpdating vulnerable packages..." -ForegroundColor Green
dotnet add package Umbraco.Cms --version 13.0.0

# Run security code analysis
Write-Host "`nRunning security analysis..." -ForegroundColor Yellow
dotnet build /p:RunAnalyzers=true /p:TreatWarningsAsErrors=true
# .github/workflows/security-scan.yml
name: Security Scan
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 0 * * 0' # Weekly scan

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: 8.0.x

      - name: Restore dependencies
        run: dotnet restore

      - name: Check vulnerable packages
        run: dotnet list package --vulnerable --include-transitive

      - name: Run security code scan
        run: dotnet build /p:RunAnalyzers=true

      - name: Upload security results
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: security-results.sarif

Security Best Practices

  • Enable NuGet package vulnerability scanning in build pipeline
  • Keep Umbraco CMS updated to the latest patch version
  • Review and update all NuGet packages regularly
  • Use tools like OWASP Dependency Check or Snyk
  • Subscribe to Umbraco security bulletins and advisories
  • Implement automated dependency updates with security checks

📚 Learn More: OWASP: Vulnerable Components

7. Identification and Authentication Failures

Authentication failures in Umbraco 13 allow attackers to compromise user accounts, gain unauthorized backoffice access, or hijack member sessions. Robust authentication mechanisms are critical for both backoffice users and frontend members.

Secure Authentication Implementation

ASP.NET Core Identity (which Umbraco's IMemberManager and back-office IBackOfficeSignInManager are built on) already implements account lockout. The right move is to configure the framework, not write a parallel cache-based throttler.

// Program.cs - configure member lockout via Identity options
builder.Services.Configure<IdentityOptions>(options =>
{
    options.Lockout.AllowedForNewUsers = true;
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);

    options.Password.RequireDigit = true;
    options.Password.RequireLowercase = true;
    options.Password.RequireUppercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequiredLength = 12;
    options.Password.RequiredUniqueChars = 4;
});
// Controllers/SecureAuthController.cs
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Umbraco.Cms.Core.Security;

[ApiController]
[Route("api/auth")]
[EnableRateLimiting("auth")] // 5 attempts / 15 min / IP — see rate limiter section
public class SecureAuthController : UmbracoApiController
{
    private readonly IMemberSignInManager _signInManager;
    private readonly IMemberManager _memberManager;
    private readonly IWebHostEnvironment _env;
    private readonly ILogger<SecureAuthController> _logger;

    public SecureAuthController(
        IMemberSignInManager signInManager,
        IMemberManager memberManager,
        IWebHostEnvironment env,
        ILogger<SecureAuthController> logger)
    {
        _signInManager = signInManager;
        _memberManager = memberManager;
        _env = env;
        _logger = logger;
    }

    // For API controllers anti-forgery is NOT automatic — register
    // services.AddAntiforgery() in Program.cs and require an antiforgery
    // token via [AutoValidateAntiforgeryToken] on the controller, OR drop
    // CSRF tokens entirely and rely on a same-origin SameSite=Strict cookie
    // plus a custom request header check. Both are valid; pick one.
    [HttpPost("login")]
    [AutoValidateAntiforgeryToken]
    public async Task<IActionResult> Login([FromBody] LoginModel model)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        if (!await ValidateCaptcha(model.CaptchaToken))
            return BadRequest("Invalid CAPTCHA");

        // lockoutOnFailure: true means Identity handles the counter and the
        // 15-minute lockout itself, atomically against the user store.
        var result = await _signInManager.PasswordSignInAsync(
            model.Username, model.Password, model.RememberMe, lockoutOnFailure: true);

        if (result.Succeeded)
        {
            _logger.LogInformation("Login success for {Username}", model.Username);
            return Ok(new { success = true });
        }

        if (result.IsLockedOut)
        {
            _logger.LogWarning("Login attempt against locked account {Username}", model.Username);
            return StatusCode(StatusCodes.Status423Locked, "Account locked. Try again later.");
        }

        if (result.RequiresTwoFactor)
        {
            return Ok(new { requiresTwoFactor = true });
        }

        // Constant-ish response for invalid credentials — don't leak whether
        // the username exists.
        return Unauthorized("Invalid credentials");
    }

    [HttpPost("logout")]
    [Authorize]
    public async Task<IActionResult> Logout()
    {
        await _signInManager.SignOutAsync();
        return Ok();
    }
}

If you do issue your own session cookie (e.g. an API token alongside the Identity cookie), make Secure conditional so local HTTP development still works:

Response.Cookies.Append("AuthToken", token, new CookieOptions
{
    HttpOnly = true,
    Secure = Request.IsHttps, // unconditional `true` breaks http://localhost
    SameSite = SameSiteMode.Strict,
    Expires = model.RememberMe
        ? DateTimeOffset.UtcNow.AddDays(30)
        : DateTimeOffset.UtcNow.AddHours(2)
});

Security Best Practices

  • Configure Identity's built-in lockout (IdentityOptions.Lockout) instead of rolling your own
  • Use strong password policies; pair with a breached-password check (e.g. Pwned Passwords API)
  • Enable phishing-resistant MFA — passkeys (WebAuthn) where supported, TOTP as the fallback
  • For API controllers, wire AddAntiforgery() explicitly — it's automatic on MVC views, not on [ApiController]
  • Set cookies with HttpOnly, Secure = Request.IsHttps, and SameSite=Lax (or Strict for pure-server-rendered apps)
  • Log authentication events server-side; never log passwords or tokens

📚 Learn More: Umbraco External Login Providers | OWASP: Authentication Failures

8. Software and Data Integrity Failures

Integrity failures in Umbraco 13 occur when code and content updates don't protect against tampering. This includes insecure package updates, compromised deployments, and lack of content integrity verification.

Content and Code Integrity

// Services/ContentIntegrityService.cs
using System.Security.Cryptography;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Services;

public class ContentIntegrityService
{
    private readonly IContentService _contentService;
    private readonly ILogger<ContentIntegrityService> _logger;

    public string GenerateContentHash(IContent content)
    {
        using var sha256 = SHA256.Create();
        var contentData = JsonSerializer.Serialize(new
        {
            Id = content.Id,
            Name = content.Name,
            Properties = content.Properties.Select(p => new
            {
                Alias = p.Alias,
                Values = p.Values
            }),
            UpdateDate = content.UpdateDate
        });

        var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(contentData));
        return Convert.ToBase64String(hashBytes);
    }

    public bool VerifyContentIntegrity(IContent content, string expectedHash)
    {
        var actualHash = GenerateContentHash(content);
        var isValid = actualHash == expectedHash;

        if (!isValid)
        {
            _logger.LogWarning(
                "Content integrity check failed for {ContentId}: {ContentName}",
                content.Id, content.Name);
        }

        return isValid;
    }

    public void AuditContentChange(IContent content, string userId, string action)
    {
        var auditEntry = new
        {
            ContentId = content.Id,
            ContentName = content.Name,
            Action = action,
            UserId = userId,
            Timestamp = DateTime.UtcNow,
            ContentHash = GenerateContentHash(content),
            IpAddress = GetClientIpAddress()
        };

        // Store audit log
        _logger.LogInformation(
            "Content audit: {AuditEntry}",
            JsonSerializer.Serialize(auditEntry));
    }
}
# azure-pipelines.yml - Secure deployment pipeline
trigger:
  branches:
    include:
      - main
  paths:
    exclude:
      - README.md
      - docs/*

variables:
  buildConfiguration: 'Release'

stages:
  - stage: Build
    jobs:
      - job: BuildAndTest
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: UseDotNet@2
            inputs:
              version: '8.x'

          - task: DotNetCoreCLI@2
            displayName: 'Restore packages'
            inputs:
              command: 'restore'

          - task: DotNetCoreCLI@2
            displayName: 'Security scan'
            inputs:
              command: 'custom'
              custom: 'list'
              arguments: 'package --vulnerable --include-transitive'

          - task: DotNetCoreCLI@2
            displayName: 'Build'
            inputs:
              command: 'build'
              arguments: '--configuration $(buildConfiguration)'

          - task: DotNetCoreCLI@2
            displayName: 'Run tests'
            inputs:
              command: 'test'
              arguments: '--configuration $(buildConfiguration)'

          - task: DotNetCoreCLI@2
            displayName: 'Publish'
            inputs:
              command: 'publish'
              publishWebProjects: true
              arguments: '--configuration $(buildConfiguration) --output $(Build.ArtifactStagingDirectory)'

          # Generate build manifest
          - task: PowerShell@2
            displayName: 'Generate integrity manifest'
            inputs:
              targetType: 'inline'
              script: |
                $files = Get-ChildItem -Path $(Build.ArtifactStagingDirectory) -Recurse -File
                $manifest = @{}
                foreach ($file in $files) {
                  $hash = Get-FileHash -Path $file.FullName -Algorithm SHA256
                  $manifest[$file.Name] = $hash.Hash
                }
                $manifest | ConvertTo-Json | Out-File -FilePath $(Build.ArtifactStagingDirectory)/build-manifest.json

          - task: PublishBuildArtifacts@1
            inputs:
              PathtoPublish: '$(Build.ArtifactStagingDirectory)'
              ArtifactName: 'drop'

Security Best Practices

  • Implement content versioning with integrity checks
  • Use signed packages and verify signatures during deployment
  • Implement audit logging for all content changes
  • Use secure CI/CD pipelines with integrity verification
  • Enable Umbraco's built-in content versioning and rollback
  • Implement database backup and recovery procedures

📚 Learn More: Azure DevOps Security | OWASP: Integrity Failures

9. Security Logging and Monitoring Failures

Insufficient logging and monitoring in Umbraco 13 allows security breaches to go undetected. Comprehensive logging of authentication events, content changes, and system access is essential for security incident detection and response.

Comprehensive Security Logging

// Services/SecurityEventLogger.cs
using Serilog;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Notifications;

public class SecurityEventLogger :
    INotificationHandler<MemberSavedNotification>,
    INotificationHandler<UserLoginSuccessNotification>,
    INotificationHandler<UserLoginFailedNotification>
{
    private readonly ILogger _logger;

    public SecurityEventLogger(ILogger logger)
    {
        _logger = logger.ForContext<SecurityEventLogger>();
    }

    public void Handle(UserLoginSuccessNotification notification)
    {
        _logger.Information(
            "User login success: {Username} from IP {IpAddress} at {Timestamp}",
            notification.Username,
            GetClientIpAddress(),
            DateTime.UtcNow);
    }

    public void Handle(UserLoginFailedNotification notification)
    {
        _logger.Warning(
            "User login failed: {Username} from IP {IpAddress} at {Timestamp}",
            notification.Username,
            GetClientIpAddress(),
            DateTime.UtcNow);
    }

    public void Handle(MemberSavedNotification notification)
    {
        foreach (var member in notification.SavedEntities)
        {
            _logger.Information(
                "Member saved: {MemberId} {MemberEmail} by {CurrentUser} at {Timestamp}",
                member.Id,
                member.Email,
                GetCurrentUser(),
                DateTime.UtcNow);

            // Log sensitive changes
            if (member.IsPropertyDirty("Password"))
            {
                _logger.Warning(
                    "Member password changed: {MemberId} {MemberEmail}",
                    member.Id,
                    member.Email);
            }
        }
    }
}

// Composers/LoggingComposer.cs
public class LoggingComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        // Configure Serilog
        builder.Services.AddSingleton<ILoggerFactory>(services =>
        {
            var configuration = services.GetRequiredService<IConfiguration>();

            Log.Logger = new LoggerConfiguration()
                .ReadFrom.Configuration(configuration)
                .Enrich.WithProperty("Application", "Umbraco")
                .Enrich.WithMachineName()
                .Enrich.WithEnvironmentName()
                .WriteTo.File(
                    path: "logs/security-.txt",
                    rollingInterval: RollingInterval.Day,
                    restrictedToMinimumLevel: LogEventLevel.Information,
                    outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
                .WriteTo.Console()
                .CreateLogger();

            return new SerilogLoggerFactory();
        });

        // Register security event handlers
        builder.AddNotificationHandler<UserLoginSuccessNotification, SecurityEventLogger>();
        builder.AddNotificationHandler<UserLoginFailedNotification, SecurityEventLogger>();
        builder.AddNotificationHandler<MemberSavedNotification, SecurityEventLogger>();
    }
}
// appsettings.json - Logging configuration
{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System": "Warning",
        "Umbraco": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "File",
        "Args": {
          "path": "logs/umbraco-.txt",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 14,
          "fileSizeLimitBytes": 10485760,
          "rollOnFileSizeLimit": true
        }
      },
      {
        "Name": "File",
        "Args": {
          "path": "logs/security-.txt",
          "rollingInterval": "Day",
          "retainedFileCountLimit": 30,
          "restrictedToMinimumLevel": "Information",
          "filter": [
            {
              "Name": "ByIncludingOnly",
              "Args": {
                "expression": "SourceContext = 'SecurityEventLogger' or @l = 'Warning' or @l = 'Error'"
              }
            }
          ]
        }
      }
    ],
    "Filter": [
      {
        "Name": "ByExcluding",
        "Args": {
          "expression": "@mt = 'An unhandled exception has occurred while executing the request.' and @x is not null"
        }
      }
    ]
  }
}

Security Best Practices

  • Log all authentication attempts (success and failure)
  • Monitor and alert on suspicious activity patterns
  • Implement centralized logging with proper retention
  • Use structured logging for better analysis and alerting
  • Never log sensitive data like passwords or API keys
  • Set up real-time alerts for critical security events

📚 Learn More: OWASP: Logging Failures

10. Server-Side Request Forgery (SSRF)

SSRF vulnerabilities in Umbraco 13 occur when the application fetches remote resources without proper validation. This can happen through media imports, external content fetching, or custom integrations that process user-supplied URLs.

Safe URL Handling in Umbraco

A common SSRF gotcha is DNS rebinding: a "validate by hostname, then fetch by hostname" check resolves DNS twice — once for the validation lookup (returns a public IP) and once for the actual request (returns an internal IP, after the attacker's DNS server flips the answer). The fix is to resolve once and connect to the resolved IP, then pin the Host header.

// Services/SafeHttpClientService.cs
using System.Net;
using System.Net.Sockets;

public class SafeHttpClientService
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<SafeHttpClientService> _logger;
    private readonly List<string> _allowedDomains;

    public SafeHttpClientService(
        HttpClient httpClient,
        IConfiguration configuration,
        ILogger<SafeHttpClientService> logger)
    {
        _httpClient = httpClient;
        _logger = logger;
        _allowedDomains = configuration.GetSection("Security:AllowedDomains")
            .Get<List<string>>() ?? new List<string>();
    }

    public async Task<HttpResponseMessage> SafeFetchAsync(string url)
    {
        if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
            throw new ArgumentException("Invalid URL format");

        if (uri.Scheme != "https")
            throw new InvalidOperationException("Only HTTPS URLs are allowed");

        if (_allowedDomains.Any() && !_allowedDomains.Contains(uri.Host))
            throw new UnauthorizedAccessException("Domain not allowed");

        // Resolve DNS once. We will connect to one of these IPs explicitly,
        // so a second resolution by HttpClient cannot return a different
        // (internal) address — this defeats DNS rebinding.
        var addresses = await Dns.GetHostAddressesAsync(uri.Host);
        var safeAddress = addresses.FirstOrDefault(a => !IsPrivateOrReserved(a))
            ?? throw new UnauthorizedAccessException("All resolved addresses are internal");

        if (addresses.Any(IsPrivateOrReserved))
            throw new UnauthorizedAccessException("Hostname resolves to mixed public/internal addresses");

        // Build a request that connects to the resolved IP but presents the
        // original Host header so TLS SNI and HTTP virtual hosting work.
        var ipUri = new UriBuilder(uri) { Host = safeAddress.ToString() }.Uri;
        var request = new HttpRequestMessage(HttpMethod.Get, ipUri);
        request.Headers.Host = uri.Host;
        request.Headers.UserAgent.ParseAdd("Umbraco-CMS-SafeFetch/1.0");

        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        try
        {
            return await _httpClient.SendAsync(request, cts.Token);
        }
        catch (TaskCanceledException)
        {
            throw new TimeoutException("Request timed out");
        }
    }

    private static bool IsPrivateOrReserved(IPAddress address)
    {
        if (IPAddress.IsLoopback(address)) return true;

        if (address.AddressFamily == AddressFamily.InterNetwork)
        {
            var b = address.GetAddressBytes();
            // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16,
            // 100.64.0.0/10 (CGNAT), 0.0.0.0/8, 127.0.0.0/8 (loopback above)
            if (b[0] == 10) return true;
            if (b[0] == 172 && (b[1] & 0xF0) == 16) return true;
            if (b[0] == 192 && b[1] == 168) return true;
            if (b[0] == 169 && b[1] == 254) return true;
            if (b[0] == 100 && (b[1] & 0xC0) == 64) return true;
            if (b[0] == 0) return true;
            return false;
        }

        if (address.AddressFamily == AddressFamily.InterNetworkV6)
        {
            // ::1 caught by IsLoopback. Block link-local (fe80::/10),
            // unique-local (fc00::/7), IPv4-mapped, site-local (deprecated, fec0::/10).
            if (address.IsIPv6LinkLocal) return true;
            if (address.IsIPv6SiteLocal) return true;
            if (address.IsIPv4MappedToIPv6 && IsPrivateOrReserved(address.MapToIPv4())) return true;

            var b = address.GetAddressBytes();
            if ((b[0] & 0xFE) == 0xFC) return true; // fc00::/7 ULA
            return false;
        }

        return true; // unknown family — block by default
    }
}

HttpClient connecting to the IP directly with a spoofed Host header works for plain HTTP. For TLS, the server still sees SNI matching the original host, so the certificate validates correctly. If your runtime or load balancer rejects requests with mismatched SNI/Host, fall back to a SocketsHttpHandler.ConnectCallback that opens the socket to the pre-resolved IP while leaving the request URI untouched — same pinning, cleaner separation.

// Controllers/MediaImportController.cs
[Authorize(Policy = "BackOfficePolicy")]
public class MediaImportController : UmbracoAuthorizedApiController
{
    private readonly SafeHttpClientService _safeHttpClient;
    private readonly IMediaService _mediaService;

    [HttpPost("import-from-url")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> ImportMediaFromUrl([FromBody] MediaImportModel model)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        try
        {
            // Safely fetch the media
            var response = await _safeHttpClient.SafeFetchAsync(model.Url);

            // Validate content type
            var contentType = response.Content.Headers.ContentType?.MediaType;
            if (!IsAllowedContentType(contentType))
            {
                return BadRequest("Unsupported media type");
            }

            // Validate file size
            var contentLength = response.Content.Headers.ContentLength ?? 0;
            if (contentLength > 10 * 1024 * 1024) // 10MB limit
            {
                return BadRequest("File size exceeds limit");
            }

            // Create media item
            var mediaStream = await response.Content.ReadAsStreamAsync();
            var fileName = Path.GetFileName(new Uri(model.Url).LocalPath);

            var media = _mediaService.CreateMedia(
                fileName,
                model.ParentId ?? -1,
                "Image");

            media.SetValue("umbracoFile", fileName, mediaStream);
            _mediaService.Save(media);

            return Ok(new { mediaId = media.Id });
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to import media from URL: {Url}", model.Url);
            return BadRequest("Failed to import media");
        }
    }

    private bool IsAllowedContentType(string contentType)
    {
        var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
        return allowedTypes.Contains(contentType?.ToLower());
    }
}

Security Best Practices

  • Validate all URLs against an allowlist of expected domains
  • Resolve DNS once and connect to the resolved IP — defeats DNS rebinding
  • Block private and link-local ranges in both IPv4 and IPv6
  • Set short timeouts and disable redirects (or validate every redirect target)
  • HTTPS only; validate Content-Type and Content-Length before reading the body
  • Pair application checks with egress firewall rules — defense in depth

📚 Learn More: OWASP SSRF Prevention | OWASP: SSRF

Building Secure Umbraco 13 Applications

Implementing security in Umbraco 13 applications requires a comprehensive approach that addresses each of the OWASP Top 10 vulnerabilities. By following the patterns and practices outlined in this guide, you can build robust, secure CMS implementations that protect content, user data, and maintain system integrity.

Key security principles for Umbraco 13 developers:

  • Security is a continuous process requiring regular updates and monitoring
  • Always validate and sanitize user input on both client and server
  • Implement defense in depth with multiple security layers
  • Keep Umbraco CMS and all packages updated with security patches
  • Use HTTPS everywhere and implement proper authentication mechanisms
  • Log security events comprehensively and monitor for anomalies
  • Regular security testing and code reviews are essential
  • Follow Umbraco's security best practices and guidelines

Remember that CMS security extends beyond the application code to include server configuration, database security, and operational procedures. Always coordinate with your infrastructure and security teams to ensure comprehensive protection of your Umbraco applications.

Contact

Drop me a line. I read everything and reply within a day.

Required fields are marked “(required)”.