Skip to content
Files SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Upload to Azure Blob Storage with managed identity and user delegation SAS

Let browsers upload to a private Azure Blob container with no account key anywhere, using DefaultAzureCredential on the server and user delegation SAS URLs, with the roles each step needs.

Give azure() a Microsoft Entra credential instead of an account key. With credential: new DefaultAzureCredential() and an account name, the adapter authenticates every server-side call with an Entra token. url() and signedUploadUrl() return user delegation SAS URLs, which are signed with a short-lived key that Azure issues to that identity. Your machine signs in with az login, production uses the app’s managed identity, and the same code runs in both with no secret to store or rotate.

What it takes is two role assignments. One lets the identity read and write blobs in the container. The other lets it request the user delegation key. That request is made against the storage account, so the second role has to be assigned at the account (or higher) even when the data role covers only one container. Most cases where server uploads work but signing fails come down to that second role.

Before you start

  • An Azure storage account (this guide calls it acmefiles) with a private container named uploads.
  • The Azure CLI, signed in as someone who can create role assignments (Microsoft.Authorization/roleAssignments/write, which Owner and User Access Administrator include).
  • A host that gives your app a managed identity: App Service, Azure Functions, Container Apps, a VM, or AKS with workload identity.
  • Written against files-sdk 3.0, @azure/storage-blob 12.34, @azure/identity 4.13, and Next.js 16.4. The routes use only Request and Response.
npm install files-sdk @azure/storage-blob @azure/identity
pnpm add files-sdk @azure/storage-blob @azure/identity
yarn add files-sdk @azure/storage-blob @azure/identity
bun add files-sdk @azure/storage-blob @azure/identity
nub add files-sdk @azure/storage-blob @azure/identity
aube add files-sdk @azure/storage-blob @azure/identity

What each call needs

Call How it’s authorized What the identity needs
Server-side upload, head, list, download, delete An Entra token for https://storage.azure.com/.default A data role on the container, such as Storage Blob Data Contributor
url() and signedUploadUrl() Signed locally with a user delegation key, fetched by one Get User Delegation Key request The generateUserDelegationKey action at account scope, such as Storage Blob Delegator
The browser’s PUT or GET with the SAS The SAS itself, checked against the signing identity’s roles Both: the SAS permission and a matching role

The last row is easy to miss. Microsoft’s user delegation SAS reference says the permissions a SAS holder gets “are the intersection of the permissions that were granted to the security principal that requested the user delegation key and the permissions that were granted to the resource on the SAS token.” A SAS can’t grant more than the identity that signed it has.

To see the requests behind the table, the adapter was run in Bun with a stand-in credential against a local HTTPS stub of the Blob service. Signing one download URL made the credential ask for a token with scope https://storage.azure.com/.default. Then the SDK sent POST /?restype=service&comp=userdelegationkey with that bearer token. Two more URLs signed right afterwards reused the same key, with no further request. Each SAS carried skoid and sktid: the object ID and tenant of the identity that requested the key. That’s how Azure ties the SAS back to the identity when it’s used.

Grant the roles

Assign the data role at the container and the delegator role at the account:

SUB=$(az account show --query id -o tsv)
RG=files-rg
ACCOUNT=acmefiles
ACCOUNT_SCOPE="/subscriptions/$SUB/resourceGroups/$RG/providers/Microsoft.Storage/storageAccounts/$ACCOUNT"
CONTAINER_SCOPE="$ACCOUNT_SCOPE/blobServices/default/containers/uploads"

# Read, write, list, and delete blobs in one container.
az role assignment create \
  --role "Storage Blob Data Contributor" \
  --assignee you@example.com \
  --scope "$CONTAINER_SCOPE"

# Request user delegation keys. Must be the account scope or higher.
az role assignment create \
  --role "Storage Blob Delegator" \
  --assignee you@example.com \
  --scope "$ACCOUNT_SCOPE"

This is for local development; you’ll repeat it for the managed identity in Deploy with a managed identity. Microsoft’s reference explains the split: “Because the Get User Delegation Key operation acts at the level of the storage account,” the action “must be scoped at the level of the storage account, the resource group, or the subscription.” For a data role scoped to a container, it suggests adding Storage Blob Delegator at the account. Storage Blob Data Contributor includes the delegation action too, but only where it’s assigned. Scoped to the container, as here, it doesn’t cover a request made against the account.

Two more things to know before testing:

  • Creating the account didn’t give you data access. Per Microsoft’s role assignment guide, “When you create an Azure Storage account, you aren’t automatically assigned permissions to access data via Microsoft Entra ID.” Owner and Contributor manage the account; they don’t read blobs.
  • Assignments take time to apply. The same guide says “it can take up to 10 minutes for changes to take effect.” A 403 in the first few minutes after an assignment doesn’t mean it’s wrong.

