Azure User-Bound Delegation SAS Is GA: Lock Storage Tokens to a Single Entra Identity
Azure User-Bound Delegation SAS Is GA: Lock Storage Tokens to a Single Entra Identity
Every SAS token you've ever generated for Azure Storage has the same underlying weakness: it's a bearer credential. Whoever holds the URL โ the person you sent it to, whoever they forwarded it to, whoever it leaked to in a log line or a Slack message or a public GitHub gist โ can use it, for as long as it's valid, with no way for Azure to tell the difference between the intended recipient and anyone else holding the string. A 7-day user delegation SAS "expiring soon" is cold comfort if it's been sitting in a misconfigured public bucket for six of those days.
Microsoft's fix, user-bound user delegation SAS, went generally available on Azure Storage on September 9, 2026, after entering public preview earlier in the year. It closes that gap by binding the token to a specific Microsoft Entra ID identity โ the URL alone stops being sufficient. This post covers exactly how the binding works, how to actually construct one (including a from-scratch worked example, since CLI/SDK support for the new fields can lag the REST surface at GA), and how to enforce it account-wide so old-style SAS tokens stop working entirely.
Why this matters: what a normal user delegation SAS doesn't check
A standard user delegation SAS is generated using Entra credentials (rather than a long-lived storage account key), which is already a security improvement Microsoft has recommended for years. But the token it produces is still just a signed URL. Present the URL, and Azure Storage validates the signature, the expiry, and the permissions โ nothing about who is presenting it. That's the entire attack surface this feature closes: URL exfiltration (through browser history, proxy logs, shared documents, or a forwarded email) has always meant credential exfiltration, because the URL is the credential.
User-bound user delegation SAS adds one more check to the request: the caller must also present a valid Entra bearer token, and the oid (object ID) claim in that bearer token must match the object ID baked into the SAS at creation time. Leak the URL without also leaking the token, and it's useless to whoever finds it.
How the binding actually works
Two new fields, introduced with storage authorization version 2025-07-05, drive this:
signedDelegatedUserObjectId(short formsduoid) โ the Entra object ID of the one user allowed to use this SAS token. Required to enable the feature at all.signedKeyDelegatedUserTenantId(short formskdutid) โ required only when that user is in a different Entra tenant than the storage account. This also requires you to have passedDelegatedUserTidwhen you requested the user delegation key in the first place.
When sduoid is present on a SAS token, Azure Storage requires the request to carry an Entra bearer token whose oid claim matches it exactly. No matching bearer token, no access โ regardless of how correct the SAS signature otherwise is. Cross-tenant use is blocked by default even if skdutid is set correctly; you have to explicitly opt the storage account into it with allowCrossTenantDelegationSas.
The feature works across Blob, Queue, Table, and Azure Files, is available on every GPv2 storage account in every public region, and costs nothing beyond your normal read/write transaction pricing.
Step-by-step: building a user-bound SAS
At GA, Microsoft's own announcement lists REST APIs, SDKs, PowerShell, CLI, and the portal as supported access paths. In practice, tooling versions vary โ if your installed Az PowerShell module or az CLI doesn't yet expose a --signed-delegated-user-object-id-style flag, don't assume the feature isn't available; fall back to the REST API directly, which is fully documented and guaranteed current. That's the path this walkthrough uses, since it works regardless of your tooling version.
1. Confirm the caller has permission to generate a delegation key
The principal requesting the user delegation key needs an RBAC role that includes the Microsoft.Storage/storageAccounts/{serviceType}/generateUserDelegationKey action โ built-in roles like Storage Blob Data Contributor, Storage Blob Data Reader, or the narrower Storage Blob Delegator all qualify.
2. Get the end user's Entra object ID
1# For the currently signed-in user
2az ad signed-in-user show --query id -o tsv
3
4# For a specific user by UPN
5az ad user show --id "user@yourtenant.com" --query id -o tsv
3. Request a user delegation key (REST)
1curl -X POST \
2 "https://<account>.blob.core.windows.net/?restype=service&comp=userdelegationkey" \
3 -H "Authorization: Bearer <caller-entra-token>" \
4 -H "x-ms-version: 2025-07-05" \
5 -d '<?xml version="1.0" encoding="utf-8"?>
6<KeyInfo>
7 <Start>2026-09-14T18:00:00Z</Start>
8 <Expiry>2026-09-15T18:00:00Z</Expiry>
9</KeyInfo>'
The response gives you SignedOid, SignedTid, SignedStart, SignedExpiry, SignedService, SignedVersion, and the Value (the delegation key itself) โ you need all of these to build the SAS signature.
4. Construct the string-to-sign and compute the signature
This is the part CLI/SDK wrappers normally hide from you. For blob resources on sv=2025-07-05 and later, the string-to-sign has a specific field order โ get this wrong and the signature simply won't validate:
1import hmac, hashlib, base64
2from urllib.parse import quote
3
4def build_user_bound_sas(
5 account, container, blob,
6 delegation_key_value, signed_oid, signed_tid,
7 signed_start, signed_expiry, signed_key_start, signed_key_expiry,
8 sduoid, permissions="r", token_start="2026-09-14T18:05:00Z", token_expiry="2026-09-14T20:05:00Z",
9):
10 canonicalized_resource = f"/blob/{account}/{container}/{blob}"
11 string_to_sign = "\n".join([
12 permissions, # signedPermissions
13 token_start, # signedStart
14 token_expiry, # signedExpiry
15 canonicalized_resource, # canonicalizedResource
16 signed_oid, # signedKeyObjectId
17 signed_tid, # signedKeyTenantId
18 signed_key_start, # signedKeyStart
19 signed_key_expiry, # signedKeyExpiry
20 "b", # signedKeyService (blob)
21 "2025-07-05", # signedKeyVersion
22 "", # signedAuthorizedUserObjectId (saoid)
23 "", # signedUnauthorizedUserObjectId (suoid)
24 "", # signedCorrelationId
25 "", # signedKeyDelegatedUserTenantId (skdutid, same-tenant here)
26 sduoid, # signedDelegatedUserObjectId
27 "", # signedIP
28 "https", # signedProtocol
29 "2025-07-05", # signedVersion
30 "b", # signedResource (blob)
31 "", # signedSnapshotTime
32 "", # signedEncryptionScope
33 "", "", "", "", "", # rscc, rscd, rsce, rscl, rsct
34 ])
35 key_bytes = base64.b64decode(delegation_key_value)
36 signature = base64.b64encode(
37 hmac.new(key_bytes, string_to_sign.encode("utf-8"), hashlib.sha256).digest()
38 ).decode()
39
40 params = {
41 "sv": "2025-07-05", "sr": "b", "sp": permissions,
42 "st": token_start, "se": token_expiry,
43 "skoid": signed_oid, "sktid": signed_tid,
44 "skt": signed_key_start, "ske": signed_key_expiry,
45 "sks": "b", "skv": "2025-07-05",
46 "sduoid": sduoid,
47 "spr": "https", "sig": signature,
48 }
49 return "&".join(f"{k}={quote(v, safe='')}" for k, v in params.items())
The sduoid you pass in here is the object ID from step 2 โ this is the field that turns a normal user delegation SAS into a user-bound one. Everything else in the string-to-sign order matters; the field for skdutid sits before sduoid, not after, which is easy to get backwards if you're building this by hand.
5. Enforce it account-wide
Binding individual tokens is opt-in per SAS. To stop any non-user-bound user delegation SAS from working against an account, set two account-level properties:
requireUserBoundUserDelegationSasโtrue/false(defaultfalse)requireUserBoundUserDelegationSasActionโblockorlog(defaultlog)
With requireUserBoundUserDelegationSas=true and the action set to block, any SAS token that isn't user-bound fails outright. Set the action to log first in a non-production account and watch the logs for anything that would break before flipping to block โ this is exactly the kind of account-wide access change that deserves a soak period, not a same-day flip in production. Set both via the portal (Storage account โ Configuration) or an ARM/Bicep template if your CLI version doesn't yet expose dedicated flags for these properties.
Best practices
- Start with
log, notblock. FliprequireUserBoundUserDelegationSasActiontoblockonly after confirming, from the log output, that nothing legitimate is still using non-bound tokens. - Keep cross-tenant access off unless you actually need it.
allowCrossTenantDelegationSasdefaults to disabled โ leave it that way unless you have a genuine cross-tenant delegation scenario, and scopeskdutidtightly when you do enable it. - Update client applications before you enforce this account-wide. Anything that expects "receive a URL, use the URL" now also needs to acquire and attach an Entra bearer token. Rolling out enforcement before your clients are updated breaks them, not attackers.
- Treat the delegation key's own lifetime carefully. The key you request in step 3 is valid for up to seven days โ don't request a longer-lived key than the SAS tokens you'll actually issue against it need.
- Don't stop scoping permissions tightly just because tokens are now identity-bound. User-bound SAS adds a second lock; it doesn't replace the value of narrow
sp=permissions and short expiries.
Common mistakes to avoid
- Assuming your existing SDK version already supports
sduoid. New REST fields at a service's GA date don't guarantee same-day SDK/CLI parity โ check your installed version's changelog before assuming a flag exists, and fall back to the REST construction shown above if it doesn't. - Getting the string-to-sign field order wrong. The 2025-07-05 format inserts
skdutidandsduoidin a specific position relative tosaoid/suoid/scidโ copy-pasting an older version's string-to-sign template silently breaks the signature. - Flipping
requireUserBoundUserDelegationSasActionstraight toblockin production. This account-wide enforcement change should soak inlogmode first, per the best practices above. - Forgetting the end user needs a matching bearer token at request time, not just a correctly generated SAS. A perfectly valid
sduoid-bound SAS still fails if the caller doesn't also present an Entra token whoseoidclaim matches. - Enabling cross-tenant delegation broadly instead of narrowly scoping it to the specific tenant relationship that needs it.
Troubleshooting
Requests fail with an authorization error even though the SAS signature looks correct. Confirm the caller is also sending a valid Entra bearer token, and that its oid claim exactly matches the sduoid value baked into the SAS โ a mismatched or missing bearer token is the most common cause once the signature itself checks out.
Cross-tenant SAS tokens fail even with a correct skdutid. Check allowCrossTenantDelegationSas on the storage account โ it's disabled by default and blocks all cross-tenant user-bound tokens regardless of how correctly skdutid is set.
requireUserBoundUserDelegationSas=true unexpectedly broke an existing integration. That integration is issuing non-bound (older-style) SAS tokens. Either update it to bind tokens with sduoid, or temporarily set the action back to log while you migrate it.
Signature doesn't validate despite correct field values. Recheck the string-to-sign field order against the exact 2025-07-05 format โ it differs from pre-2025-07-05 versions specifically around where skdutid/sduoid sit.
FAQ
Does user-bound user delegation SAS cost extra? No. Pricing is standard read/write transaction cost for your account type โ no separate charge for the feature itself.
Which storage services support it? Blob, Queue, Table, and Azure Files, on any GPv2 account in any public Azure region.
Does this replace account-key-based SAS entirely? It only applies to user delegation SAS (Entra-credential-based). It has no bearing on account-key SAS, which Microsoft already recommends avoiding in favor of Entra-based auth generally.
Can I bind a SAS to a user in a different Entra tenant?
Yes, using skdutid plus DelegatedUserTid on the delegation key request โ but only if allowCrossTenantDelegationSas is explicitly enabled on the storage account first.
What happens to SAS tokens issued before I turn on account-wide enforcement?
Non-bound tokens keep working until you set requireUserBoundUserDelegationSasAction to block. With the default log action, they're logged but still succeed.
Key takeaways
| Question | Answer |
|---|---|
| GA date | September 9, 2026 |
| What it binds | A user delegation SAS to one Entra object ID (sduoid) |
| Minimum auth version | sv=2025-07-05 |
| Cross-tenant field | skdutid, requires allowCrossTenantDelegationSas=true |
| Supported services | Blob, Queue, Table, Azure Files (GPv2 accounts, public regions) |
| Account-wide enforcement | requireUserBoundUserDelegationSas + requireUserBoundUserDelegationSasAction (log/block) |
| Extra cost | None beyond standard transaction pricing |
| Biggest rollout risk | Enforcing before client apps can present a matching Entra bearer token |
Further Reading
- Managed Instance on Azure App Service Is GA: Migrate Legacy .NET Framework Apps Without Rewriting Them
- Docker Hub OIDC for GitHub Actions: Retire Your Stored Access Tokens
- GitHub Apps and OAuth Apps Get Refresh Tokens: How to Migrate Off Long-Lived Access Tokens
- Official Microsoft Learn: Create a user delegation SAS (REST API reference)
- Official Microsoft Community Hub: Generally Available โ Restrict usage of user delegation SAS to an Entra ID identity