Skip to main content

Command Palette

Search for a command to run...

Defending Against Confused Deputy Attacks in Azure

Updated
7 min read
Defending Against Confused Deputy Attacks in Azure

Most .NET developers working in Azure feel confident about identity. You use Entra ID, validate JWTs, and lean on Managed Identity instead of secrets. On paper, that is the right direction. Authentication is in place, access keys are gone, and the platform is doing the heavy lifting. Yet there is a class of vulnerability that slips through systems that look correct. It does not rely on token theft or broken cryptography. It exploits something more subtle - implicit trust between services. This is the confused deputy problem.

If your system has multiple Azure workloads calling each other with Managed Identity, Event Grid, Service Bus, or Durable Functions, you can introduce confused deputy behaviour without realising it. The system stays secure by conventional checks, while still letting the wrong caller trigger the wrong privileged action.

A confused deputy attack happens when a highly privileged service performs an action on behalf of a less privileged caller without properly validating whether the request should be honoured. The key detail is that the deputy is not compromised. It is authenticated, authorised, and behaving as designed. The failure is that it is acting with its own authority on a request it should not have accepted. Azure makes this easy to miss because Managed Identity answers only one question: who am I. It does not answer who asked me to do this, why they asked, whether they are the right workload for this operation, or whether the operation is legitimate within your business rules. If you treat “valid token for my API” as sufficient proof of legitimacy, you have created a deputy that will do anything it is capable of doing for anyone who can obtain the right kind of token.

Take a concrete internal API example. You have an Invoices API that can write invoices to Azure SQL. A Billing worker calls it during a nightly workflow. Both use Managed Identity. Everything feels internal, so you add an endpoint like POST /internal/invoices/generate and protect it with normal JWT validation. You have now created a target where any Azure workload that can obtain a token for that API can trigger invoice generation. The call is authenticated, but the intent is unauthorised. That is a confused deputy.

Here is the failure mode in one sequence. The attacker does not steal a token. They simply obtain one legitimately from Entra ID for the right audience, then use your privileged service as the execution engine.

Managed Identity represents authority, not permission. It says this workload can do powerful things. It does not mean the caller is allowed to ask for those powerful things to be done. If you treat Managed Identity as permission, you are implicitly saying that any caller who can reach your API is allowed to ask it to do anything the API can do. That assumption collapses the moment you have multiple services, multiple teams, multiple workflows, or any pathway where untrusted input can influence a privileged operation. Event-driven systems amplify the problem. Event Grid, Service Bus, and Durable Functions often run without user context and execute with full Managed Identity authority. If your handler processes messages simply because they arrived, it becomes a deputy by default. The attacker no longer needs to reach your HTTP boundary. They only need to get the right event into the right pipeline.

The core defence is intent binding. Every privileged operation needs to be bound to an explicit caller, an explicit target, and an explicit reason. In practical .NET terms, this means you cannot rely solely on ClaimsPrincipal and you cannot rely solely on Managed Identity. You must carry the context that explains why the operation is legitimate, and you must validate that context at the point where the privileged action occurs. One of the simplest and most effective hardening steps is to enforce strict audience validation and explicitly validate the calling workload identity. Many internal APIs accept any token with a valid issuer and broadly matching audience. That is not enough. You want exact audience matching and you want to reject calls from workloads that are not allowed to invoke this API, even if they can technically obtain a token.

Here is a practical example for ASP.NET using AddJwtBearer. This does not solve intent binding by itself, but it closes a major hole where any internal workload can call any internal API.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = $"https://login.microsoftonline.com/{tenantId}/v2.0";
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidAudience = "api://invoices",
            ValidateAudience = true,
            ValidateIssuer = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ClockSkew = TimeSpan.FromMinutes(1)
        };

        options.Events = new JwtBearerEvents
        {
            OnTokenValidated = ctx =>
            {
                var callerAppId = ctx.Principal?.FindFirst("azp")?.Value
                    ?? ctx.Principal?.FindFirst("appid")?.Value;

                if (callerAppId is null || !AllowedCallers.Contains(callerAppId))
                {
                    ctx.Fail("Caller application is not allowed.");
                }

                return Task.CompletedTask;
            }
        };
    });

static readonly HashSet<string> AllowedCallers = new(StringComparer.OrdinalIgnoreCase)
{
    "00000000-0000-0000-0000-000000000001"
};

Strict caller validation prevents random internal workloads from calling the API, but it still does not guarantee the call is legitimate in business terms. That is where intent binding comes in. The service must refuse to perform privileged work unless the request carries verifiable provenance and the operation is authorised against a rule you control.