Configure the adapter

import { DefaultAzureCredential } from "@azure/identity";
import { createFiles } from "files-sdk";
import { azure } from "files-sdk/azure";

export const files = createFiles({
  adapter: azure({
    accountName: "acmefiles",
    container: "uploads",
    credential: new DefaultAzureCredential(),
  }),
});

accountName can come from AZURE_STORAGE_ACCOUNT_NAME instead. The adapter’s credential handling, from its source:

  • Passing credential turns off the key fallbacks. The adapter reads AZURE_STORAGE_CONNECTION_STRING, AZURE_STORAGE_ACCOUNT_KEY, and AZURE_STORAGE_SAS_TOKEN only when you pass none of connectionString, accountKey, credential, or sasToken. A connection string left in a .env file can’t take over.
  • Don’t pass a key alongside it. With both, the adapter picks connectionString first, then accountKey, then credential, and signs with the key.
  • User delegation SAS is on by default. useUserDelegationSas defaults to true with a credential. Set it to false and url() and signedUploadUrl() throw Unsupported.

DefaultAzureCredential tries a fixed list of sources and uses the first that returns a token. In @azure/identity 4.13 the order is: environment variables (a service principal secret or certificate), workload identity, managed identity, then developer tools: VS Code, the Azure CLI, Azure PowerShell, the Azure Developer CLI, and a broker. Two consequences:

  • An app setting can outrank the managed identity. If AZURE_CLIENT_SECRET, AZURE_TENANT_ID, and AZURE_CLIENT_ID are set on the host, the environment credential wins. Set AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential in production to use only the managed identity, or prod for the first three sources. Locally, dev limits it to the developer tools.
  • A user-assigned identity needs its client ID. Set AZURE_CLIENT_ID on the host, or pass new DefaultAzureCredential({ managedIdentityClientId }). A system-assigned identity needs neither.

Sign in and check both roles from a script:

import { files } from "../lib/files";

// Exercises the data role.
const { items } = await files.list({ limit: 5 });
console.log(
  "Listed:",
  items.map((item) => item.key)
);

// Exercises the delegator role. The blob doesn't need to exist.
console.log("Signed:", await files.url("check.txt", { expiresIn: 60 }));
az login
bun scripts/check-azure.ts

If the list succeeds and the signing fails, the delegator role is missing or still propagating. If both fail with the same 403, the data role is.

Allow the browser origin

The browser sends the file to acmefiles.blob.core.windows.net, so the Blob service needs a CORS rule. Azure sets CORS rules per service, not per container. In the portal, open the storage account, then Settings > Resource sharing (CORS) > Blob service, and add:

Field Value
Allowed origins http://localhost:3000,https://app.example.com
Allowed methods PUT
Allowed headers content-type,x-ms-blob-type
Exposed headers etag
Max age 3600

The upload sends x-ms-blob-type and a Content-Type, so the browser preflights it, and both headers must be allowed. If no rule matches, Azure answers the preflight with 403. Downloads are top-level navigations to the SAS URL, which need no CORS rule.

Sign the upload

Keep the upload rules in one module, because the server checks them twice:

export const MAX_BYTES = 25 * 1024 * 1024;
export const ALLOWED_TYPES = new Set([
  "application/pdf",
  "image/jpeg",
  "image/png",
]);
import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
import { ALLOWED_TYPES, MAX_BYTES } from "@/lib/upload-rules";

export async function POST(request: Request) {
  const session = await getSession(request.headers);
  if (!session) {
    return new Response("Sign in first", { status: 401 });
  }
  // What the browser says it will send. Azure won't enforce either value,
  // so the complete route checks the blob that actually lands.
  const { size, type } = (await request.json()) as {
    size: number;
    type: string;
  };
  if (!(size > 0 && size <= MAX_BYTES) || !ALLOWED_TYPES.has(type)) {
    return new Response("File type or size not allowed", { status: 422 });
  }
  const key = `users/${session.user.id}/${crypto.randomUUID()}`;
  const target = await files.signedUploadUrl(key, { expiresIn: 300 });
  return Response.json({ key, target });
}

getSession stands in for your auth library (Auth.js, Clerk, Better Auth, or your own cookie check). This guide assumes it takes request headers and returns { user: { id: string } } or null.

target is { method: "PUT", url, headers: { "x-ms-blob-type": "BlockBlob" } }. The URL is scoped to one blob (sr=b), grants create and write (sp=cw), and works only over HTTPS (spr=https). Its start time is set a minute in the past to absorb clock skew. Those were the parameters on the URL from the stubbed run.

