Mert Özen Further With Every Line
DevOps Cloud Fundamentals — #07

Cloud Storage: Containers, Access Tiers, SAS and Managed Identity in Blob Storage for Book Covers

Mert Özen Nov 15, 2026 19 min 10 views
Cloud Storage: Containers, Access Tiers, SAS and Managed Identity in Blob Storage for Book Covers

Book covers belong in a private Blob Storage container and should reach the browser through a short-lived SAS signed with a user delegation key obtained by a managed identity, not with the account key. We cover choosing redundancy, access tiers and lifecycle policies, a cover upload endpoint that checks magic bytes, real SAS tests against Azurite in OAuth mode, the Storage Blob Delegator role and the S3 equivalents.

Version note: The examples in this post were run with .NET 10 (SDK 10.0.404), Azure.Storage.Blobs 12.30.1, Azure.Identity 1.21.0, Entity Framework Core 10.0.15, xUnit v3 4.0.3, Azurite 3.37.0 and a SQL Server 2025 container. Blob operations ran locally against Azurite in OAuth mode; no storage account or role assignment was created on Azure. Azure-side behaviour comes from Microsoft Learn, and list prices from the Azure Retail Prices API (14 November 2026).

Guess First

In Bookstore, the online bookstore used as the shared example across this site's series, covers live in the covers container. In post 3 we gave Bookstore.Api's managed identity the "Storage Blob Data Contributor" role on that container only: it can read, write and delete. Now the API wants to produce a user delegation SAS so the browser can download a cover straight from storage. Is that role enough? Second question: a URL with a SAS ended up in a log by mistake and hasn't expired yet. How do you invalidate it? Both answers are in the post.

Storage Account Types and Redundancy

On Azure, blobs, file shares, queues and tables live under the same resource, the storage account. For a new application the choice is almost always a Standard general-purpose v2 account; a Premium block blob account runs on SSDs for many small, latency-sensitive operations and costs more per GB. Bookstore's account, stbookstoredevweu, was created in post 3 as Standard with the cheapest redundancy option.

Redundancy decides how many copies of the data exist and where. LRS, three copies in a single datacenter, is the cheapest; Learn gives it at least eleven nines of durability per year, but if the datacenter goes, the data becomes unreachable too. ZRS copies synchronously across at least three availability zones in the same region and survives losing a zone. GRS, LRS in the primary region plus an asynchronous copy in the paired region, and GZRS, ZRS in the primary plus the geo copy, protect against losing a region; you can't read the secondary copy without failing over. If you want to read it, you choose RA-GRS or RA-GZRS. The price of an asynchronous copy is the risk of data loss: Learn says data loss is expected in an unplanned failover and that the account becomes LRS in the new region.

For Bookstore the decision is short. Covers can be uploaded again, and losing them doesn't stop the business; LRS is enough for dev and staging. ZRS makes sense in production: the price difference is small (in West Europe, a list price of 0.0196 versus 0.024 dollars per GB per month in the hot tier), and covers survive a zone failure. We'll think about a geo copy when missing covers start to mean lost sales. One more constraint: the archive tier works only in LRS, GRS and RA-GRS accounts.

Object storage isn't the only kind. Blob Storage is object storage accessed over HTTP. Azure Files is a file share mounted over SMB or NFS; it helps when an old application writes files to disk and you can't change its code. A managed disk is a block device attached to a VM. The table below gives the AWS equivalents: S3, EFS and EBS.

AzureAWS equivalentNote
Blob Storage, containerS3, bucketObject storage, accessed over HTTP
Hot, cool, cold, archive tierS3 Standard, Standard-IA, Glacier Instant Retrieval, Glacier Deep ArchiveMinimum durations: Azure 30/90/180 days, AWS 30/90/180 days
Lifecycle management policyS3 Lifecycle configurationChange tier or delete by age or last access
User delegation SASPresigned URLBoth valid for at most 7 days
Azure FilesEFS, FSxFile share over the network
Managed diskEBSBlock disk attached to a VM

Containers, Public Access and Data Protection

A container is a flat namespace for blobs; prefixes such as exports/2026/11/ that look like folders are really part of the blob name. Access is set at the account and container level. According to Learn, anonymous access is off by default for accounts created through Resource Manager; the script in post 3 also stated it explicitly with --allow-blob-public-access false. Making a container public "so covers are open to everyone" also opens every file someone drops into it by mistake.

