Documentation menu

 

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
  1. The browser asks your endpoint for an upload URL, sending the file's name, size and type.
  2. 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.
  3. The browser sends the file in a single PUT (Azure's Put Blob operation) with the headers your endpoint told it to send. Azure answers 201 Created.

1. Create the storage account and container

With the Azure CLI, signed in with az login:

sh
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 login

In 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

sh
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:

sh
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:

sh
az storage blob upload \
  --account-name mystorageacct \
  --container-name uploads \
  --name photo.jpg \
  --file ./photo.jpg \
  --auth-mode login

The 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:

sh
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:

ts
// 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

tsx
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.
  • PUT is in the allowed methods.
  • Every header upup sends is allowed: x-ms-blob-type and content-type at minimum, or everything you return in uploadHeaders.

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