Uploading to Azure Blob with the Storage JS SDK

To upload from a browser to Azure Blob Storage, your backend mints a short-lived Shared Access Signature (SAS) that grants write-only access to one blob, and the client uploads with BlockBlobClient — staging blocks for large files and committing them as a list. The account key never leaves the server.

This guide is part of Direct-to-Cloud Upload Patterns under Backend Validation & Cloud Storage Architecture. For how Azure stacks up against the alternatives, see S3 vs GCS vs Azure Blob for media uploads.

When to use this approach

  • Your stack is on Azure and you want uploads to bypass your API server entirely.
  • You need parallel, out-of-order chunk uploads with client-chosen identifiers — unlike S3 multipart, Azure lets you name the parts, which makes resume trivial.
  • You want time-bound, blob-scoped write permission without sharing the account key.

Reach for a server proxy instead when every byte must pass through your own validation before it is durable, or when you need to run antivirus synchronously. Azure gives you no equivalent of an S3 POST policy’s content-length-range, so a SAS holder can write a blob of any size up to the service limit; if that matters, cap it with upload rate limiting on token issuance and a post-upload size check.

Prerequisites

  1. A storage account and a container set to Private (no anonymous access).
  2. npm i @azure/storage-blob@^12.26.0 on Node 20+ (the same package runs in the browser).
  3. The account name and key, or @azure/identity for a keyless user delegation SAS.
  4. Blob-service CORS allowing your frontend origin (covered below).

How a SAS is actually verified

A SAS is not an opaque token that Azure looks up. It is a set of plaintext query parameters plus sig, an HMAC-SHA256 of those parameters — canonicalised in a fixed field order — keyed by the account key or a user delegation key. On every request Azure re-derives the string-to-sign from the query string it received and recomputes the MAC. If a single character of se or sp differs, the recomputed signature does not match and the request is rejected. Nothing is stored server-side, which is why a SAS cannot be revoked individually unless you bind it to a stored access policy.

Anatomy of a blob SAS query string Each SAS query parameter listed with the constraint it encodes, ending with the sig field that is an HMAC over all the others. https://acct.blob.core.windows.net/media/uploads/u42/9f3c.mp4? sv=2025-01-05 Service version — pins the field order used to build the string-to-sign sr=b Resource scope: exactly one blob, not the whole container sp=cw Permissions: create and write only — no read, no delete, no list st + se Start and expiry in ISO-8601 UTC — a 10-minute window, backdated 60s spr=https Protocol filter — a plain HTTP request with this token is refused sig=base64 HMAC-SHA256 over every field above plus the canonical resource path Azure stores nothing: it rebuilds the string-to-sign per request and compares.
Every constraint travels in the clear; only sig makes them tamper-proof, so widening a permission means minting a new token.

That design has one practical consequence worth internalising: because verification is stateless, an issued SAS stays valid until se passes even if you delete the user’s account. Keep expiries in minutes, not hours. The same reasoning drives the short lifetimes in presigned URL generation on AWS.

Implementation: mint a write-only SAS

Scope the token to a single blob, write-only, with a short expiry. generateBlobSASQueryParameters produces it from the account credential.

import {
  StorageSharedKeyCredential,
  generateBlobSASQueryParameters,
  BlobSASPermissions,
  SASProtocol,
} from "@azure/storage-blob";

const account = process.env.AZURE_ACCOUNT_NAME!;
const accountKey = process.env.AZURE_ACCOUNT_KEY!;
const container = process.env.AZURE_CONTAINER!;
const credential = new StorageSharedKeyCredential(account, accountKey);

export interface SasTicket {
  uploadUrl: string;
  blobName: string;
  expiresOn: string;
}

export function mintUploadSas(userId: string): SasTicket {
  const blobName = `uploads/${userId}/${crypto.randomUUID()}`;
  const now = new Date();
  const expiresOn = new Date(now.getTime() + 10 * 60 * 1000); // 10 minutes

  const sas = generateBlobSASQueryParameters(
    {
      containerName: container,
      blobName,
      permissions: BlobSASPermissions.parse("cw"), // create + write only
      startsOn: new Date(now.getTime() - 60 * 1000), // tolerate clock skew
      expiresOn,
      protocol: SASProtocol.Https,
      contentType: "video/mp4", // pins the Content-Type the client may send
    },
    credential,
  ).toString();

  const uploadUrl =
    `https://${account}.blob.core.windows.net/${container}/${blobName}?${sas}`;
  return { uploadUrl, blobName, expiresOn: expiresOn.toISOString() };
}

