WebGIS applications frequently employ tile encryption to protect geographic data. However, many development teams inadvertently introduce new security vulnerabilities when decrypting and rendering tiles in the browser:
Decrypted plaintext tiles appear as
blob:http://...URLs in browser DevTools Network panel, allowing users to view and download the unprotected image data.
This article presents a production-ready approach covering:
- Encrypted tile loading and rendering in Cesium (3D scenarios)
- Encrypted tile loading and rendering in OpenLayers (2D scenarios)
- Preventing plaintext exposure through
URL.createObjectURLin both libraries
The Core Principle
Instead of routing decrypted data through blobUrl -> img.src, convert the decrypted result directly to an ImageBitmap (or fallback to Canvas) and pass it to the map engine for rendering.
Background: Understanding Blob URL Leakage Risks
The intuitive approach many developers take involves creaitng a Blob URL:
// NOT RECOMMENDED: Increases blob resource exposure
const blob = new Blob([decryptedData], { type: 'image/png' });
const blobUrl = URL.createObjectURL(blob);
imageElement.src = blobUrl;
The issue with this pattern isn't functionality—it's visibility. Plaintext images become discoverable as clickable URL resources, providing a low-friction path for users with download intentions.
The secure approach eliminates Blob URLs entirely:
- Use
createImageBitmap(blob)to obtain a renderable bitmap object - Fall back to
Canvasfor compatibility scenarios - Never generate
blob:URLs that expose plaintext data
Cesium Implementation: Custom ImageryProvider
Cesium requires a custom ImageryProvider implementation that intercepts the requestImage method to handle decryption and rendering.
Key Challenge: Texture Coordinate FlipY
Cesium uses a different texture coordinate system than standard web images. Without correction, decrypted tiles render upside down. The createImageBitmap API with imageOrientation: 'flipY' handles this during decoding.
Complete Implementation
import { decodeTileBytes } from './crypto-utils.js';
const EncryptedTileProvider = (function() {
function EncryptedTileProvider(configuration) {
const BaseProvider = window.Cesium.UrlTemplateImageryProvider;
const providerInstance = new BaseProvider(configuration);
Object.setPrototypeOf(providerInstance, EncryptedTileProvider.prototype);
Object.setPrototypeOf(EncryptedTileProvider.prototype, BaseProvider.prototype);
return providerInstance;
}
EncryptedTileProvider.prototype.loadTile = function(xCoordinate, yCoordinate, zoomLevel, networkRequest) {
const projection = this.tilingScheme;
const tileBounds = projection.tileXYToNativeRectangle(xCoordinate, yCoordinate, zoomLevel);
const templateUrl = this._resource.getUrlComponent(true);
const resolvedUrl = templateUrl
.replace(/{westProjected}/g, tileBounds.west)
.replace(/{southProjected}/g, tileBounds.south)
.replace(/{eastProjected}/g, tileBounds.east)
.replace(/{northProjected}/g, tileBounds.north);
return new Promise((success, failure) => {
const httpRequest = new XMLHttpRequest();
httpRequest.open('GET', resolvedUrl, true);
httpRequest.responseType = 'arraybuffer';
httpRequest.onload = () => {
if (httpRequest.status !== 200) {
failure(new Error(`HTTP request failed with status: ${httpRequest.status}`));
return;
}
try {
const encryptedPayload = new Uint8Array(httpRequest.response);
const decryptedPayload = decodeTileBytes(encryptedPayload);
const imageContainer = new Blob([decryptedPayload], { type: 'image/png' });
if (window.createImageBitmap) {
createImageBitmap(imageContainer, { imageOrientation: 'flipY' })
.then(success)
.catch(failure);
return;
}
// Fallback path for legacy browsers
const imageObject = new Image();
imageObject.onload = () => {
const renderingCanvas = document.createElement('canvas');
const drawingContext = renderingCanvas.getContext('2d');
renderingCanvas.width = imageObject.width;
renderingCanvas.height = imageObject.height;
drawingContext.scale(1, -1);
drawingContext.drawImage(imageObject, 0, -renderingCanvas.height);
success(renderingCanvas);
};
imageObject.onerror = () => failure(new Error('Image decode failure'));
const fileReader = new FileReader();
fileReader.onload = (event) => { imageObject.src = event.target.result; };
fileReader.onerror = () => failure(new Error('FileReader operation failed'));
fileReader.readAsDataURL(imageContainer);
} catch (error) {
failure(error);
}
};
httpRequest.onerror = () => failure(new Error('Network connectivity error'));
httpRequest.send();
});
};
return EncryptedTileProvider;
})();
Integration with Pure Cesium Viewer
import {
Viewer,
ImageryLayer,
Rectangle,
WebMercatorProjection,
} from 'cesium';
import { EncryptedTileProvider } from './EncryptedTileProvider.js';
const mapViewer = new Viewer('cesiumContainer', {
baseLayer: false,
});
const encryptionProvider = new EncryptedTileProvider({
url: 'https://tiles.example.com/export?bbox={westProjected},{southProjected},{eastProjected},{northProjected}&dimensions=256,256&format=png&transparent=true&output=image&spatialRef=3857',
tilingScheme: new WebMercatorProjection(),
rectangle: Rectangle.fromDegrees(113.0, 22.0, 115.0, 24.0),
minimumLevel: 0,
maximumLevel: 18,
tileWidth: 256,
tileHeight: 256,
});
const encryptionLayer = new ImageryLayer(encryptionProvider, {
alpha: 1.0,
visible: true,
});
mapViewer.imageryLayers.add(encryptionLayer);
Critical considerations:
- URL template variables must align with the replacement logic in
loadTile - Coordinate system parameters must match the tile server configuration
- TMS-style services with
{reverseY}require template and coordinate adjustments
OpenLayers Implementation: Custom XYZ Source
OpenLayers allows extending the XYZ source class and ovreriding tileLoadFunction to intercept the tile loading lifecycle.
Complete Source Implementation
import XYZSource from 'ol/source/XYZ.js';
import TileState from 'ol/TileState.js';
import { decodeTileBytes } from './crypto-utils.js';
const EncryptedTileSource = class extends XYZSource {
constructor(configuration) {
const sourceConfig = { ...configuration };
sourceConfig.tileLoadFunction = processEncryptedTile;
super(sourceConfig);
}
};
function processEncryptedTile(tileInstance, tileUrl) {
const networkRequest = new XMLHttpRequest();
networkRequest.open('GET', tileUrl, true);
networkRequest.responseType = 'arraybuffer';
networkRequest.onload = function() {
if (networkRequest.status !== 200) {
tileInstance.setState(TileState.ERROR);
return;
}
try {
const encryptedPayload = new Uint8Array(networkRequest.response);
const decryptedPayload = decodeTileBytes(encryptedPayload);
const imageContainer = new Blob([decryptedPayload], { type: 'image/png' });
if (window.createImageBitmap) {
createImageBitmap(imageContainer)
.then((bitmap) => {
tileInstance.setImage(bitmap);
tileInstance.setState(TileState.LOADED);
})
.catch((error) => {
console.error('Bitmap creation failed:', error);
tileInstance.setState(TileState.ERROR);
});
return;
}
// Fallback rendering path
const renderingCanvas = document.createElement('canvas');
const drawingContext = renderingCanvas.getContext('2d');
const imageObject = new Image();
imageObject.onload = function() {
renderingCanvas.width = imageObject.width;
renderingCanvas.height = imageObject.height;
drawingContext.drawImage(imageObject, 0, 0);
tileInstance.setImage(renderingCanvas);
tileInstance.setState(TileState.LOADED);
};
imageObject.onerror = function() {
tileInstance.setState(TileState.ERROR);
};
const fileReader = new FileReader();
fileReader.onload = (event) => { imageObject.src = event.target.result; };
fileReader.readAsDataURL(imageContainer);
} catch (error) {
console.error('Tile decryption failed:', error);
tileInstance.setState(TileState.ERROR);
}
};
networkRequest.onerror = function() {
tileInstance.setState(TileState.ERROR);
};
networkRequest.send();
}
export { EncryptedTileSource };
Integration with OpenLayers Map
import TileLayer from 'ol/layer/Tile.js';
import TileGrid from 'ol/tilegrid/TileGrid.js';
import { EncryptedTileSource } from './EncryptedTileSource.js';
const mapProjection = 'EPSG:3857';
const zoomResolutions = [156543.033928, 78271.516964, 39135.758482, 19567.879241, 9783.9396205, 4891.96981025];
const gridOrigin = [-20037508.3427892, 20037508.3427892];
const serviceExtent = [-20037508.3427892, -20037508.3427892, 20037508.3427892, 20037508.3427892];
const tileGrid = new TileGrid({
tileSize: 256,
origin: gridOrigin,
resolutions: zoomResolutions,
extent: serviceExtent,
});
const encryptedSource = new EncryptedTileSource({
projection: mapProjection,
crossOrigin: 'anonymous',
url: 'https://tiles.example.com/{z}/{y}/{x}',
cacheSize: 1024,
tileGrid,
});
const encryptedLayer = new TileLayer({
source: encryptedSource,
minZoom: 0,
maxZoom: 18,
preload: 2,
zIndex: 10,
});
mapInstance.addLayer(encryptedLayer);
Key requirements:
TileGridparameters must match the tile server configuration exactly- The source class overrides
tileLoadFunctioninternally, so external overrides are unnecessary - Adjust
Blobcontent type if the server returns JPEG or other formats - Explicit
crossOrigin: 'anonymous'ensures stable cross-origin handling
Decryption Utility: Suitable Scenarios
The appropriate decryption strategy depends on the server-side encryption approach:
Byte-by-byte reversal applies when:
The server uses custom byte-stream encryption such as byte reordering, bit shifts, XOR operations, or cyclical key perturbation. Frontend code receives Uint8Array data and must reconstruct the original byte sequence.
Standard crypto applies when:
The server uses symmetric encryption algorithms like AES-CBC, AES-GCM, or DES. Use crypto.subtle.decrypt (Web Crypto API) or a library like crypto-js for reliable decryption.
Reference Decryption Skeleton
/**
* Decrypts encrypted tile binary data
* @param {Uint8Array} encryptedPayload - Encrypted tile data from server
* @returns {Uint8Array} - Decrypted tile data ready for rendering
*/
export function decodeTileBytes(encryptedPayload) {
const payloadLength = encryptedPayload.length;
const outputBuffer = new Uint8Array(payloadLength);
for (let index = 0; index < payloadLength; index++) {
// Apply server-specific decryption algorithm
// Byte reordering, bitwise XOR, or other transformations
outputBuffer[index] = processByte(encryptedPayload, index);
}
return outputBuffer;
}
function processByte(input, position) {
// Implementation-specific byte transformation
return input[position];
}
Implementation Checklist
-
URL.createObjectURLrendering pathway completely removed -
createImageBitmappath validated in target browsers - Canvas fallback verified in legacy environments
- Cesium
flipYorientation confirmed rendering tiles correctly - Network panel inspection confirms no
blob:tile URLs exposed