How to Use Azure Communication Services Email API with Cloudflare Workers

Azure Communication Services Email API authentication with Cloudflare Workers using HMAC-SHA256 - tutorial diagram showing integration between Azure cloud and Cloudflare edge computing

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

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

  1. The access key must be base64 decoded before using it for HMAC signing
  2. Content hash must be base64 encoded (not hex!)
  3. Watch for trailing slashes in your endpoint URL
  4. 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: "[email protected]",
    recipients: { to: [{ address: "[email protected]" }] },
    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 string
  • ACS_SENDER_EMAIL: Your verified sender email (e.g., [email protected])
  • 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:

  1. Use HMAC-SHA256, not simple API keys
  2. Base64 decode the access key
  3. Base64 encode the content hash
  4. Watch for trailing slashes
  5. Include all required headers

With this implementation, you’ll have reliable email sending from your Cloudflare Workers without the dreaded 401 errors.


Resources

0 0 votes
Article Rating
Subscribe
Notify of
guest
0 Comments
Oldest
Newest
Inline Feedbacks
View all comments
0
Would love your thoughts, please comment.x
()
x