Line-by-line on the critical parameters

  • BlobSASPermissions.parse("cw") grants only create and write. Never include read or delete on an upload SAS — least privilege limits the blast radius of a leaked token. Add "r" only if the client needs getBlockList to resume (see below), and understand that you have just made the token readable by anyone who intercepts it.
  • startsOn is set one minute in the past. Azure rejects a SAS whose start time is in the future relative to its own clock, so backdating absorbs minor skew between your server and the storage service.
  • expiresOn keeps the token usable for only ten minutes. That is the whole upload window for a small blob; for a multi-gigabyte file, mint the SAS, start immediately, and re-mint on AuthenticationFailed rather than issuing a two-hour token.
  • protocol: SASProtocol.Https emits spr=https and makes Azure refuse the token over plain HTTP.
  • contentType binds the response Content-Type into the signature. It is optional, but pinning it stops a client from storing an image/svg+xml payload under a .mp4 name — a cheap complement to real server-side file validation.
  • The returned uploadUrl is the full blob URL with the SAS query string. Return it to the browser as-is; see the re-encoding gotcha below for why you must not rebuild it.

Keyless: a user delegation SAS

In production, prefer a SAS signed with a user delegation key derived from an Entra ID credential. The account key then never exists in your app config at all, and revoking the managed identity’s role assignment invalidates every token it signed. The delegation key itself is valid for at most 7 days; cache it and refresh on a timer.

import {
  BlobServiceClient,
  generateBlobSASQueryParameters,
  BlobSASPermissions,
  SASProtocol,
} from "@azure/storage-blob";
import { DefaultAzureCredential } from "@azure/identity";

const accountName = process.env.AZURE_ACCOUNT_NAME!;
const containerName = process.env.AZURE_CONTAINER!;

const service = new BlobServiceClient(
  `https://${accountName}.blob.core.windows.net`,
  new DefaultAzureCredential(),
);

let cachedKey: Awaited<ReturnType<typeof service.getUserDelegationKey>> | null = null;
let cachedUntil = 0;

async function delegationKey() {
  if (cachedKey && Date.now() < cachedUntil) return cachedKey;
  const start = new Date(Date.now() - 60 * 1000);
  const end = new Date(Date.now() + 6 * 60 * 60 * 1000); // 6 hours
  cachedKey = await service.getUserDelegationKey(start, end);
  cachedUntil = Date.now() + 5 * 60 * 60 * 1000; // refresh an hour early
  return cachedKey;
}

export async function mintDelegatedSas(userId: string): Promise<string> {
  const key = await delegationKey();
  const blobName = `uploads/${userId}/${crypto.randomUUID()}`;
  const sas = generateBlobSASQueryParameters(
    {
      containerName,
      blobName,
      permissions: BlobSASPermissions.parse("cw"),
      startsOn: new Date(Date.now() - 60 * 1000),
      expiresOn: new Date(Date.now() + 10 * 60 * 1000),
      protocol: SASProtocol.Https,
    },
    key,
    accountName,
  ).toString();
  return `https://${accountName}.blob.core.windows.net/${containerName}/${blobName}?${sas}`;
}

The identity needs both Storage Blob Data Contributor on the container and the Microsoft.Storage/storageAccounts/blobServices/generateUserDelegationKey action. Missing the second one fails with AuthorizationPermissionMismatch at getUserDelegationKey, not at upload time — which is a much easier bug to find.

Uploading a small blob from the browser

For anything under a few hundred megabytes, construct a BlockBlobClient from the SAS URL and call uploadData. It transparently switches between a single Put Blob request and the block path at maxSingleShotSize (256 MiB by default).

import { BlockBlobClient } from "@azure/storage-blob";

export async function uploadSmall(uploadUrl: string, file: File): Promise<string> {
  const client = new BlockBlobClient(uploadUrl);
  const response = await client.uploadData(file, {
    blobHTTPHeaders: { blobContentType: file.type || "application/octet-stream" },
    metadata: { originalname: encodeURIComponent(file.name) },
    concurrency: 4,
    onProgress: (ev) => console.log(`${ev.loadedBytes} / ${file.size}`),
  });
  if (!response.etag) throw new Error("upload completed without an ETag");
  return response.etag;
}

Metadata values must be ASCII, so percent-encode any filename that might carry accents; the whole x-ms-meta-* set is capped at 8 KiB. If you also want dimensions or duration alongside the blob, record them in your own database instead — see storing image dimensions and duration metadata.

Large files: stage blocks then commit