A clean way to do this in ASP.NET is resource-based authorisation. Instead of authorising the principal in isolation, you authorise a principal acting on a specific resource context that includes caller intent.

using Microsoft.AspNetCore.Authorization;

app.MapPost("/internal/invoices/generate",
    [Authorize] async (
        string callerService,
        string correlationId,
        GenerateInvoiceRequest request,
        IAuthorizationService authorization,
        ClaimsPrincipal principal,
        CancellationToken stopToken) =>
    {
        var resource = new InvoiceGenerationResource(
            callerService,
            correlationId,
            request.AccountId);

        var result = await authorization.AuthorizeAsync(principal, resource, "InvoiceGeneration");
        if (!result.Succeeded)
            return Results.Forbid();

        await GenerateInvoicesAsync(request, stopToken);
        return Results.Accepted();
    });

At this point the deputy is no longer “just checking a token”. It is checking whether this caller is allowed to request this operation, with this declared purpose, against this target. That logic lives in an authorisation handler, where you can combine workload identity from the token with declared intent from the request.

using Microsoft.AspNetCore.Authorization;

public sealed class InvoiceGenerationRequirement : IAuthorizationRequirement;

public sealed record InvoiceGenerationResource(
    string CallerService,
    string CorrelationId,
    Guid AccountId);

public sealed class InvoiceGenerationHandler
    : AuthorizationHandler<InvoiceGenerationRequirement, InvoiceGenerationResource>
{
    protected override Task HandleRequirementAsync(
        AuthorizationHandlerContext context,
        InvoiceGenerationRequirement requirement,
        InvoiceGenerationResource resource)
    {
        var callerAppId = context.User.FindFirst("azp")?.Value
            ?? context.User.FindFirst("appid")?.Value;

        if (callerAppId != "00000000-0000-0000-0000-000000000001")
            return Task.CompletedTask;

        if (!string.Equals(resource.CallerService, "Billing.Worker", StringComparison.Ordinal))
            return Task.CompletedTask;

        if (resource.AccountId == Guid.Empty)
            return Task.CompletedTask;

        context.Succeed(requirement);
        return Task.CompletedTask;
    }
}

The same principle applies in event-driven paths, except the intent must be carried in the message itself. If you treat messages as implicitly trusted because they arrive on your subscription, you are delegating authority to any actor that can inject a message. A good handler validates provenance and schema before it performs privileged work, and it fails closed when the metadata is missing.

Here is an Azure Function example using Service Bus where intent is enforced before invoice generation is executed.

using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using System.Text.Json;

public sealed class InvoiceMessages
{
    [Function(nameof(HandleInvoiceRequested))]
    public async Task HandleInvoiceRequested(
        [ServiceBusTrigger("invoice-requests", Connection = "ServiceBusConnection")]
        ServiceBusReceivedMessage msg,
        CancellationToken stopToken)
    {
        if (!msg.ApplicationProperties.TryGetValue("producer", out var producer)
            || !Equals(producer, "Billing.Worker"))
            throw new UnauthorizedAccessException("Untrusted producer.");

        if (!msg.ApplicationProperties.TryGetValue("schema", out var schema)
            || !Equals(schema, "invoice-requested:v1"))
            throw new UnauthorizedAccessException("Unexpected schema.");

        var payload = JsonSerializer.Deserialize<InvoiceRequested>(msg.Body);
        if (payload is null || payload.AccountId == Guid.Empty)
            throw new InvalidOperationException("Invalid payload.");

        await GenerateInvoicesAsync(payload, stopToken);
    }
}

public sealed record InvoiceRequested(
    Guid AccountId,
    string CorrelationId,
    DateOnly PeriodStart,
    DateOnly PeriodEnd);

This is the heart of it. Confused deputy attacks do not happen because your tokens are invalid. They happen because your system accepts valid identity as sufficient proof of legitimate intent. The fix is not a new Azure feature. The fix is boundaries, explicit intent, and validation at the moment privileged work is executed.

Audit logs matter here as well, because they are how you catch deputies in the real world. When something goes wrong, you want logs that tell you who requested the action, which service executed it, and why it was allowed. If your logs only say “operation succeeded”, confused deputy behaviour disappears into the noise.

Before shipping a service, it is worth asking one uncomfortable question. If another internal workload can obtain a token for this API, can it trick this service into doing something dangerous. If the answer is not clearly “no” with a reason you can point at in code, you probably have a deputy waiting to be confused.

Confused deputy attacks already exist in many Azure systems, hidden behind valid tokens and successful authentication. The good news is that as a .NET developer you have the tools to shut them down. You control the authorisation pipeline, middleware, message handlers, and execution context. Use them deliberately, and your services stop being deputies that trust too much.