There are two settings for data protection. Soft delete keeps a deleted or overwritten blob recoverable for a period you choose (1 to 365 days); new accounts created in the portal get blob soft delete on with 7 days by default. Container soft delete is a separate setting: blob soft delete doesn't bring back a deleted container. Versioning keeps the previous version on every write. We don't turn versioning on in Bookstore, because covers are never overwritten: every upload creates a new blob with a random name. Deleting the old cover is enough; soft delete keeps it for 14 days. Soft-deleted data is billed like normal data.

Access Tiers and Lifecycle Policies

There are four tiers, depending on how often a blob is read. In the hot tier storage is expensive and reads are cheap. In cool, cold and archive storage gets cheaper while reads and early deletion get more expensive: if you don't keep a blob for at least 30 days in cool, 90 in cold or 180 in archive, you pay for the remaining days. West Europe LRS list prices (14 November 2026, per GB per month): hot 0.0196, cool 0.01, cold 0.0045, archive 0.0018 dollars. Reading from archive costs 0.024 dollars per GB, and 10,000 read operations cost 6 dollars. Prices change.

Archive is different from the others: the blob is offline. You have to rehydrate it first, that is, move it back to hot, cool or cold. According to Learn this can take up to 15 hours with standard priority, and with high priority under an hour for a blob smaller than 10 GB. We tried it on Azurite. Code that tries to read an archived blob:

await cover.SetAccessTierAsync(AccessTier.Archive);
try
{
    await cover.DownloadContentAsync();
}
catch (RequestFailedException ex)
{
    Console.WriteLine($"{ex.Status} {ex.ErrorCode}: {ex.Message.Split('\n')[0]}");
}
409 BlobArchived: This operation is not permitted on an archived blob.
tier=Archive archiveStatus=
tier=Hot archiveStatus=

The error is the same as on Azure. But look at the last line: Azurite moved the blob back to hot instantly. On Azure, after this step archiveStatus stays rehydrate-pending-to-hot for hours. The real duration of a flow doesn't show up in an emulator; design code that reads anything sent to archive so that it can tolerate waiting for hours.

Instead of changing tiers by hand, you write a lifecycle policy. The rules run on the platform side; according to Learn, a change can take up to 24 hours to reach its first run. Bookstore's policy has two rules:

{
  "rules": [
    {
      "name": "exports-cool-then-cold-then-delete",
      "enabled": true,
      "type": "Lifecycle",
      "definition": {
        "filters": {
          "blobTypes": ["blockBlob"],
          "prefixMatch": ["exports/"]
        },
        "actions": {
          "baseBlob": {
            "tierToCool": { "daysAfterModificationGreaterThan": 30 },
            "tierToCold": { "daysAfterModificationGreaterThan": 90 },
            "delete": { "daysAfterModificationGreaterThan": 1825 }
          }
        }
      }
    },
    {
      "name": "covers-unread-for-a-year-to-cool",
      "enabled": true,
      "type": "Lifecycle",
      "definition": {
        "filters": {
          "blobTypes": ["blockBlob"],
          "prefixMatch": ["covers/"]
        },
        "actions": {
          "baseBlob": {
            "tierToCool": { "daysAfterLastAccessTimeGreaterThan": 365 },
            "enableAutoTierToHotFromCool": true
          }
        }
      }
    }
  ]
}

The first rule moves the back office's daily sales exports to cool after 30 days and to cold after 90, and deletes them after five years. Five years is only an example; accounting decides the real period. The second rule looks at last access: a cover nobody has opened for a year moves to cool, and when someone opens it, enableAutoTierToHotFromCool brings it back to hot. This condition needs last access tracking turned on for the account. We don't send covers to archive: a customer opening a book in the catalog won't wait 15 hours. There's also a newer option, smart tier: according to Learn it moves objects between hot, cool and cold by itself based on access, charges no early deletion fee, but requires a ZRS or GZRS account.

The Cover Upload Endpoint