For large media, split the file into blocks with Blob.slice, stageBlock each under a base64 block ID you choose, then commitBlockList to assemble them in order. Staged-but-uncommitted blocks live in a per-blob holding area for 7 days and are invisible to readers — the blob does not exist until the commit. That maps directly onto a frontend resumable upload state machine.

Block staging and commit on Azure Blob The browser stages four blocks by base64 ID into an uncommitted list, then a single commit call assembles them into one committed blob. Browser file.slice() Uncommitted block list discarded after 7 days MDAwMDAw · 8 MiB MDAwMDAx · 8 MiB MDAwMDAy · 8 MiB MDAwMDAz · 3 MiB staged in parallel, any order PUT commitBlockList [0, 1, 2, 3] committed blob one ETag, readable
Nothing is readable until the commit, so a half-finished upload never exposes a truncated file.

The following worker-pool version stages four blocks at a time, reports byte-accurate progress, and honours an AbortSignal — the same primitive used for aborting uploads with AbortController.

import {
  BlockBlobClient,
  AnonymousCredential,
  newPipeline,
} from "@azure/storage-blob";

export interface UploadResult {
  blockIds: string[];
  bytes: number;
}

export async function uploadLarge(
  uploadUrl: string,
  file: File,
  onProgress: (loaded: number) => void,
  signal: AbortSignal,
): Promise<UploadResult> {
  const pipeline = newPipeline(new AnonymousCredential(), {
    retryOptions: { maxTries: 4, retryDelayInMs: 500, maxRetryDelayInMs: 8000 },
  });
  const client = new BlockBlobClient(uploadUrl, pipeline);

  const blockSize = 8 * 1024 * 1024; // 8 MiB
  const total = Math.ceil(file.size / blockSize);
  if (total > 50_000) throw new Error(`${total} blocks exceeds the 50,000 limit`);

  // Block IDs must be equal-length base64 strings, unique within this blob.
  const blockIds = Array.from({ length: total }, (_, i) =>
    btoa(String(i).padStart(6, "0")),
  );

  let loaded = 0;
  let next = 0;
  const worker = async (): Promise<void> => {
    while (next < total) {
      const i = next++;
      const chunk = file.slice(i * blockSize, (i + 1) * blockSize);
      await client.stageBlock(blockIds[i], chunk, chunk.size, {
        abortSignal: signal,
      });
      loaded += chunk.size;
      onProgress(loaded);
    }
  };
  await Promise.all(Array.from({ length: 4 }, worker));

  await client.commitBlockList(blockIds, {
    blobHTTPHeaders: { blobContentType: file.type || "application/octet-stream" },
    conditions: { ifNoneMatch: "*" }, // fail rather than overwrite
    abortSignal: signal,
  });
  return { blockIds, bytes: loaded };
}

onProgress here fires per completed block, so with 8 MiB blocks the bar advances in visible steps. If you need a smooth percentage or an ETA, feed the loaded-byte deltas into the smoothing described in showing accurate time-remaining estimates.

Choosing a block size

Azure’s limits define the trade-off precisely. With service version 2019-12-12 and later a single block may be up to 4000 MiB, a blob may hold 50,000 blocks, and a block blob therefore tops out at 190.7 TiB. Small blocks mean more HTTP round trips; large blocks mean a failed block costs more to re-send.

Limit Value Notes
Max block size 4000 MiB Service version 2019-12-12+; 100 MiB on 2016-05-31 → 2019-07-07
Max blocks per blob 50,000 Committed and uncommitted count together
Max block blob size 190.7 TiB 50,000 × 4000 MiB
Max single Put Blob 5000 MiB Above this you must stage blocks
Uncommitted block lifetime 7 days Garbage-collected if never committed
Block count against block size for a 12 GiB upload Horizontal bars showing that a 12 GiB file needs 3072 blocks at 4 MiB, 1536 at 8 MiB, 768 at 16 MiB and 192 at 64 MiB. Blocks (= HTTP PUTs) to upload a 12 GiB master 4 MiB blocks 3,072 8 MiB blocks 1,536 — good default 16 MiB blocks 768 64 MiB blocks 192 50,000-block ceiling: 4 MiB caps a blob at 195 GiB, 100 MiB caps it at 4.8 TiB. A retry re-sends one whole block — small blocks cost requests, big blocks cost bytes.
8 MiB is the usual sweet spot on consumer uplinks: few enough requests to keep overhead low, small enough that a retry wastes under ten seconds.

