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.
- You need parallel, out-of-order chunk uploads with client-chosen identifiers.
- You want time-bound, blob-scoped write permission without sharing the account key.
Prerequisites
- A storage account and a container (private access level).
npm i @azure/storage-blobon Node 20+.- The account name and key, or a user delegation key for keyless SAS.
- Blob-service CORS allowing your frontend origin (covered below).
Implementation: mint a write-only SAS
A SAS is a signed query string appended to the blob URL. Scope it 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;
}
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,
},
credential,
).toString();
const uploadUrl =
`https://${account}.blob.core.windows.net/${container}/${blobName}?${sas}`;
return { uploadUrl, blobName };
}
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.startsOnis set one minute in the past. Azure rejects a SAS whose start time is in the future relative to its clock, so backdating absorbs minor clock skew between your server and Azure.expiresOnkeeps the token usable for only ten minutes, bounding the exposure window.protocol: SASProtocol.Httpsrefuses the token over plain HTTP, preventing interception of the signature.- The returned
uploadUrlis the full blob URL with the SAS query string. The browser uploads directly to it; no account key is ever present client-side.
Uploading a small blob from the browser
For a single-shot upload, the client constructs a BlockBlobClient from the SAS URL and calls uploadData.
import { BlockBlobClient } from "@azure/storage-blob";
export async function uploadSmall(uploadUrl: string, file: File) {
const client = new BlockBlobClient(uploadUrl);
const response = await client.uploadData(file, {
blobHTTPHeaders: { blobContentType: file.type },
});
return response.etag; // present on success
}
Large files: stage blocks then commit
For large media, split the file into blocks, stageBlock each under a base64 block ID (you choose the IDs), then commitBlockList to assemble them in order. Blocks can be staged in parallel and out of order, which maps directly onto a frontend resumable upload state machine.
export async function uploadLarge(uploadUrl: string, file: File) {
const client = new BlockBlobClient(uploadUrl);
const blockSize = 4 * 1024 * 1024; // 4 MB blocks
const blockIds: string[] = [];
let index = 0;
for (let offset = 0; offset < file.size; offset += blockSize) {
const chunk = file.slice(offset, offset + blockSize);
// Block IDs must be equal-length, base64-encoded strings.
const blockId = btoa(String(index).padStart(6, "0"));
blockIds.push(blockId);
await client.stageBlock(blockId, chunk, chunk.size);
index++;
}
await client.commitBlockList(blockIds, {
blobHTTPHeaders: { blobContentType: file.type },
});
return blockIds.length;
}
Blob-service CORS configuration
Azure applies CORS to the entire Blob service of the storage account, not per container. Allow your origin and the methods Azure uses for block uploads (PUT and OPTIONS). The full reference is in CORS configuration for uploads.
import { BlobServiceClient } from "@azure/storage-blob";
const service = new BlobServiceClient(
`https://${account}.blob.core.windows.net`,
credential,
);
export async function applyAzureCors(): Promise<void> {
await service.setProperties({
cors: [
{
allowedOrigins: "https://app.example.com,http://localhost:5173",
allowedMethods: "PUT,POST,GET,HEAD,OPTIONS",
allowedHeaders: "*",
exposedHeaders: "ETag,x-ms-request-id",
maxAgeInSeconds: 3000,
},
],
});
console.log("CORS applied");
// Expected: "CORS applied" with no thrown error.
}
Configuration gotchas
“AuthenticationFailed” on every upload
The SAS start time is in the future relative to Azure’s clock. Backdate startsOn by 60 seconds (as above) so minor server clock skew does not invalidate the token before it is used.
“InvalidBlockList” on commit
A block ID in commitBlockList was never staged, or the IDs are not all the same length. Azure requires every block ID to be an equal-length base64 string; pad the index before encoding, as the example does.
CORS change does not take effect for one container only
You cannot scope Azure CORS to a single container — setProperties applies to the whole account’s Blob service. If different apps need different origins, isolate them in separate storage accounts.
Verification
Confirm the committed blob’s size and content type:
export async function verifyBlob(blobName: string) {
const client = service.getContainerClient(container).getBlockBlobClient(blobName);
const props = await client.getProperties();
console.log(props.contentLength, props.contentType);
// Expected: the uploaded byte size and the content type you set.
}
FAQ
Should I use a SAS account key or a user delegation key?
Prefer a user delegation SAS (signed with an Azure AD credential) in production, because it avoids distributing the account key entirely and can be revoked via the identity. The account-key SAS shown here is the simplest to start with.
Why use stageBlock instead of a single uploadData call?
uploadData is fine for small files. For large media, staging blocks lets you upload chunks in parallel, retry an individual failed block, and resume without re-sending the whole file.
Do block IDs need to be unique per blob?
They must be unique within the uncommitted block list for that blob and all the same length. Encoding a zero-padded index, as shown, satisfies both rules.