A store employee uploads the cover to Bookstore.Api, the API validates it and writes it as a blob. The first job is to find out whether the file really is an image. The file name and the Content-Type header come from the client and neither can be trusted; the only thing you can trust is the file's first bytes. JPEG, PNG and WebP are recognised by the signatures in their first bytes:

namespace Bookstore.Storage;

public sealed record CoverFormat(string ContentType, string Extension);

public static class CoverImage
{
    public const long MaxBytes = 2 * 1024 * 1024;

    private static readonly CoverFormat Jpeg = new("image/jpeg", ".jpg");
    private static readonly CoverFormat Png = new("image/png", ".png");
    private static readonly CoverFormat WebP = new("image/webp", ".webp");

    // The file name and the Content-Type header come from the client; only the first bytes are trusted
    public static CoverFormat? Detect(ReadOnlySpan<byte> header) => header switch
    {
        [0xFF, 0xD8, 0xFF, ..] => Jpeg,
        [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..] => Png,
        [0x52, 0x49, 0x46, 0x46, _, _, _, _, 0x57, 0x45, 0x42, 0x50, ..] => WebP,
        _ => null,
    };
}

The endpoint:

using System.Security.Cryptography;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Bookstore.Data;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Storage;

public sealed record CoverUploaded(string Isbn, string BlobName, string ContentType, long Size);

public static class CoverEndpoints
{
    public static IEndpointRouteBuilder MapCoverEndpoints(this IEndpointRouteBuilder app)
    {
        RouteGroupBuilder covers = app.MapGroup("/api/books/{isbn}/cover").WithTags("Covers");
        // Store staff only once authentication is added; the form is read by an API client, not a browser page
        covers.MapPut("/", UploadCover)
            .DisableAntiforgery()
            .WithFormOptions(multipartBodyLengthLimit: CoverImage.MaxBytes + 64 * 1024);
        covers.MapGet("/", RedirectToCover);
        return app;
    }

    private static async Task<Results<Created<CoverUploaded>, NotFound, ProblemHttpResult>> UploadCover(
        string isbn, IFormFile file, BookstoreContext db, BlobContainerClient covers,
        ILoggerFactory loggerFactory, CancellationToken ct)
    {
        var book = await db.Books.Where(b => b.Isbn == isbn).Select(b => new { b.Id, b.CoverBlobName }).FirstOrDefaultAsync(ct);
        if (book is null)
        {
            return TypedResults.NotFound();
        }
        if (file.Length is 0 or > CoverImage.MaxBytes)
        {
            return TypedResults.Problem($"The cover must be between 1 byte and {CoverImage.MaxBytes} bytes.", statusCode: 400);
        }

        await using Stream stream = file.OpenReadStream();
        byte[] header = new byte[12];
        int read = await stream.ReadAtLeastAsync(header, header.Length, throwOnEndOfStream: false, ct);
        if (CoverImage.Detect(header.AsSpan(0, read)) is not { } format)
        {
            return TypedResults.Problem("Only JPEG, PNG and WebP images are accepted.", statusCode: 415);
        }
        stream.Position = 0;

        // Never the client's file name: a random name can't be guessed, can't collide and needs no escaping
        string blobName = $"{isbn}-{RandomNumberGenerator.GetHexString(32, lowercase: true)}{format.Extension}";
        BlobClient blob = covers.GetBlobClient(blobName);
        await blob.UploadAsync(stream, new BlobUploadOptions
        {
            HttpHeaders = new BlobHttpHeaders { ContentType = format.ContentType, CacheControl = "public, max-age=31536000, immutable" },
            Metadata = new Dictionary<string, string> { ["isbn"] = isbn },
            Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All },
        }, ct);

        await db.Books.Where(b => b.Id == book.Id)
            .ExecuteUpdateAsync(s => s.SetProperty(b => b.CoverBlobName, blobName), ct);

        if (book.CoverBlobName is { } oldName)
        {
            // Soft delete keeps the old cover for the retention period
            await covers.DeleteBlobIfExistsAsync(oldName, cancellationToken: ct);
        }

