A deep-dive into User Delegation Shared Access Signature (SAS) for Azure Storage

In a previous blog post, I covered what Shared Access Signatures are and how to choose between the various types. Among the three types, User Delegation SAS is the preferred option when it comes to accessing either Azure Blob Storage or Azure Data Lake Storage Gen2. It's just not available for the other storage services, namely Queue, Table, and File services at the time of writing this blog post.
What is so special about User Delegation SAS?
User Delegation SAS is a type of Shared Access Signature that differs in how it is signed. Unlike its companions, the Account SAS and the Service SAS, which are signed with the account key, the User Delegation SAS is signed with a delegation key. This delegation key is issued to a security principal in Entra ID - user, service principal, or managed identity. So, it's not meant to be used by users only, although the name can be a bit misleading.
You can issue a user delegation SAS at a container level, at a blob level, or in the case of Azure Data Lake Storage Gen2, at the directory level. However, the permissions granted by the other two types of SAS tokens, service and account, differ significantly, as they provide uniform access across all resources. So let's cover how permissions work here.
Just like the other token types, the user delegation SAS token contains a list of fields that describe what the token is capable of doing. As you might already know, these are kept in the signedPermissions
(sp
) field that is for specifying the permissions of the SAS. A few example values for this field are r
(read), w
(write), rw
(read and write), rl
(read and list). But those are not the effective permissions of a user delegation SAS. Instead, the effective permissions are the intersection between the following checked at runtime:
- The permissions that are explicitly stated in the
field of the SAS token.signedPermissions
- The Azure RBAC permissions that are granted to the security principal. For example, Storage Blob Data Reader, Storage Blob Data Contributor, etc.
- The POSIX ACLs, if it is an Azure Data Lake Storage Gen2 and there isn't a matching RBAC permission granted to the security principal.
Let's work through one example of a user delegation SAS token: ?sv=2023-11-03&st=2025-01-12T15%3A03%3A31Z&se=2025-01-13T15%3A03%3A31Z&skoid=1b44b83a-7a61-4f10-85b3-6690926afb63&sktid=1712d5af-25d4-4ca9-9582-0b6efdb0440d&skt=2025-01-12T15%3A03%3A31Z&ske=2025-01-13T15%3A03%3A31Z&sks=b&skv=2023-11-03&sr=b&sp=r&sig=w5%2ckz0iViW3vpo67bVtMHOtWL2Gr3MvqA1j29gX62tw%3D
The signed permission specified in the SAS token above is sp=r
(read). The Entra ID security principal is stated in the skoid
field. If we pretend this token is still valid, we could prepend it to the URL of the Get Blob operation, for example. While the SAS itself looks fine, the Azure Storage service will have to check whether the security principal is allowed to read a given blob. It must have been granted an RBAC role such as Storage Blob Data Reader or read permissions configured via the POSIX Access Control Lists in the case of ADLS Gen2. If not, the operation will be denied.
Besides the way permissions work, a user delegation SAS token requires some special fields that are not present in the other types of SAS tokens. Those are signedTenantId
(sktid) and a bunch of fields that describe various properties of the user delegation key such as signedKeyStartTime
(skt), signedKeyExpiryTime
(ske), signedKeyService
(sks), and others. By possessing a user delegation key of a security principal, one can be regarded as the security principal without knowing their credentials. This exemplifies the inherence factor in authentication (something the user is), as a user delegation key is uniquely issued to a security principal for a specified period. Consequently, all fields describing a user delegation key are essential. You can check all fields in the documentation.
There are two more interesting fields:signedAuthorizedObjectId
(saoid
) and signedUnauthorizedObjectId
(suoid
). They allow you to specify the object ID of another security principal that is authorized by the owner of the user delegation key to perform some actions by using a SAS token. In a sense, that becomes a delegated delegation. To keep this post at a reasonable length, I won't cover them but you can learn more about them in the documentation.
Why is user delegation SAS a good idea?
You don't need the storage account key to sign the SAS. This is a major benefit as using a storage account key is generally not recommended. Handling the storage account key requires a lot of care and therefore its usage is typically disallowed in most organizations.
The delegated access is fine-grained and revocable. As discussed above, the effective permissions are the intersection of the permissions that are set on the SAS token itself and those that are assigned via either an RBAC role or POSIX ACLs. One option for revoking that SAS would be just unassigning the RBAC role or POSIX ACL permission. But do keep in mind that permissions might be cached at the storage service level, so it might take some time before the change comes into effect. Based on tests I have done, the permission changes are processed immediately but that likely depends on the depth and breadth of your storage account and also at what level you assign these permissions.
There are more Storage audit logs available. For example, each user delegation key that gets generated results in a distinct GetUserDelegationKey
operation in the diagnostic logs of your storage account. You can see when and where this happened, along with what security principal requested it. Later on, when you supply a user delegation SAS to some operation in Azure Storage, the security principal is tracked in RequesterObjectId
almost like it was a normal call that supplied an Entra ID access token of the security principal. But upon a further look, you see that AuthenticationType
is DelegationSas
and the value of AuthenticationHash
is something like user-delegation(6908E4F39F0BD8FD47F8027024461A266D2800B552238122F432C4E75DE2CBE3),SasSignature(0582D8D7419F5B1C62C180AFF6885C53AA4BAB41602E427D3F1611B238F6C712)
.
You can optionally specify a correlation ID to be included in the user delegation SAS by specifying the signedCorrelationId
field and supplying a Guid as a value. This could be handy if you want to get a correlation ID from the process that issues the SAS token and make sure that this correlation ID becomes an indispensable part of the SAS. As this correlation ID is included in the storage audit logs, later on you will be able to find out in what circumstances the SAS was issued by checking the logs of the process that issued the SAS token.
Enough theory, let's see how obtaining user delegation works in action!
Generate a user delegation SAS
As a prerequisite, make sure that the security principal you are going to use to obtain a user delegation key (see Step 2 below) has an RBAC role that contains the Microsoft.Storage/storageAccounts/blobServices/generateUserDelegationKey action at the level of the storage account or above. The built-in Storage Blob Delegator has just this action, so choose this role should you want to follow the principle of least privilege. Alternatively, you could use Contributor or Storage Blob Data Reader/Contributor/Owner roles because they have the abovementioned action too.
In general, the steps to issue a user delegation SAS are as follows:
- Obtain an Entra ID access token via an applicable OAuth 2.0 flow.
- Get a user delegation key by invoking the Get User Delegation Key operation.
- Assemble all the fields of the SAS token and sign using the user delegation key.
Depending on the method you use for issuing the SAS token, this step can range from very easy to very complex. There are several ways to issue a user delegation SAS token that are also applicable to the other types of SAS tokens:
- Azure Storage Explorer - this might be the easiest of all. Just right-click on a blob/directory/container, choose Get Shared Access Signature from the context menu, then choose "User delegation key" as in the "Signing Key" dropdown. Done!
- Storage Browser available in the Azure Portal - it provides a similar but more restricted experience than Azure Storage Explorer right from the Azure Portal. Navigate to your storage account resource, then you will see the "Storage browser" blade from the menu. Click on the three dots next to a file or directory, to see "Generate SAS". Easy!
- Use a client library - if you want to issue SAS tokens programmatically. I will show you an example with Azure.Storage.Blobs below.
- Implement it yourself - you must have strong reasons and know what you are doing. For example, I had to implement such a thing once for a client inside a Power Platform Custom Connector. It was the only place where we could shamelessly stick a piece of .NET code to be executed. However, the major restriction was that we could not use any .NET libraries. Therefore I had to implement it from scratch.
Azure Storage Explorer and the Storage Browser from the Azure Portal are really handy if you want to generate a SAS occasionally. But if you have to implement it as part of some solution, you'll have to write some code. It's always good if you know how things really work. By comparing both of the examples that follow, you come to appreciate how SDKs are practically shielding us, the users, from unneeded complexity.
Generate a user delegation SAS using Azure.Storage.Blobs
I am going to use a service principal for both examples, so the applicable OAuth 2.0 is client credentials, therefore I chose to use ClientSecretCredential. But depending on your use case, you can use any of the implementations of the TokenCredential abstract class.
- Create an instance of BlobServiceClient:
var azCredential = new ClientSecretCredential(tenantId, clientId, clientSecret); var blobServiceClient = new BlobServiceClient( new Uri(blobEndpoint), azCredential);
- Get a user delegation key for the service principal:
var utcNow = DateTimeOffset.UtcNow; var userDelegationKey = await blobServiceClient.GetUserDelegationKeyAsync( utcNow, utcNow.AddHours(hours), cancellationToken);
- Create an instance of BlobClient that points to a specific blob:
var blobClient = blobServiceClient .GetBlobContainerClient(blobContainerName) .GetBlobClient(blobName);
- Construct a user delegation SAS URL.
var utcNow = DateTimeOffset.UtcNow; var sasPermissions = BlobSasPermissions.Read | BlobSasPermissions.Write; var sasBuilder = new BlobSasBuilder(sasPermissions, utcNow.AddHours(validityInHours)) { BlobContainerName = blobClient.BlobContainerName, BlobName = blobClient.Name, Resource = "b", StartsOn = utcNow, }; // Add the SAS token to the blob URI var uriBuilder = new BlobUriBuilder(blobClient.Uri) { // Specify the user delegation key Sas = sasBuilder.ToSasQueryParameters( userDelegationKey.OriginalUserDelegationKey, blobServiceClient.AccountName) }; var sasUri = uriBuilder.ToUri();
You can see the full example in GitHub. I had to abstract things away via an interface so that both examples become easily swappable strategies.
Generate a user delegation SAS: the DIY way
This time, let's approach it differently by implementing it from scratch instead of relying on a client library. If that's too much code in a blog post for your taste, you can follow along by opening the full example on GitHub.
-
Obtain an access token for the service principal:
var tokenEndpoint = $"https://login.microsoftonline.com/{_tenantId}/oauth2/v2.0/token"; using var aadTokenRequest = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint); var form = new Dictionary<string, string> { {"grant_type", "client_credentials"}, {"client_id", _clientId}, {"client_secret", _clientSecret}, {"scope", "https://storage.azure.com/.default"} }; aadTokenRequest.Content = new FormUrlEncodedContent(form); var response = await httpClient.SendAsync(aadTokenRequest, cancellationToken); using var jsonContent = await response.Content.ReadAsStreamAsync(cancellationToken); var parsedJson = await JsonDocument.ParseAsync(jsonContent, cancellationToken: cancellationToken); var accessToken = parsedJson.RootElement.GetProperty("access_token").GetString();
-
Get a user delegation key for the service principal:
Here the code starts to look a bit messy, so let me provide some context. I use the access key that was just obtained to invoke Get User Delegation Key. The XML payload specifies the period of validity for the delegation key.var delegationUriBuilder = new UriBuilder(_storageServiceUri) { Scheme = Uri.UriSchemeHttps, Port = -1, // default port for scheme Query = "restype=service&comp=userdelegationkey&timeout=30" }; using var delegationKeyRequest = new HttpRequestMessage(HttpMethod.Post, delegationUriBuilder.Uri.ToString()); delegationKeyRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", authToken); delegationKeyRequest.Headers.Add("x-ms-version", "2023-01-03"); var startTime = DateTimeOffset.UtcNow; var endTime = startTime.AddHours(hours); var payload = $"<KeyInfo><Start>{startTime.ToString("s")}Z</Start><Expiry>{endTime.ToString("s")}Z</Expiry></KeyInfo>"; var payloadContent = new StringContent(payload, Encoding.UTF8, "application/xml"); delegationKeyRequest.Content = payloadContent; using var delegationKeyResponse = await httpClient.SendAsync(delegationKeyRequest, cancellationToken); var xmlResponse = await delegationKeyResponse.Content.ReadAsStringAsync(cancellationToken); var userDelegationKey = xmlResponse.FromXml<UserDelegationKey>();
-
Construct a string to sign.
The string to sign contains the value of the fields from the SAS token, separated by the newline character\n
. The order of the fields is predefined and you should strictly follow it. If you wonder what the sequences of repeating\n
characters are for, these represent empty placeholders for several fields that I did not specify any values.var storageAccountEndpointUri = new Uri(_storageServiceUri); var storageAccountName = storageAccountEndpointUri.Host.Split('.').FirstOrDefault(); var startTime = DateTimeOffset.UtcNow; var endTime = startTime.AddHours(validityInHours); var start = startTime.ToString("s") + 'Z'; var end = endTime.ToString("s") + 'Z'; var pathCleansed = $"{blobContainerName}/{blobName}".Replace("//", "/"); var stringToSign = "r" + "\n" + start + "\n" + end + "\n" + $"/blob/{storageAccountName}/{pathCleansed}".Replace("//", "/") + "\n" + userDelegationKey.SignedObjectId + "\n" + userDelegationKey.SignedTenantId + "\n" + userDelegationKey.SignedStartsOn.ToString("s") + 'Z' + "\n" + userDelegationKey.SignedExpiresOn.ToString("s") + 'Z' + "\n" + userDelegationKey.SignedService + "\n" + userDelegationKey.SignedVersion + "\n" + "\n\n\n\n" + "https" + "\n" + "2023-01-03" + "\n" + "b" + "\n" + "\n\n\n\n\n\n";
-
Compute an HMAC-SHA256 over the string-to-sing using the user delegation key.
This might seem a bit complex, so let's break it down. We start with thestringToSign
, which includes all the fields of our future SAS token. HMAC-SHA256, or Hash-based Message Authentication Code, is the algorithm we use. It employs a cryptographic hash function, SHA-256, and a key to generate a message authentication code (MAC). In our case, the key is the user delegation key that we obtained from Azure Storage.The output, or MAC, is essentially a hash that can't be reversed to reveal the original value. When Azure Storage receives a signature, it replicates the same steps you took to compute the signature. If the incoming signature matches the one Azure Storage computed, the SAS token is considered valid and trustworthy.
For convenience, I implemented a simple extension method that does just this.
string signature = stringToSign.ComputeHMACSHA256(userDelegationKey.Value); string signatureUrlEncoded = HttpUtility.UrlEncode(signature);
-
Assemble the final SAS URL:
var query = string.Join("&", "sp=r", $"st={start}", $"se={end}", $"skoid={userDelegationKey.SignedObjectId}", $"sktid={userDelegationKey.SignedTenantId}", $"skt={userDelegationKey.SignedStartsOn.ToString("s") + 'Z'}", $"ske={userDelegationKey.SignedExpiresOn.ToString("s") + 'Z'}", "sks=b", $"skv={userDelegationKey.SignedVersion}", "spr=https", $"sv={userDelegationKey.SignedVersion}", "sr=b", $"sig={signatureUrlEncoded}" ); var sasBuilder = new UriBuilder(_storageServiceUri) { Scheme = Uri.UriSchemeHttps, Port = -1, // default port for scheme Path = pathCleansed, Query = query }; var sasUrl = sasBuilder.Uri;
If User Delegation SAS seemed complex before, I hope this blog post has made it a bit less mysterious.