The call passes only expiresIn, and that’s deliberate. A SAS can’t carry a size limit or a content type, so the adapter refuses maxSize, a positive minSize, and contentType instead of returning a URL that ignores them. In the run, signedUploadUrl(key, { expiresIn: 300, contentType: "application/pdf" }) threw Unsupported with azure: `contentType` is not supported for signed upload URLs, and maxSize threw the matching azure: `maxSize` is not supported error. files.capabilities.signedUpload reports both as false.

Upload from the browser

export async function uploadToAzure(file: File): Promise<string> {
  const signed = await fetch("/api/uploads", {
    body: JSON.stringify({ size: file.size, type: file.type }),
    headers: { "Content-Type": "application/json" },
    method: "POST",
  });
  if (!signed.ok) {
    throw new Error(await signed.text());
  }
  const { key, target } = await signed.json();

  // Straight to Azure. x-ms-blob-type comes from target.headers; the
  // Content-Type is stored as the blob's content type.
  const put = await fetch(target.url, {
    body: file,
    headers: { ...target.headers, "Content-Type": file.type },
    method: target.method,
  });
  if (!put.ok) {
    throw new Error(`Azure refused the upload (${put.status})`);
  }

  const done = await fetch("/api/uploads/complete", {
    body: JSON.stringify({ key }),
    headers: { "Content-Type": "application/json" },
    method: "POST",
  });
  if (!done.ok) {
    throw new Error(await done.text());
  }
  return key;
}

Send target.headers as given. Put Blob marks x-ms-blob-type as required. If a request sets Content-Type but not x-ms-blob-content-type, Azure stores the Content-Type value as the blob’s type. Without either, the type defaults to application/octet-stream.

One PUT writes the whole blob, up to 5,000 MiB on current service versions. fetch doesn’t report upload progress; use XMLHttpRequest with an upload.onprogress listener if you need a progress bar.

Check the blob after it lands

The SAS limited the key and the time window. It didn’t check what arrived. This route does:

import { FilesError } from "files-sdk";

import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";
import { ALLOWED_TYPES, MAX_BYTES } from "@/lib/upload-rules";

export async function POST(request: Request) {
  const session = await getSession(request.headers);
  if (!session) {
    return new Response("Sign in first", { status: 401 });
  }
  const { key } = (await request.json()) as { key: string };
  if (!key.startsWith(`users/${session.user.id}/`)) {
    return new Response("Not your upload", { status: 403 });
  }
  try {
    const info = await files.head(key);
    if (info.size > MAX_BYTES || !ALLOWED_TYPES.has(info.contentType)) {
      await files.delete(key);
      return new Response("File type or size not allowed", { status: 422 });
    }
    // Save info.key, info.size, and info.contentType to your database here.
    return Response.json({ key: info.key, size: info.size });
  } catch (error) {
    if (error instanceof FilesError && error.code === "NotFound") {
      return new Response("Upload not found", { status: 404 });
    }
    throw error;
  }
}

head() reads the stored size and type with the server’s Entra token, so it needs only the data role. The type is still what the browser declared, not what the bytes are. To check the bytes, see Enforce file-size and content-type limits on presigned uploads. Isolate each tenant’s files covers the prefix check in more depth.

Serve private downloads

import { getSession } from "@/lib/auth";
import { files } from "@/lib/files";

export async function GET(request: Request) {
  const session = await getSession(request.headers);
  if (!session) {
    return new Response("Sign in first", { status: 401 });
  }
  const key = new URL(request.url).searchParams.get("key") ?? "";
  if (!key.startsWith(`users/${session.user.id}/`)) {
    return new Response("Not found", { status: 404 });
  }
  const url = await files.url(key, {
    expiresIn: 300,
    responseContentDisposition: "attachment",
  });
  return Response.redirect(url, 302);
}

The read SAS is scoped the same way: sp=r, sr=b, spr=https, and the disposition is signed in as rscd. After the first call, signing is local. The adapter requests a key that outlives the SAS by an hour and five minutes, capped at Azure’s 7 days, and reuses it for every URL that fits inside that window. In the stubbed run, three signed URLs cost one key request.

Deploy with a managed identity

Turn on the app’s system-assigned identity and give it the same two roles. For App Service:

PRINCIPAL_ID=$(az webapp identity assign \
  --name files-app --resource-group $RG \
  --query principalId -o tsv)

az role assignment create \
  --role "Storage Blob Data Contributor" \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --scope "$CONTAINER_SCOPE"

az role assignment create \
  --role "Storage Blob Delegator" \
  --assignee-object-id "$PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --scope "$ACCOUNT_SCOPE"

Functions, Container Apps, and VMs each have their own switch for the identity, and the role assignments are identical. On AKS, workload identity gives the pod a federated token file, which DefaultAzureCredential picks up through its workload identity source.