        loggerFactory.CreateLogger(typeof(CoverEndpoints)).LogInformation(
            "Stored cover {BlobName} ({Size} bytes, {ContentType}) for {Isbn}", blobName, file.Length, format.ContentType, isbn);
        return TypedResults.Created($"/api/books/{isbn}/cover", new CoverUploaded(isbn, blobName, format.ContentType, file.Length));
    }

    private static async Task<Results<RedirectHttpResult, NotFound>> RedirectToCover(
        string isbn, BookstoreContext db, CoverUrlSigner signer, HttpContext http, TimeProvider clock, CancellationToken ct)
    {
        string? blobName = await db.Books.Where(b => b.Isbn == isbn).Select(b => b.CoverBlobName).FirstOrDefaultAsync(ct);
        if (blobName is null)
        {
            return TypedResults.NotFound();
        }

        SignedCoverUrl signed = await signer.SignAsync(blobName, ct);
        // Cache the redirect at most until the signed URL expires (minus a margin)
        int maxAge = Math.Max(0, (int)(signed.ExpiresOn - clock.GetUtcNow()).TotalSeconds - 300);
        http.Response.Headers.CacheControl = $"private, max-age={maxAge}";
        return TypedResults.Redirect(signed.Url.ToString());
    }
}

Let's go through the decisions. Size is limited in two places: WithFormOptions cuts the multipart body slightly above 2 MB, and the code checks the file itself. Without limits, ASP.NET Core's defaults apply: about 28.6 MB for a request body in Kestrel and 128 MB for a multipart body. The blob name isn't the client's file name; it's built from the ISBN and a random 32-character value: it can't be guessed, doesn't collide and needs no escaping. IfNoneMatch = ETag.All refuses the write if a blob with that name exists; with a random name this should never happen, but if it does we want an error rather than a silent overwrite. Cache-Control: immutable follows from the same design: since a blob's content never changes, browsers and CDNs can keep it for a year.

Writing the tests brought a surprise. Sending a 3 MB file returned 500 instead of 400:

fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
      An unhandled exception has occurred while executing the request.
      Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "IFormFile file" from the request body as form.
       ---> System.IO.InvalidDataException: Multipart body length limit 2162688 exceeded.

In the Development environment, minimal APIs throw an exception for a body they can't read, and the exception handler turns every exception it doesn't know into a 500. In Program.cs we used StatusCodeSelector so that BadHttpRequestException keeps its own status code:

app.UseExceptionHandler(new ExceptionHandlerOptions
{
    StatusCodeSelector = ex => ex is BadHttpRequestException bad ? bad.StatusCode : StatusCodes.Status500InternalServerError,
});

Book got a CoverBlobName column that holds the cover's blob name; the AddBookCover migration adds it as varchar(100).

A Read URL with a User Delegation SAS

Streaming the cover through the API spends the API's resources on every image request; sending the browser straight to the blob is better. Since the container is private, the browser needs a SAS URL. There are three kinds of SAS: account SAS and service SAS are signed with the account key, while a user delegation SAS is signed with a user delegation key obtained through Entra ID. Microsoft recommends the last one; on our account, where shared key was turned off in post 3, there is no other option anyway.

using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Azure.Storage.Sas;

namespace Bookstore.Storage;

// Signs short-lived read URLs with a user delegation key: no account key is involved anywhere.
public sealed class CoverUrlSigner(BlobServiceClient serviceClient, BlobContainerClient covers, TimeProvider clock)
{
    private static readonly TimeSpan KeyLifetime = TimeSpan.FromDays(1);
    private static readonly TimeSpan ClockSkew = TimeSpan.FromMinutes(15);
    private readonly SemaphoreSlim keyLock = new(1, 1);
    private UserDelegationKey? key;

    public async Task<SignedCoverUrl> SignAsync(string blobName, CancellationToken ct)
    {
        // Every request in the same clock hour gets the same URL (valid until the end of the next hour), so it can be cached
        DateTimeOffset now = clock.GetUtcNow();
        DateTimeOffset hourStart = new(now.Year, now.Month, now.Day, now.Hour, 0, 0, TimeSpan.Zero);
        DateTimeOffset expiresOn = hourStart.AddHours(2);

        UserDelegationKey delegationKey = await GetKeyAsync(now, expiresOn, ct);
        BlobSasBuilder sas = new(BlobSasPermissions.Read, expiresOn)
        {
            BlobContainerName = covers.Name,
            BlobName = blobName,
            Resource = "b",
            StartsOn = hourStart - ClockSkew,
            Protocol = SasProtocol.Https,
        };
        Uri url = covers.GetBlobClient(blobName).GenerateUserDelegationSasUri(sas, delegationKey);
        return new SignedCoverUrl(url, expiresOn);
    }