On a mobile connection drop the retry cost dominates, so bias smaller; on a datacentre-to-datacentre transfer the per-request overhead dominates, so bias larger. Pair either choice with the jittered backoff in implementing exponential backoff for failed chunks.

Resuming after a dropped connection

Because you chose the block IDs deterministically, resume needs no server-side bookkeeping at all: ask Azure which blocks it already holds, and stage only the gaps. This is the single biggest ergonomic win Azure has over S3 multipart, where part numbers are yours but upload IDs are not.

Resuming an interrupted block upload A sequence in which eight blocks are staged, the connection drops after block five, the client lists uncommitted blocks and re-stages only blocks six and seven before committing. Client Blob service stageBlock 0–7, four at a time connection lost after block 5 getBlockList("uncommitted") names 0, 1, 2, 3, 4, 5 stageBlock 6 and 7 only commitBlockList 0–7
Deterministic block IDs turn resume into one list call — no upload ID, no session state on your server.
import { BlockBlobClient } from "@azure/storage-blob";

export async function resumeUpload(
  uploadUrl: string, // SAS must include "r" as well as "cw"
  file: File,
  blockSize: number,
): Promise<{ restaged: number; total: number }> {
  const client = new BlockBlobClient(uploadUrl);
  const total = Math.ceil(file.size / blockSize);
  const blockIds = Array.from({ length: total }, (_, i) =>
    btoa(String(i).padStart(6, "0")),
  );

  const list = await client.getBlockList("uncommitted");
  const staged = new Set((list.uncommittedBlocks ?? []).map((b) => b.name));

  let restaged = 0;
  for (const [i, id] of blockIds.entries()) {
    if (staged.has(id)) continue;
    const chunk = file.slice(i * blockSize, (i + 1) * blockSize);
    await client.stageBlock(id, chunk, chunk.size);
    restaged++;
  }

  await client.commitBlockList(blockIds, {
    blobHTTPHeaders: { blobContentType: file.type || "application/octet-stream" },
  });
  return { restaged, total };
}

You must use the same blockSize on resume, otherwise the sizes behind the IDs no longer line up. Persist it alongside the blob name — and if you want end-to-end integrity, hash the file first with Web Crypto in the browser and compare after commit. Abandoned block lists cost nothing after a week, but the same hygiene argument applies as in expiring incomplete multipart uploads automatically.

Configuration reference

Options accepted by uploadData and, where marked, by stageBlock / commitBlockList.

Option Type Default Effect
blockSize number auto-sized to stay under 50,000 blocks Bytes per staged block
concurrency number 5 Parallel block requests inside uploadData
maxSingleShotSize number 256 MiB Below this, one Put Blob and no block list
blobHTTPHeaders.blobContentType string application/octet-stream Content-Type served on download
blobHTTPHeaders.blobCacheControl string unset Cache header for CDN-fronted media
metadata Record<string, string> {} x-ms-meta-*, ASCII only, 8 KiB total
tags Record<string, string> unset Index tags; needs t in the SAS permissions
tier "Hot" | "Cool" | "Cold" | "Archive" account default Access tier applied at commit
conditions.ifNoneMatch string unset "*" makes an overwrite fail with BlobAlreadyExists
onProgress (ev) => void unset Called with loadedBytes per completed block
abortSignal AbortSignal unset Cancels in-flight requests (also on stageBlock)

Blob-service CORS configuration

Azure applies CORS to the entire Blob service of the storage account, not per container, and allows at most five rules. Allow your origin, the methods the SDK uses, and — critically — expose ETag, or response.etag comes back undefined in the browser even though the upload succeeded. Provider-by-provider detail lives in configuring CORS for GCS and Azure Blob uploads.

import { BlobServiceClient, StorageSharedKeyCredential } from "@azure/storage-blob";

const corsAccount = process.env.AZURE_ACCOUNT_NAME!;
const corsCredential = new StorageSharedKeyCredential(
  corsAccount,
  process.env.AZURE_ACCOUNT_KEY!,
);
const corsService = new BlobServiceClient(
  `https://${corsAccount}.blob.core.windows.net`,
  corsCredential,
);

export async function applyAzureCors(): Promise<void> {
  await corsService.setProperties({
    cors: [
      {
        allowedOrigins: "https://app.example.com,http://localhost:5173",
        allowedMethods: "PUT,POST,GET,HEAD,OPTIONS",
        allowedHeaders: "x-ms-blob-type,x-ms-blob-content-type,x-ms-version,content-type",
        exposedHeaders: "ETag,x-ms-request-id,x-ms-version",
        maxAgeInSeconds: 3600,
      },
    ],
  });
  console.log("CORS applied");
  // Expected: "CORS applied" with no thrown error. Propagation takes ~30s.
}

