Upload Files to Azure Blob Storage from the Browser
The short answer: to upload a file to Azure Blob Storage from the browser,
have your server create a short-lived SAS URL for one blob, then PUT the file
to that URL with the header x-ms-blob-type: BlockBlob. The storage account
needs a CORS rule that allows your origin. The account key never reaches the
browser.
This tutorial walks through every step: the container, the SAS (from the
portal and Node), a command-line test with the Azure CLI and curl, CORS, the
upload itself in JavaScript, and the errors you will hit on the way. It uses upup's uploader for the browser side, but the Azure setup is
the same whatever sends the PUT.
Client mode only
upup supports Azure Blob Storage through client mode: your endpoint
returns a SAS URL and the browser uploads straight to the blob. Server mode
(@useupup/server) does not support Azure. See why server mode rejects
it below.
How the upload works
browser ──POST /api/upload-token {name, size, type}──> your server
browser <──{ uploadUrl: <blob URL + SAS>, uploadHeaders }── your server
browser ──PUT file bytes + x-ms-blob-type: BlockBlob──> <account>.blob.core.windows.net- The browser asks your endpoint for an upload URL, sending the file's name, size and type.
- Your endpoint checks the user and the file, picks a blob name, and signs a SAS that allows writing that one blob for a few minutes.
- The browser sends the file in a single
PUT(Azure's Put Blob operation) with the headers your endpoint told it to send. Azure answers201 Created.
1. Create the storage account and container
With the Azure CLI, signed in with
az login:
az storage account create \
--name mystorageacct \
--resource-group my-rg \
--location eastus \
--sku Standard_LRS
az storage container create \
--name uploads \
--account-name mystorageacct \
--auth-mode loginIn the portal: Storage accounts → Create, then the account's Containers →
+ Container. Leave the container's public access level Private. A SAS
grants access one request at a time, so the container never needs to be public.
2. Create a SAS URL
A SAS (shared access signature) is a set of query parameters, signed with the account key or a user delegation key, that grants specific permissions on a resource until an expiry time. For a browser upload you want:
- Scope: one blob, not the whole container or account.
- Permissions: create and write (
cw), nothing else. No read, list or delete. - Expiry: minutes, not days.
- Protocol: HTTPS only.
In production your backend signs a fresh SAS for each file (step 5). To try the flow first, generate one from the command line (step 3) or the portal.
In the portal
Open the container, then Shared access tokens. Choose the signing method
(account key or user delegation key), tick Create and Write, set a short
expiry, set Allowed protocols to HTTPS only, and click Generate SAS token
and URL. That produces a container-level SAS; to upload a file, insert the
blob name into the URL path before the ?.
Portal-generated tokens are for testing. In production your server creates a fresh one per file, as in step 5.
3. Upload a file with the Azure CLI (az storage blob upload)
Before you write any backend code, upload a test file with az storage blob upload
(from the Azure CLI) or curl. If an upload
works here, anything that still fails in the browser is CORS or your endpoint,
not the SAS.
Your Microsoft Entra ID account needs a data role such as Storage Blob Data Contributor on the storage account for the commands below. Owning the account is not enough: data operations need their own role assignment.
Generate a user delegation SAS
SAS=$(az storage container generate-sas \
--account-name mystorageacct \
--name uploads \
--permissions cw \
--expiry "$(date -u -d '30 minutes' '+%Y-%m-%dT%H:%MZ')" \
--https-only \
--auth-mode login --as-user \
--output tsv)--as-user signs the SAS with a user delegation key from your Entra ID sign-in
instead of the account key. It requires --auth-mode login and --expiry, and
a user delegation SAS is valid for at most seven days. --permissions cw
allows create and write only, --https-only rejects plain HTTP, and
--output tsv prints the bare token rather than a quoted JSON string. The
date -u -d syntax is GNU date; on macOS, type an ISO 8601 UTC time a few
minutes ahead.
This SAS covers every blob in the uploads container, which is fine for a
test. Your backend should scope each SAS to one blob instead:
az storage blob generate-sas takes the same flags plus --container-name and
the blob --name, and --full-uri prints the complete blob URL with the SAS
attached.
Upload with curl
Send the same request the browser will send, a Put Blob with the blob-type header:
curl -i -X PUT \
-H "x-ms-blob-type: BlockBlob" \
-H "Content-Type: image/jpeg" \
--data-binary @photo.jpg \
"https://mystorageacct.blob.core.windows.net/uploads/photo.jpg?$SAS"A 201 Created status means the SAS, its permissions and the headers are
right. curl does not do CORS, so a 403 here comes from the SAS or the
account's network rules, never from a CORS rule; see
Troubleshooting.
Upload with az storage blob upload
To upload a local file from a machine you control, you don't need a SAS at all.
az storage blob upload authenticates with your own sign-in:
az storage blob upload \
--account-name mystorageacct \
--container-name uploads \
--name photo.jpg \
--file ./photo.jpg \
--auth-mode loginThe command fails if the blob already exists; add --overwrite to replace it.
To test the SAS itself with the CLI, replace --auth-mode login with
--sas-token "$SAS". The CLI is the right tool for scripts and CI, not for end
users uploading from a web page. Those need the SAS flow in this tutorial.
4. Configure CORS on the storage account
The browser's PUT goes to <account>.blob.core.windows.net, a different
origin from your app, and it carries a custom x-ms-blob-type header, so the
browser sends a CORS preflight first. Azure has to allow it:
az storage cors add \
--account-name mystorageacct \
--services b \
--origins https://app.example.com \
--methods PUT OPTIONS \
--allowed-headers x-ms-blob-type content-type \
--exposed-headers etag \
--max-age 3600--services b targets the Blob service. Check the result with
az storage cors list --services b --account-name mystorageacct.
In the portal the same rule lives under the storage account → Resource
sharing (CORS) → Blob service: allowed origin, PUT method, the headers
you send (x-ms-blob-type, Content-Type), and exposed header ETag.
Unlike S3, Azure CORS is an account-level setting per service, not a per-container one. Every container in the account shares it.
5. Issue the SAS from your backend
This is your own backend code, not part of upup. It is a minimal Express
route that returns upup's presign contract with
@azure/storage-blob:
// server.ts: your backend, not an upup package
import { randomUUID } from 'node:crypto'
import express from 'express'
import {
BlobSASPermissions,
SASProtocol,
StorageSharedKeyCredential,
generateBlobSASQueryParameters,
} from '@azure/storage-blob'
const account = process.env.AZURE_STORAGE_ACCOUNT!
const container = process.env.AZURE_STORAGE_CONTAINER!
const credential = new StorageSharedKeyCredential(
account,
process.env.AZURE_STORAGE_KEY!,
)
const MAX_BYTES = 100 * 1024 * 1024 // must fit a single Put Blob; see below
const app = express()
app.use(express.json())
app.post('/api/upload-token', (req, res) => {
// Authenticate the caller here: this route hands out write access.
const { name, size, type } = req.body ?? {}
if (typeof name !== 'string' || typeof size !== 'number') {
return res.status(400).json({ error: 'name and size are required' })
}
if (size > MAX_BYTES) {
return res.status(413).json({ error: 'File too large' })
}
// The server picks the blob name; never trust a client-chosen path.
const safeName = name.replace(/[^\w.-]+/g, '_')
const key = `uploads/${randomUUID()}-${safeName}`
const expiresIn = 600 // seconds
const sas = generateBlobSASQueryParameters(
{
containerName: container,
blobName: key,
permissions: BlobSASPermissions.parse('cw'), // create + write
startsOn: new Date(Date.now() - 5 * 60 * 1000), // clock-skew margin
expiresOn: new Date(Date.now() + expiresIn * 1000),
protocol: SASProtocol.Https,
},
credential,
).toString()
res.json({
key,
uploadUrl: `https://${account}.blob.core.windows.net/${container}/${key}?${sas}`,
uploadHeaders: {
'x-ms-blob-type': 'BlockBlob',
'Content-Type': type || 'application/octet-stream',
},
expiresIn,
})
})
app.listen(3000)upup POSTs { name, size, type, metadata } as JSON to this route and expects
key, uploadUrl and expiresIn back, plus optional uploadHeaders,
publicUrl and downloadUrl. The full contract is on
Azure SAS Responses; the S3
equivalent is S3 Presign Responses.
A Next.js route handler works the same way: parse the body with
await req.json() and return Response.json({...}).
Prefer a user delegation key in production
The route above signs with the account key, the simplest option. A user
delegation key, obtained through Entra ID (for example with
BlobServiceClient.getUserDelegationKey) and passed to
generateBlobSASQueryParameters together with the account name, produces
the same URL shape without an account key in your server's environment.
Either way, keep the signing credential server-side: the browser must only
ever receive the finished URL.
6. Point upup at the endpoint
import { UpupUploader } from '@useupup/react'
import '@useupup/react/styles'
export default function Uploader() {
return (
<UpupUploader
uploadEndpoint="/api/upload-token"
maxFileSize={{ size: 100, unit: 'MB' }}
/>
)
}That's the whole client. upup asks the endpoint for each file's URL, then PUTs
the file with an XMLHttpRequest so it can report progress, sending every
header from uploadHeaders. Vue, Svelte, Angular, Preact and vanilla JS take
the same uploadEndpoint option; see the quickstarts.
The provider prop is optional for a custom uploadEndpoint.
Keep maxFileSize in step with the server's limit so users get the error
before the upload starts, not after.
x-ms-blob-type is mandatory
Azure rejects a Put Blob request that has no x-ms-blob-type header with a
400, and the browser does not surface the reason usefully. It must be
returned in uploadHeaders so upup sends it. This is the single most common
reason an otherwise correct SAS URL fails.
Uploading images
Nothing changes for images, but two details matter. The Content-Type you
send becomes the blob's content type, which is what a browser uses when it
later displays the image, so pass the file's real type through as the route
above does. And restrict what users can pick with the allowedFileTypes prop
(for example allowedFileTypes="image/*"), mirrored by a type check in your
route.
File size: one Put Blob per file
upup's client-mode contract is one signed PUT per file, so every upload must
fit in a single Azure Put Blob request. Larger files need Azure's staged block
flow (Put Block plus Put Block List), which the presign contract does not
model, and resumable: { protocol: 'multipart' } speaks S3 multipart, not
Azure blocks, so don't enable it here. Cap the size with maxFileSize, and
check the Put Blob limit for your account's service version before choosing the
number.
Troubleshooting
Azure puts an error code in the XML response body. Open the failed request in your browser's network panel to read it.
403 AuthorizationFailure
The request reached Azure but the account refused it outright. The most common cause is the storage account's network rules: under Networking, public access is limited to selected networks or disabled in favour of private endpoints, and your users' browsers are not on those networks. Browser uploads need the blob endpoint reachable from wherever your users are.
403 AuthenticationFailed
The signature itself was rejected. Check that the SAS was signed for the exact container and blob name in the URL, that it has not expired, and that the account key or delegation key is current. A message saying the signature is "not valid in the specified time frame" is a clock problem; see below.
403 AuthorizationPermissionMismatch
The SAS is valid but lacks a permission. Uploading a new blob needs create or
write (cw). A read-only SAS copied from somewhere else fails here.
CORS preflight failures
The console shows the PUT blocked by CORS, and the OPTIONS preflight
returned 403 with "CORS not enabled or no matching rule found for this
request." Check, in order:
- The rule is on the Blob service (
--services b), not Queue, Table or File. - The origin matches exactly: scheme, host and port, with no trailing slash.
PUTis in the allowed methods.- Every header upup sends is allowed:
x-ms-blob-typeandcontent-typeat minimum, or everything you return inuploadHeaders.
CORS changes can take a short while to apply, so retry before changing more.
Missing x-ms-blob-type header
A 400 on the PUT, typically with a MissingRequiredHeader error code, means
the blob-type header didn't arrive. Check that your endpoint returns it inside
uploadHeaders (not as a response header of the endpoint itself), and that
the CORS rule allows it. Otherwise the preflight fails before this point.
Clock skew
SAS tokens are time-scoped. If the server that signs them has a clock that
runs ahead, startsOn lands in Azure's future and the token is rejected until
that moment passes. Omit startsOn, or backdate it a few minutes as the route
above does, and keep the server's clock synced with NTP. Short expiries are
good; expiries shorter than a realistic upload time are not, because Azure
checks the SAS when the request starts.
Why server mode rejects Azure
Azure Blob Storage is the one provider in upup's list that server mode cannot
serve. It has no S3-compatible API, and @useupup/server speaks only S3: every
upload it performs goes through an @aws-sdk/client-s3 client.
@useupup/core exports azure as a StorageProvider value and also lists it in
NON_S3_STORAGE_PROVIDERS. createUpupHandler checks that set while it is being
constructed, so passing type: 'azure' throws an UpupConfigError at boot,
before a single request is served, rather than failing per request later:
[@useupup/server] storage.type "azure" has no S3-compatible API and cannot be
served. upup uploads via the S3 API — use an S3-compatible provider
(aws, minio, r2, wasabi, …) and set storage.endpoint for non-AWS backends.getDownloadUrl from @useupup/server throws the same error for azure. This
is deliberate: accepting the value and failing on the first upload would hide a
configuration mistake behind a runtime 500 that looks like a credentials
problem.
Server mode's guarantees (credentials never reach the browser, drive-to-storage transfers streamed server-side, one origin for the uploader) are tied to the S3 API. Getting them with an Azure account means putting an S3-compatible store in front of the upload path and syncing to Azure afterwards, or moving the upload bucket to any provider in the matrix. There is no Azure adapter to enable.
FAQ
Can the browser upload to Azure Blob Storage without exposing the account key?
Yes, and it should. Your server signs a SAS for one blob with the account key or a user delegation key and returns only the finished URL. The browser never sees the key, and the SAS can do nothing beyond writing that one blob until it expires.
Which headers does a Put Blob with a SAS URL need?
x-ms-blob-type: BlockBlob is required. Content-Type is optional but sets the
blob's content type, so send the file's real type. The SAS itself travels in
the URL's query string, so no Authorization header is needed.
Does the container need to be public?
No. Keep it private. The SAS grants write access per request. For downloads, issue read SAS URLs the same way, or put a CDN in front of the account.
Does Azure Blob Storage have an S3-compatible API?
No. Azure Blob Storage has its own REST API (Put Blob, Put Block) rather than the
S3 API. upup's server mode speaks only S3, so createUpupHandler rejects
storage.type: 'azure'. Use client mode instead: your endpoint returns a SAS URL
and the browser uploads to it with a single PUT.
Does upup server mode support Azure Blob Storage?
No. createUpupHandler throws at construction for storage.type: 'azure',
because server mode speaks only the S3 API. Use client mode with a SAS endpoint,
as in this tutorial.
How large a file can I upload this way?
As large as one Put Blob request allows for your account's service version,
since upup sends each file in a single PUT. Set maxFileSize below that
limit. Resumable multipart in upup targets S3, not Azure.
Why does the upload work with curl but fail in the browser?
curl doesn't do CORS. If the same SAS URL and headers succeed from curl but fail in the browser, the storage account's CORS rule is missing or doesn't match your origin, method or headers.
How do I upload a file to Azure Blob Storage with the Azure CLI?
Run az storage blob upload with --account-name, --container-name,
--name and --file, plus --auth-mode login to use your Microsoft Entra ID
sign-in. That sign-in needs a data role such as Storage Blob Data Contributor
on the account. To use a SAS instead, pass --sas-token in place of
--auth-mode login. The CLI suits scripts and CI; users uploading from a web
page need a SAS URL from your server. The full commands are in
step 3.
Next steps
- Storage Providers: the provider matrix and
the shared
storageconfig reference. - Client Mode vs Server Mode: what changes on the wire.
- Other S3-compatible services: if you would rather use a store that server mode can serve.