    private async Task<UserDelegationKey> GetKeyAsync(DateTimeOffset now, DateTimeOffset neededUntil, CancellationToken ct)
    {
        await keyLock.WaitAsync(ct);
        try
        {
            // A SAS stops working when its delegation key expires, so the key must outlive the SAS
            if (key is null || key.SignedExpiresOn < neededUntil)
            {
                BlobGetUserDelegationKeyOptions options = new(now + KeyLifetime) { StartsOn = now - ClockSkew };
                key = (await serviceClient.GetUserDelegationKeyAsync(options, ct)).Value;
            }
            return key;
        }
        finally
        {
            keyLock.Release();
        }
    }
}

public sealed record SignedCoverUrl(Uri Url, DateTimeOffset ExpiresOn);

A user delegation key can be valid for at most 7 days, and getting one is a network call. Instead of getting one per request, we take a key valid for a day and keep it in memory. The SAS lifetime is aligned to the hour: all requests within the same hour get the same URL, so browsers and CDNs don't download the image again. The start time is moved 15 minutes back because, according to Learn, clock differences between servers can reach 15 minutes in either direction. GET /api/books/{isbn}/cover redirects to this URL with a 302. The Location we got in the test:

Location: https://127.0.0.1:52710/devstoreaccount1/covers/9780441172719-0d9810feaa27d9defaf9acc68386b8f3.png?skoid=11111111-1111-1111-1111-111111111111&sktid=00000000-0000-0000-0000-000000000000&skt=2026-11-14T20%3A28%3A00Z&ske=2026-11-15T20%3A43%3A00Z&sks=b&skv=2026-06-06&sv=2026-10-06&spr=https&st=2026-11-14T19%3A45%3A00Z&se=2026-11-14T22%3A00%3A00Z&sr=b&sp=r&sig=<signature>

The parameters starting with sk belong to the key: skoid is the object ID of the identity that got the key, skt and ske its validity window. The rest are the SAS itself: sr=b a single blob, sp=r read only, spr=https HTTPS only, st and se the validity window. The signature covers all of them. We opened the URL with curl and then changed two things:

$ curl -s -i --cacert cert.pem "$SAS_URL"
HTTP/1.1 200 OK
Server: Azurite-Blob/3.37.0
x-ms-meta-isbn: 9780441172719
content-length: 70
content-type: image/png
cache-control: public, max-age=31536000, immutable
...
$ curl -s -i --cacert cert.pem "${SAS_URL/sp=r&/sp=rw&}" | head -1
HTTP/1.1 403 Server failed to authenticate the request. Make sure the value of the Authorization header is formed correctly including the signature.
$ curl -s --cacert cert.pem -X PUT -H "x-ms-blob-type: BlockBlob" --data-binary "x" "$SAS_URL" | grep -o "<Code>[^<]*"
<Code>AuthorizationPermissionMismatch

Widening the permissions breaks the signature and returns 403. Trying to write with a read SAS also gets 403, this time with a different code: the signature is valid but the permission isn't there. We hid the sig value here; a valid SAS URL behaves like a password until it expires.

Trying It Locally: Azurite and OAuth

When you use Azurite with a connection string as usual, you can't get a user delegation key. We saw that on the first try:

FAIL 403 AuthenticationFailed

This is consistent with Azure: a user delegation key can only be obtained with an Entra ID token. Azurite's --oauth basic mode helps here: it accepts bearer tokens and checks a token's issuer, audience and lifetime, but not its signature. Because this mode requires HTTPS, we generated a self-signed certificate and started Azurite with it; the commands are in the repo's README. The test project has a small credential that gives Azurite an unsigned token shaped like an Entra ID token:

using System.Text;
using System.Text.Json;
using Azure.Core;
using Azure.Core.Pipeline;
using Azure.Storage.Blobs;

namespace Bookstore.Api.Tests;