Then set AZURE_TOKEN_CREDENTIALS=ManagedIdentityCredential as an app setting, and AZURE_CLIENT_ID if the identity is user-assigned. The code doesn’t change.

With nothing left that uses the account key, you can turn off key access on the account: az storage account update --name $ACCOUNT --resource-group $RG --allow-shared-key-access false. Per the Put Blob docs, “When Shared Key access is disallowed for the storage account, a service SAS token or an account SAS token will not be permitted on a request to Blob Storage.” User delegation SAS, which is all this setup uses, isn’t affected.

Use the gateway instead

If the size limit has to hold before bytes reach the container, upload through your server. With createFilesRouter({ files, maxUploadSize, authorize }) and useFiles, the gateway signs a direct upload only when the adapter can enforce everything the upload carries. On Azure, signedUpload.contentType and signedUpload.maxSize are false. A file with a type, or any upload when maxUploadSize is set, therefore gets the gateway’s proxy PUT instead, and the server streams it into the container with its Entra token while counting bytes. The browser client always sends a type, so in practice every gateway upload to Azure is proxied. Downloads still redirect to a user delegation SAS. See Gateway and the validation guide’s provider table.

Limits and tradeoffs

  • 7 days at most. A user delegation SAS can’t outlive the key that signs it, and Azure caps the key at seven days. url(key, { expiresIn: 8 * 86400 }) threw Invalid before any request. files.capabilities.signedUrl.maxExpiresIn and signedUpload.maxExpiresIn are both 604800. An account key signs longer-lived SAS, at the cost of holding the key.
  • The upload SAS can overwrite. sp=cw includes write, and Put Blob replaces an existing blob. Until it expires, the same URL can replace the blob, even after the complete route accepted it. Keep expiresIn short, and key each upload with a fresh UUID so a URL never points at someone else’s blob.
  • Uploads nobody completes stay. A browser can PUT and never call complete. Delete stale objects under users/ that have no database record, on a schedule.
  • Revoking a SAS isn’t instant. Microsoft lists two ways: revoke the account’s user delegation keys (az storage account revoke-delegation-keys --name $ACCOUNT --resource-group $RG), or remove the identity’s role. Both are cached by Azure Storage, “so there may be a delay.” The adapter also caches its key in memory, for up to an hour and five minutes past the SAS it was fetched for. A running process keeps signing with a revoked key, and the URLs it returns fail, until that key expires or the process restarts. Restart the app after revoking.
  • SAS tokens and anonymous access can’t sign. An adapter built from sasToken alone, or with no credential, reports signedUrl.supported: false, and url() throws Unsupported. Only an account key or an Entra credential can mint new SAS.
  • Credential failures surface as Provider. When DefaultAzureCredential finds no identity, the error has code Provider, not Unauthorized, so a retries setting retries it. A 403 from Azure, such as a missing role, maps to Unauthorized as expected.
  • copy() signs too. A server-side copy sends Azure a 5-minute read SAS for the source, signed with the same delegation key, so it needs the delegator role as well.

Troubleshooting

This request is not authorized to perform this operation using this permission. Azure answered 403 with a role-permission error code such as AuthorizationPermissionMismatch, and the FilesError code is Unauthorized. If list() works but url() fails, the delegator role is missing at account scope. If everything fails, the data role is missing. If you assigned either in the last 10 minutes, wait.

ChainedTokenCredential authentication failed. followed by CredentialUnavailableError lines. DefaultAzureCredential found no identity. The code is Provider, and the lines list each source it tried and why that source was unavailable. Locally, run az login. On Azure, check that the identity is enabled on the app and that AZURE_CLIENT_ID is set for a user-assigned identity.

azure: cannot sign URLs without a shared key or User Delegation SAS credential… The adapter has no signer: it was built with sasToken, with no credentials, or with useUserDelegationSas: false. The code is Unsupported.

azure: `expiresIn` of …s exceeds the 604800s (7-day) maximum for a User Delegation SAS… Lower expiresIn or defaultUrlExpiresIn. The code is Invalid, so retries skip it.

azure: `contentType` is not supported for signed upload URLs… or azure: `maxSize` is not supported… Don’t pass them to signedUploadUrl() on Azure. Check the blob after it lands, as the complete route does, or upload through the gateway.

A CORS error in the browser console, with the preflight answered 403. No CORS rule on the Blob service matches the page’s origin, the PUT method, and both request headers. Origins must match exactly, including scheme and port.

The upload succeeds, but the blob’s type is application/octet-stream. The PUT didn’t send a Content-Type. Send file.type as in the browser code above.

Last updated on

Was this page helpful?