Configuration gotchas

AuthenticationFailed — “Signature not valid in the specified time frame”

The SAS start time is in the future relative to Azure’s clock, or the ten-minute window elapsed mid-upload. Backdate startsOn by 60 seconds as shown, and treat a 403 during staging as “re-mint and retry” rather than a fatal error.

AuthenticationFailed — “Signature did not match”

Almost always the client rebuilt the URL. new URL(sas).searchParams re-encodes + and / inside the base64 sig, which changes it. Pass the string your backend returned straight into new BlockBlobClient(uploadUrl) and never round-trip it through URLSearchParams.

InvalidBlockList — “The specified block list is invalid”

A block ID in commitBlockList was never staged, the IDs are not all the same length, or more than 7 days passed since staging. Azure requires every block ID to be an equal-length base64 string of at most 64 bytes before encoding; zero-pad the index before encoding, as the examples do.

RequestBodyTooLarge on stageBlock

Your blockSize exceeds the per-block ceiling for the negotiated service version — 100 MiB if the request lands on sv=2019-07-07 or earlier. Pin a recent SDK so x-ms-version is current, or drop the block size.

CORS preflight fails but the same call works from Node

The browser sends OPTIONS with Access-Control-Request-Headers: x-ms-blob-type,x-ms-version. If those are absent from allowedHeaders you get “Response to preflight request doesn’t pass access control check” with no Azure-side error at all. Remember the rule is account-wide: you cannot scope it to one container, so isolate apps that need different origins into separate storage accounts.

Verification

Read the committed blob’s headers back with a short-lived read SAS and assert on them. A HEAD is enough — it costs one transaction and returns everything you need.

curl -sI "https://$AZURE_ACCOUNT_NAME.blob.core.windows.net/$AZURE_CONTAINER/$BLOB_NAME?$READ_SAS" \
  | grep -Ei '^(HTTP|content-length|content-type|etag|x-ms-blob-type)'
# Expected:
# HTTP/1.1 200 OK
# Content-Length: 12884901888
# Content-Type: video/mp4
# ETag: "0x8DD1F3A2B4C5D6E"
# x-ms-blob-type: BlockBlob

From Node, assert the same facts plus the committed block count:

export async function verifyBlob(blobName: string, expectedBytes: number) {
  const blob = corsService.getContainerClient(process.env.AZURE_CONTAINER!)
    .getBlockBlobClient(blobName);
  const props = await blob.getProperties();
  if (props.contentLength !== expectedBytes) {
    throw new Error(`size mismatch: ${props.contentLength} != ${expectedBytes}`);
  }
  const list = await blob.getBlockList("committed");
  console.log(props.contentType, list.committedBlocks?.length ?? 0, "blocks");
  // Expected: "video/mp4 1536 blocks" for a 12 GiB file at 8 MiB blocks.
}

If getProperties throws BlobNotFound after a run of successful stageBlock calls, the commit never happened — the blocks are sitting uncommitted and will vanish in seven days.

Frequently Asked Questions

Should I use an account-key SAS or a user delegation key?

Prefer a user delegation SAS in production: it is signed with an Entra ID credential, so the account key never reaches your application config, and removing the identity’s role assignment invalidates every token it signed. The account-key SAS is fine for a prototype but leaves you with a secret that can only be rotated account-wide.

Why use stageBlock instead of a single uploadData call?

uploadData is fine below its 256 MiB single-shot threshold. Beyond that, explicit staging lets you set your own concurrency, retry one 8 MiB block rather than the whole transfer, and resume from getBlockList after a network drop without any server-side session record.

Do block IDs need to be unique per blob?

They must be unique within that blob’s uncommitted list and all exactly the same length — Azure rejects a mixed-length list with InvalidBlockList. Encoding a zero-padded index, as shown, satisfies both rules and makes the ID reproducible on resume.

Can I cap the upload size the way an S3 POST policy does?

No. A blob SAS has no size condition, so a token holder can write up to the service limit. Enforce limits by rate-limiting token issuance, checking contentLength after commit, and deleting anything oversized before it enters your catalogue.

How do I revoke a SAS I have already handed out?

Only by invalidating the signing key: rotate the account key, or revoke the managed identity’s role for a user delegation SAS. For per-token control, sign against a container stored access policy (signedIdentifier) and delete that policy — which is why short expiries remain the practical defence.