How to Use Azure Communication Services Email API with Cloudflare Workers
If you’ve tried to send emails from Cloudflare Workers using Azure Communication Services (ACS) and received a frustrating 401 "Denied by the resource provider" error, you’re not alone. This guide will show you exactly how to fix it.
- The Problem
- The SolutionKey Insights Complete Working CodeCommon Pitfalls and Fixes
- 1. Content Hash in Hex Format
- 2. Trailing Slash in Endpoint
- 3. Access Key Not Decoded
- 4. Missing Host Header Testing Your ImplementationEnvironment VariablesBonus: Office 365 Anti-PhishingConclusionResources
The Problem
Azure Communication Services Email API requires HMAC-SHA256 authentication - it does NOT accept simple API key authentication like many other services. The accesskey in your connection string is not an API key; it’s a cryptographic key used to generate HMAC signatures.
When you try something like this:
// ❌ This will NOT work!
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"api-key": accessKey, // Wrong!
"Content-Type": "application/json"
},
body: JSON.stringify(emailPayload)
});
You’ll get:
{"error":{"code":"Denied","message":"Denied by the resource provider."}}
The Solution
You need to implement proper HMAC-SHA256 authentication using the Web Crypto API (since Node.js crypto module doesn’t work in Cloudflare Workers).
Key Insights
- The access key must be base64 decoded before using it for HMAC signing
- Content hash must be base64 encoded (not hex!)
- Watch for trailing slashes in your endpoint URL
- The string-to-sign format must be exact
Complete Working Code
Here’s the full implementation for a Cloudflare Pages Function:
/**
* Cloudflare Pages Function - Azure Communication Services Email
* with proper HMAC-SHA256 authentication
*/
// Helper: base64 decode
function base64ToArrayBuffer(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
}
// Helper: ArrayBuffer to base64
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
// Helper: SHA256 hash (returns base64)
async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message);
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
return arrayBufferToBase64(hashBuffer);
}
// Helper: HMAC-SHA256 signature (returns base64)
async function hmacSha256(key, message) {
const keyBuffer = base64ToArrayBuffer(key);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const msgBuffer = new TextEncoder().encode(message);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, msgBuffer);
return arrayBufferToBase64(signature);
}
export async function onRequestPost(context) {
const { request, env } = context;
try {
const body = await request.json();
const { name, email, subject, message } = body;
// Parse ACS connection string
const connectionString = env.ACS_CONNECTION_STRING;
const endpointRaw = connectionString.match(/endpoint=([^;]+)/)?.[1];
const accessKey = connectionString.match(/accesskey=([^;]+)/)?.[1];
if (!endpointRaw || !accessKey) {
throw new Error("Invalid ACS connection string format");
}
// IMPORTANT: Remove trailing slash to prevent double slash in URL
const endpoint = endpointRaw.replace(/\/$/, '');
const apiVersion = "2023-03-31";
const apiUrl = `${endpoint}/emails:send?api-version=${apiVersion}`;
const urlObj = new URL(apiUrl);
const host = urlObj.host;
const pathAndQuery = urlObj.pathname + urlObj.search;
// Prepare email payload
const emailPayload = {
senderAddress: env.ACS_SENDER_EMAIL,
content: {
subject: subject,
plainText: message,
html: `<p>${message}</p>`
},
recipients: {
to: [{ address: env.ACS_RECIPIENT_EMAIL }]
},
replyTo: [{ address: email }]
};
const emailBody = JSON.stringify(emailPayload);
// Generate HMAC-SHA256 authentication
const timestamp = new Date().toUTCString();
const contentHash = await sha256(emailBody);
// Create string to sign
// Format: METHOD\nPATH_AND_QUERY\nTIMESTAMP;HOST;CONTENT_HASH
const stringToSign = `POST\n${pathAndQuery}\n${timestamp};${host};${contentHash}`;
// Generate HMAC signature
const signature = await hmacSha256(accessKey, stringToSign);
// Build Authorization header
const authHeader = `HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=${signature}`;
// Send email via ACS
const acsResponse = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Host": host,
"x-ms-date": timestamp,
"x-ms-content-sha256": contentHash,
"Authorization": authHeader
},
body: emailBody
});
if (!acsResponse.ok) {
const errorDetails = await acsResponse.text();
return new Response(JSON.stringify({
error: "Email send failed",
status: acsResponse.status,
details: errorDetails
}), { status: 500 });
}
return new Response(JSON.stringify({
success: true,
message: "Email sent successfully"
}), { status: 200 });
} catch (err) {
return new Response(JSON.stringify({
error: "Internal server error",
details: err.message
}), { status: 500 });
}
}
Common Pitfalls and Fixes
1. Content Hash in Hex Format
Wrong:
// ❌ Hex format
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
Correct:
// ✅ Base64 format
return arrayBufferToBase64(hashBuffer);
2. Trailing Slash in Endpoint
Azure connection strings often end with a trailing slash:
endpoint=https://your-acs.communication.azure.com/;accesskey=...
This causes a double slash in your URL:
https://your-acs.communication.azure.com//emails:send ❌
Fix:
const endpoint = endpointRaw.replace(/\/$/, '');
3. Access Key Not Decoded
The access key is base64 encoded in the connection string. You must decode it before using it for HMAC:
// ✅ Decode the key first
const keyBuffer = base64ToArrayBuffer(accessKey);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBuffer, // Use decoded key
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
4. Missing Host Header
Even though fetch sets the Host header automatically, you should explicitly include it since you’re signing it:
headers: {
"Host": host, // Include explicitly
// ... other headers
}
Testing Your Implementation
Before deploying to Cloudflare, test with Node.js locally:
const crypto = require('crypto');
const connectionString = "your-connection-string";
const endpoint = connectionString.match(/endpoint=([^;]+)/)?.[1].replace(/\/$/, '');
const accessKey = connectionString.match(/accesskey=([^;]+)/)?.[1];
const emailPayload = {
senderAddress: "noreply@yourdomain.com",
recipients: { to: [{ address: "test@example.com" }] },
content: {
subject: "Test",
plainText: "Hello World"
}
};
const emailBody = JSON.stringify(emailPayload);
const timestamp = new Date().toUTCString();
const contentHash = crypto.createHash('sha256').update(emailBody).digest('base64');
const url = `${endpoint}/emails:send?api-version=2023-03-31`;
const urlObj = new URL(url);
const pathAndQuery = urlObj.pathname + urlObj.search;
const stringToSign = `POST\n${pathAndQuery}\n${timestamp};${urlObj.host};${contentHash}`;
const signature = crypto.createHmac('sha256', Buffer.from(accessKey, 'base64'))
.update(stringToSign)
.digest('base64');
console.log("Test with this curl command:");
console.log(`curl -X POST '${url}' \\
-H 'Content-Type: application/json' \\
-H 'Host: ${urlObj.host}' \\
-H 'x-ms-date: ${timestamp}' \\
-H 'x-ms-content-sha256: ${contentHash}' \\
-H 'Authorization: HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=${signature}' \\
-d '${emailBody}'`);
Environment Variables
Set these secrets in your Cloudflare Pages dashboard:
ACS_CONNECTION_STRING: Your full Azure Communication Services connection stringACS_SENDER_EMAIL: Your verified sender email (e.g.,noreply@yourdomain.com)ACS_RECIPIENT_EMAIL: Where contact form emails should be sent
Bonus: Office 365 Anti-Phishing
If your emails land in Office 365 quarantine with “Impersonation domain” detection, update your sender display name:
az rest --method PUT \
--url "/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Communication/emailServices/{name}/domains/{domain}/senderUsernames/{username}?api-version=2023-04-01" \
--body '{"properties":{"username":"noreply","displayName":"Your Company | Contact Form"}}'
Don’t use the domain name as the display name (e.g., “yourdomain.com”) - this triggers phishing detection.
Conclusion
Azure Communication Services is powerful but its authentication can be tricky, especially in serverless environments like Cloudflare Workers. The key points to remember:
- Use HMAC-SHA256, not simple API keys
- Base64 decode the access key
- Base64 encode the content hash
- Watch for trailing slashes
- Include all required headers
With this implementation, you’ll have reliable email sending from your Cloudflare Workers without the dreaded 401 errors.