// Azurite started with "--oauth basic" and a self-signed certificate checks a bearer token's issuer, audience
// and lifetime, not its signature. That is enough to exercise the user delegation SAS path without Azure.
public static class AzuriteOAuth
{
    // e.g. https://127.0.0.1:10000/devstoreaccount1 (see README.md)
    public static Uri? ServiceUri { get; } =
        Environment.GetEnvironmentVariable("AZURITE_OAUTH_BLOB_URI") is { Length: > 0 } uri ? new Uri(uri) : null;

    public static BlobServiceClient CreateServiceClient() =>
        new(ServiceUri, new FakeEntraTokenCredential(), new BlobClientOptions { Transport = new HttpClientTransport(CreateHandler()) });

    // Test code only: Azurite's self-signed certificate is accepted for loopback addresses
    public static HttpClientHandler CreateHandler() => new()
    {
        ServerCertificateCustomValidationCallback = (request, _, _, errors) =>
            errors == System.Net.Security.SslPolicyErrors.None || request.RequestUri!.IsLoopback,
    };

    private sealed class FakeEntraTokenCredential : TokenCredential
    {
        private const string TenantId = "00000000-0000-0000-0000-000000000000";

        public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
        {
            DateTimeOffset now = DateTimeOffset.UtcNow;
            string header = Encode(new { alg = "RS256", typ = "JWT" });
            string payload = Encode(new
            {
                aud = "https://storage.azure.com",
                iss = $"https://sts.windows.net/{TenantId}/",
                tid = TenantId,
                oid = "11111111-1111-1111-1111-111111111111",
                iat = now.AddMinutes(-5).ToUnixTimeSeconds(),
                nbf = now.AddMinutes(-5).ToUnixTimeSeconds(),
                exp = now.AddHours(1).ToUnixTimeSeconds(),
            });
            return new AccessToken($"{header}.{payload}.unsigned", now.AddHours(1));
        }

        public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) =>
            ValueTask.FromResult(GetToken(requestContext, cancellationToken));

        private static string Encode(object value) =>
            Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(value)).TrimEnd('=').Replace('+', '-').Replace('/', '_');
    }
}

This way the tests walk through the same flow as a managed identity on Azure: token, user delegation key, SAS. The first test in the class checks the steps above in order; the second checks that a new cover deletes the old blob, and the others cover the 415, 400 and 404 cases. When AZURITE_OAUTH_BLOB_URI isn't set, the tests that need Azurite are skipped. On the whole solution:

$ dotnet test
Test run summary: Passed!
  total: 33
  failed: 0
  succeeded: 33

The Azure Side: Roles, Revocation and CDN

The script turns on data protection, limits SAS lifetimes, uploads the policy and grants the missing role:

#!/usr/bin/env bash
# Blob Storage for covers: data protection, lifecycle rules and the role that lets Bookstore.Api sign user delegation SAS URLs.
set -euo pipefail

: "${AZURE_SUBSCRIPTION_ID:?Set AZURE_SUBSCRIPTION_ID to the target subscription}"
ENVIRONMENT="${1:?Usage: 07-storage.sh <dev|staging|prod>}"
RG="rg-bookstore-${ENVIRONMENT}-weu"
IDENTITY="id-bookstore-${ENVIRONMENT}-weu"
STORAGE="stbookstore${ENVIRONMENT}weu"

az account set --subscription "$AZURE_SUBSCRIPTION_ID"
PRINCIPAL_ID=$(az identity show --name "$IDENTITY" --resource-group "$RG" --query principalId --output tsv)
STORAGE_ID=$(az storage account show --name "$STORAGE" --resource-group "$RG" --query id --output tsv)

# A deleted or overwritten blob stays recoverable for 14 days; so does a deleted container
az storage account blob-service-properties update --account-name "$STORAGE" --resource-group "$RG" \
  --enable-delete-retention true --delete-retention-days 14 \
  --enable-container-delete-retention true --container-delete-retention-days 14 \
  --enable-last-access-tracking true

# Back office exports; covers came with 03-identity.sh, covers-thumbnails with 06-functions.sh
az storage container-rm create --storage-account "$STORAGE" --name exports

# Requests with a SAS that is valid for more than 3 hours, or has no start time, are rejected
az storage account update --name "$STORAGE" --resource-group "$RG" \
  --sas-expiration-period 0.03:00:00 --sas-expiration-action Block

