Encrypted Tile Loading in Cesium and OpenLayers: Preventing Blob URL Data Exposure

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.createObjectURL in 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 Canvas for 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:

  • TileGrid parameters must match the tile server configuration exactly
  • The source class overrides tileLoadFunction internally, so external overrides are unnecessary
  • Adjust Blob content 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.createObjectURL rendering pathway completely removed
  • createImageBitmap path validated in target browsers
  • Canvas fallback verified in legacy environments
  • Cesium flipY orientation confirmed rendering tiles correctly
  • Network panel inspection confirms no blob: tile URLs exposed

Tags: webgis cesium OpenLayers tile-encryption imagebitmap

Posted on Mon, 07 Sep 2026 16:00:53 +0000 by php_blob