In modern social platforms, e-commerce apps, and community networks, user privacy controls are a foundational requirement. A common feature is allowing users to toggle their profile between Public and Private.
When a user switches their account to Private:
- Their uploaded avatar/profile photo should not be visible to strangers, search engines, or non-followers.
- Approved followers and the profile owner should still see the authentic high-resolution image.
- Non-followers should see a generic default avatar or a restricted placeholder.
- Cached CDN images and direct blob links must not leak the private image.
Storing images in Azure Blob Storage creates a unique architectural challenge: How do we enforce granular user-level authorization on static cloud media without exposing raw URLs or sacrificing CDN performance?
┌──────────────────────────────────────────────┐
│ Client Request Avatar │
└──────────────────────┬───────────────────────┘
│
▼
┌──────────────────────────────┐
│ Backend Authorization Check │
│ Is Profile Public? │
│ OR Requester is Follower? │
└──────────────┬───────────────┘
│
┌──────────────────────┴──────────────────────┐
│ │
[ YES: Allowed ] [ NO: Private ]
│ │
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ Generate Short-Lived │ │ Return Default Placeholder│
│ Azure SAS Token (15m) │ │ URL (/assets/avatar.svg) │
└─────────────┬─────────────┘ └───────────────────────────┘
│
▼
┌───────────────────────────┐
│ Azure Blob Storage (Read) │
└───────────────────────────┘
In this post, we will break down the 5 primary architectural strategies for managing public vs. private media in Azure Blob Storage, complete with step-by-step implementation code in Node.js and Python.
The Core Challenges of Static Cloud Media Privacy
Why can't you just toggle a boolean column in your database and call it a day?
- Direct URL Guessing / Enumeration: If your Azure Blob container is set to public read access (
BloborContaineraccess level), anyone who knows the URL (https://mystorage.blob.core.windows.net/avatars/user-1042.jpg) can download it indefinitely. - CDN & Browser Caching: If an avatar was previously public and cached by Cloudflare, Azure Front Door, or the user's browser, turning the profile private won't immediately stop the CDN edge from serving the cached file unless invalidated.
- Hotlinking and Leaking: Sharing a direct link in a group chat or forum bypasses your application's UI privacy checks.
- Scale & Performance: Routing every single profile picture through a Node.js or Python backend proxy can cause high bandwidth and CPU saturation at scale.
Method 1: Short-Lived Shared Access Signature (SAS) Tokens ⭐ (Recommended Standard)
The most robust and scalable pattern is keeping the Azure Blob container strictly Private (no anonymous public read) and generating Time-Limited User-Delegated or Account SAS URLs on demand.
How It Works
- The Azure Blob container
avatarshas its public access level set to Private (off). - When a client requests a user profile (e.g.,
GET /api/v1/users/:id/profile), the backend inspects the relationship between the Requester and the Target Profile Owner:- If
target.is_private === false➔ Authorized - If
requester.id === target.id(Profile Owner) ➔ Authorized - If requester is an approved follower (checked in DB/Redis) ➔ Authorized
- Otherwise ➔ Unauthorized (Stranger)
- If
- If Authorized: The backend generates an Azure Blob URL appended with a signed query string (
?sv=2021-06-08&st=...&se=...&sp=r&sig=...) that expires in 10–15 minutes. - If Unauthorized: The backend returns a static placeholder URL (e.g.,
https://cdn.mysite.com/placeholders/default-private-avatar.png).
┌────────┐ ┌──────────────────┐ ┌────────────────────┐
│ Client │ │ Backend API │ │ Azure Blob Storage │
└───┬────┘ └────────┬─────────┘ └─────────┬──────────┘
│ 1. GET /users/42/profile │ │
│─────────────────────────────►│ │
│ │ 2. Check DB: Is Profile Private? │
│ │ Is Requester a Follower? │
│ │ │
│ │ 3. If Authorized: Generate SAS │
│ │ Token (Expires in 15 min) │
│ 4. Response: { │ │
│ avatarUrl: "https://.. │ │
│ /user-42.jpg?sp=r&se.."│ │
│ } │ │
│◄─────────────────────────────│ │
│ │
│ 5. GET https://.../user-42.jpg?sp=r&se=... │
│─────────────────────────────────────────────────────────────────►│
│ 6. Validates cryptographic signature & expiry │
│ 7. Returns Avatar Image Stream │
│◄─────────────────────────────────────────────────────────────────│
Node.js Implementation: SAS Generation Service
// services/azureBlobService.ts
import {
BlobServiceClient,
StorageSharedKeyCredential,
generateBlobSASQueryParameters,
BlobSASPermissions,
} from '@azure/storage-blob';
const ACCOUNT_NAME = process.env.AZURE_STORAGE_ACCOUNT_NAME!;
const ACCOUNT_KEY = process.env.AZURE_STORAGE_ACCOUNT_KEY!;
const CONTAINER_NAME = 'avatars';
const sharedKeyCredential = new StorageSharedKeyCredential(ACCOUNT_NAME, ACCOUNT_KEY);
const blobServiceClient = new BlobServiceClient(
`https://${ACCOUNT_NAME}.blob.core.windows.net`,
sharedKeyCredential
);
export function generateAvatarSasUrl(blobName: string, durationMinutes = 15): string {
const expiresOn = new Date();
expiresOn.setMinutes(expiresOn.getMinutes() + durationMinutes);
const sasToken = generateBlobSASQueryParameters(
{
containerName: CONTAINER_NAME,
blobName,
permissions: BlobSASPermissions.parse('r'), // Read-only access
startsOn: new Date(Date.now() - 60 * 1000), // Account for clock skew
expiresOn,
},
sharedKeyCredential
).toString();
return `https://${ACCOUNT_NAME}.blob.core.windows.net/${CONTAINER_NAME}/${blobName}?${sasToken}`;
}
Profile Retrieval Controller with Relationship Verification
// controllers/profileController.ts
import { Request, Response } from 'express';
import { generateAvatarSasUrl } from '../services/azureBlobService';
import { db } from '../db';
const DEFAULT_AVATAR_URL = 'https://cdn.mysite.com/assets/default-private-avatar.png';
export async function getUserProfile(req: Request, res: Response) {
const targetUserId = parseInt(req.params.userId, 10);
const requesterId = req.user?.id; // Extracted from Auth JWT (null if guest)
// 1. Fetch target user's privacy settings and avatar blob key
const targetUser = await db.user.findUnique({
where: { id: targetUserId },
select: {
id: true,
username: true,
isPrivate: true,
avatarBlobName: true,
},
});
if (!targetUser) {
return res.status(404).json({ error: 'User not found' });
}
// If user has no custom avatar uploaded
if (!targetUser.avatarBlobName) {
return res.json({
id: targetUser.id,
username: targetUser.username,
avatarUrl: DEFAULT_AVATAR_URL,
});
}
// 2. Determine Authorization
let isAuthorized = false;
if (!targetUser.isPrivate) {
// Case A: Public profile
isAuthorized = true;
} else if (requesterId && requesterId === targetUser.id) {
// Case B: Profile owner viewing their own profile
isAuthorized = true;
} else if (requesterId) {
// Case C: Check if requester is an approved active follower
const followRecord = await db.follower.findFirst({
where: {
userId: targetUser.id,
followerId: requesterId,
status: 'ACCEPTED',
},
});
if (followRecord) {
isAuthorized = true;
}
}
// 3. Return appropriate Avatar URL
const avatarUrl = isAuthorized
? generateAvatarSasUrl(targetUser.avatarBlobName, 15) // 15 min expiring URL
: DEFAULT_AVATAR_URL; // Anonymous / Non-follower placeholder
return res.json({
id: targetUser.id,
username: targetUser.username,
isPrivate: targetUser.isPrivate,
avatarUrl,
});
}
Method 2: Secure Backend Proxy / Direct Streaming
If you do not want to expose Azure Blob hostnames or query strings to your client apps at all, your backend can act as a secure proxy.
Architecture
The frontend requests GET /api/v1/users/42/avatar. The backend:
- Validates the requester's JWT and follower relationship.
- If authorized, streams the image chunks directly from Azure Blob SDK to the client response with strict cache headers:
Cache-Control: private, no-cache, max-age=0. - If unauthorized, pipes the fallback placeholder image or responds with
HTTP 403 Forbidden/HTTP 307 Redirectto the default avatar.
// routes/avatarProxy.ts
import { Router, Request, Response } from 'express';
import { blobServiceClient } from '../services/azureBlobService';
import { checkViewPermission } from '../services/privacyService';
const router = Router();
const containerClient = blobServiceClient.getContainerClient('avatars');
router.get('/users/:id/avatar', async (req: Request, res: Response) => {
const targetUserId = parseInt(req.params.id, 10);
const requesterId = req.user?.id;
const permission = await checkViewPermission(targetUserId, requesterId);
// Set privacy headers to prevent intermediate caching
res.setHeader('Cache-Control', 'private, no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
if (!permission.allowed) {
// Option A: Redirect to static default avatar
return res.redirect(307, '/assets/default-private-avatar.png');
}
try {
const blobClient = containerClient.getBlobClient(`user-${targetUserId}.jpg`);
const downloadResponse = await blobClient.download();
res.setHeader('Content-Type', downloadResponse.contentType || 'image/jpeg');
downloadResponse.readableStreamBody?.pipe(res);
} catch (err) {
return res.status(404).send('Avatar not found');
}
});
export default router;
Trade-offs
- ✅ Pros: Clean URLs (
/users/42/avatar), zero Azure configuration visible to clients, instant privacy changes. - ❌ Cons: Server bandwidth consumption. All image traffic passes through your backend servers.
Method 3: CDN with Edge Authorization (Azure Front Door / Cloudflare Workers)
For high-scale applications serving millions of avatar views per day, you can use a CDN edge worker (Cloudflare Worker, Fastly Compute@Edge, or Azure Front Door Rules Engine) to verify access tokens at the network edge.
Client ──► CDN Edge (Cloudflare / Front Door) ──► Check Auth Cookie / JWT
│
├── [Authorized] ──► Read Cache / Fetch from Azure Private Blob
└── [Denied] ──► Return Cached Default Avatar
- The client sends requests to
https://cdn.mysite.com/avatars/user-42.jpg. - The request carries the user's session JWT or signed authorization cookie.
- The Edge Worker decodes the token, verifies if the requester is authorized or if the cached privacy state is public.
- Images are cached at edge locations with granular cache tags (e.g.,
avatar-user-42). - When the user changes their privacy setting to private, the backend emits a CDN Purge Cache Tag API call to immediately evict cached images worldwide.
Method 4: Dual-Container Migration Pattern
Another architectural strategy is physically separating public and private blobs into two different Azure containers:
public-avatars: Container public access = Blob (Anonymous read enabled, connected to global CDN).private-avatars: Container public access = Private (No anonymous access, SAS required).
State Transition Workflow
┌────────────────────────────────────────────────────────┐
│ User toggles: is_private = TRUE │
└───────────────────────────┬────────────────────────────┘
│
▼
1. Copy Blob: public-avatars/user-42.jpg
──► private-avatars/user-42.jpg
│
▼
2. Delete original in public-avatars
│
▼
3. Purge CDN Cache for /public-avatars/user-42.jpg
│
▼
4. Update SQL Database (is_private = 1)
Trade-offs
- ✅ Pros: Ultra-fast public reads via standard CDN for 90% of users who have public profiles.
- ❌ Cons: Asynchronous blob copying overhead when switching privacy states; risk of race conditions during file movement.
Step-by-Step Production Implementation Guide
Here is the complete sequence of actions to implement Method 1 (SAS Tokens) in production:
Step 1: Database Schema
-- User and Privacy Table
CREATE TABLE users (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(100) NOT NULL UNIQUE,
is_private BOOLEAN NOT NULL DEFAULT FALSE,
avatar_blob_name VARCHAR(255) NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Follower Relationships
CREATE TABLE followers (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL, -- Target user being followed
follower_id INT NOT NULL, -- User sending follow request
status ENUM('PENDING', 'ACCEPTED', 'BLOCKED') NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_follower (user_id, follower_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE
);
Step 2: Configure Azure Blob Storage Permissions
- Open the Azure Portal or Azure CLI.
- Navigate to your Storage Account ➔ Containers.
- Create container
avatarsand set Public access level to Private (no anonymous access). - Configure CORS rules to allow
GETfrom your application domains.
az storage container create \
--account-name mystorageaccount \
--name avatars \
--public-access off
Step 3: Handle Cache Invalidation on Privacy Toggle
When a user switches their privacy setting, you must prevent stale cached responses on the client side:
// controllers/settingsController.ts
export async function toggleProfilePrivacy(req: Request, res: Response) {
const userId = req.user!.id;
const { isPrivate } = req.body;
// 1. Update database
await db.user.update({
where: { id: userId },
data: {
isPrivate,
// Optional: Update avatar revision to bust client-side caches
updatedAt: new Date()
},
});
// 2. Invalidate relationship caches in Redis (if using Redis for followers)
await redis.del(`user:${userId}:followers`);
await redis.del(`user:${userId}:privacy`);
return res.json({ message: `Profile privacy updated to ${isPrivate ? 'Private' : 'Public'}` });
}
Architecture Comparison Matrix
| Approach | Scalability | Inter inter-service Latency | Security Isolation | Complexity | CDN Friendly |
|---|---|---|---|---|---|
| 1. SAS Tokens | Highest | Low (SAS generated in ~1ms) | Airtight | Moderate | Partial (Short TTL) |
| 2. Backend Proxy | Low - Medium | High (Server bandwidth) | Airtight | Lowest | No |
| 3. CDN Edge Auth | Highest | Lowest (< 10ms at Edge) | High | High | Yes |
| 4. Dual Containers | High | Low | Moderate | High | Yes (for Public) |
Best Practices & Security Checklist
- Short SAS Expiry: Set avatar SAS token expiration to 10 to 15 minutes. Never issue multi-day SAS tokens for user avatars.
- Least Privilege Permissions: Always restrict SAS permissions strictly to
Read(sp=r). Never grantWriteorDeletepermissions in public-facing read links. - Use User Delegation SAS in Enterprise: Instead of using root Storage Account Keys (
StorageSharedKeyCredential), use Azure Entra ID (Managed Identity) to issue User Delegation SAS tokens. This eliminates hardcoded secret keys. - Prevent Client-Side Cache Poisoning: When serving private avatar SAS URLs, instruct browsers with
Cache-Control: private, max-age=900. - Default Placeholder Asset: Host your generic placeholder images on a public CDN edge so unauthorized requests take 0ms of backend compute.
Conclusion
Managing private vs. public profile images in Azure Blob Storage requires moving away from open public containers and adopting cryptographically signed access.
By pairing Private Azure Blob Containers with time-limited SAS tokens (Method 1) or Edge Authorization (Method 3), you can ensure complete privacy for your users, prevent data scraping, and maintain high performance and low server load.