# Lifecycle rules run on the platform side; the first run can take up to 24 hours after a change
az storage account management-policy create --account-name "$STORAGE" --resource-group "$RG" \
  --policy @infra/cli/07-lifecycle-policy.json

# generateUserDelegationKey is an account-level action: a container-scoped data role can't grant it
az role assignment create \
  --assignee-object-id "$PRINCIPAL_ID" --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Delegator" --scope "$STORAGE_ID"

The first answer to the guess is in the last command. The generateUserDelegationKey operation needed to get a user delegation key is an account-level operation; according to Learn, the role granting it must be assigned at account, resource group or subscription scope. That's why "Storage Blob Data Contributor" at container scope isn't enough to get a key. At account scope we grant "Storage Blob Delegator", a role that contains only this operation; the data permissions stay at container scope. Learn states another important rule: the permission a user delegation SAS grants is the intersection of the permissions in the SAS and the RBAC permissions of the identity that got the key. Since the API only has rights on covers, a SAS it produces can't read a file in exports.

The second answer: you can't revoke a user delegation SAS on its own. The quickest way is the Revoke User Delegation Keys operation, which invalidates all user delegation keys on the account (az storage account revoke-delegation-keys); that also breaks every SAS produced with those keys. Because Azure caches keys and role assignments, revocation may not take effect immediately. Removing the identity's role also makes its SAS URLs useless, because one side of the permission intersection becomes empty. What really limits the damage of a leaked URL is a short lifetime: our SAS URLs are valid for at most two hours.

To serve covers close to customers, you put Azure Front Door in front of them. The Premium plan can connect to the origin over Private Link, so you can turn off the storage account's public access. Front Door's managed identity authentication to origins is still in preview in November 2026, and according to Learn it can't be combined with Private Link origins. The old Azure CDN Standard from Microsoft (classic) retires on 30 September 2027 and no longer accepts new profiles. The details are in the Domain, DNS ve CDN series. On the cost side, egress matters: the first 100 GB of traffic leaving Azure for the internet each month is free, after that the list price is 0.087 dollars per GB for Europe and North America. Every cover cached at the CDN reduces that egress.

In Production

Put a SAS expiration policy on the account. The script sets a three-hour limit: when a SAS with a longer lifetime or no start time (st) is used, the Log action writes it to the logs and Block rejects the request. The policy also applies to user delegation SAS; ours, with a start time and a window of two hours fifteen minutes, comply. SAS creation can't be audited on Azure, but its use shows up in storage logs.

Monitor the upload endpoint. The number and type of rejected files is the first signal that someone is trying to upload something other than images. If the function from post 6 still sees a non-image, it sends it to the poison queue; check that this queue stays empty.

Common Pitfalls

Putting the account key in an app setting. The key gives unlimited, non-expiring access to everything in the account and doesn't say who used it. Turning off shared key and working only with Entra ID removes that risk entirely.

Treating archive as a cheap backup. Storage is cheap, but reads take hours, and a blob deleted or moved before 180 days pays for the remaining period. For data read now and then, cold is the better choice.

Using the client's file name as the blob name. Names like ../../invoice.pdf, overwrites under the same name and guessable URLs all come from this.

Forgetting egress. Storage's per-GB price looks small; a popular cover downloaded millions of times straight from storage changes the bill. That's why a CDN in front with a long cache lifetime matters.

A Small Experiment

★☆☆ Add GIF support to CoverImage.Detect (the GIF87a and GIF89a signatures) and write its test. Then think about why accepting GIFs may not be a good idea: what does the function from post 6 do with such a file?

★★☆ Change CoverUrlSigner so that the SAS lifetime and the key lifetime come from configuration. What should happen if the SAS lifetime is longer than the key lifetime? Write a test and encode your decision.

★★★ When a new cover is uploaded, the thumbnail of the old cover produced in post 6 stays in covers-thumbnails. Compare two ways to clean it up: deleting it in the API or with a lifecycle policy. Write down which is right in which situation and implement one.

In the next post we move on to BookstoreDb: choosing between Azure SQL Database, Managed Instance, PostgreSQL Flexible Server and Cosmos DB, automated backups and point-in-time restore, moving with a bacpac, and connection resilience in EF Core.