DEV Community

Cover image for Building a Secure Local Video Player in Electron
Billel Messaadi
Billel Messaadi

Posted on

Building a Secure Local Video Player in Electron

Modern desktop apps often need to access local files securely while providing a polished UX. In this tutorial, we’ll build a YouTube‑style local video player in Electron that:

  • Scans a local folder for video files
  • Validates that files are real videos
  • Streams files via a custom app:// protocol
  • Generates thumbnails on the fly
  • Uses secure IPC with contextBridge and a strict CSP

Why a custom protocol?

Accessing local files directly from the renderer can be risky. A custom protocol lets us:

  • Serve files via app:// instead of file://.
  • Apply security checks (e.g., prevent directory traversal).
  • Enable proper streaming via Electron’s net.fetch with stream: true.

This pattern is aligned with Electron’s recommended security practices. See the official protocol guide for protocol.registerSchemesAsPrivileged and protocol.handle: https://www.electronjs.org/fr/docs/latest/api/protocol#protocolregisterschemesasprivilegedcustomschemes


Registering a privileged protocol

In main.js, we declare a custom app:// protocol with streaming and CSP support (per Electron protocol docs: https://www.electronjs.org/fr/docs/latest/api/protocol#protocolregisterschemesasprivilegedcustomschemes):

const { app, BrowserWindow, protocol, net } = require('electron');
const path = require('node:path');
const { pathToFileURL } = require('node:url');

protocol.registerSchemesAsPrivileged([
  {
    scheme: 'app',
    privileges: {
      standard: true,
      secure: true,
      supportFetchAPI: true,
      stream: true,        // enable video/audio streaming
      bypassCSP: true
    }
  }
]);
Enter fullscreen mode Exit fullscreen mode

When the app is ready, we handle app://video/... requests and stream files from ./video/ using net.fetch:

const VIDEO_FOLDER = path.join(__dirname, 'video');

app.whenReady().then(() => {
  protocol.handle('app', (req) => {
    const { host, pathname } = new URL(req.url);

    if (host === 'video') {
      const fileName = decodeURIComponent(pathname.substring(1));
      const videoPath = path.join(VIDEO_FOLDER, fileName);

      // Security: keep requests inside VIDEO_FOLDER
      const relativePath = path.relative(VIDEO_FOLDER, videoPath);
      const isSafe = relativePath && !relativePath.startsWith('..') && !path.isAbsolute(relativePath);
      if (!isSafe) {
        return new Response('Forbidden', { status: 403, headers: { 'content-type': 'text/plain' } });
      }

      return net.fetch(pathToFileURL(videoPath).toString());
    }

    return new Response('Not Found', { status: 404, headers: { 'content-type': 'text/plain' } });
  });
});
Enter fullscreen mode Exit fullscreen mode

Why this matters:

  • protocol.registerSchemesAsPrivileged must run before app.whenReady().
  • net.fetch supports range requests and streaming for smooth playback.
  • We explicitly block path traversal attempts.

Secure IPC with a preload bridge

We expose a minimal API surface from the main process to the renderer via contextBridge in preload.js (docs: https://www.electronjs.org/fr/docs/latest/api/context-bridge, https://www.electronjs.org/fr/docs/latest/api/ipc-main, https://www.electronjs.org/fr/docs/latest/api/ipc-renderer):

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  getVideoFiles: () => ipcRenderer.invoke('get-video-files')
});
Enter fullscreen mode Exit fullscreen mode

In BrowserWindow, we enable:

webPreferences: {
  preload: path.join(__dirname, 'preload.js'),
  contextIsolation: true,
  nodeIntegration: false
}
Enter fullscreen mode Exit fullscreen mode

This ensures the renderer can call window.electronAPI.getVideoFiles() without direct access to Node.js or the full IPC surface.


Validating and listing local videos

The main process enumerates the video/ folder, filters by extension, checks basic file stats, and performs a lightweight magic‑byte verification:

const { ipcMain } = require('electron');
const fs = require('node:fs').promises;
const { createReadStream } = require('node:fs');

const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv', '.m4v'];

ipcMain.handle('get-video-files', async () => {
  const files = await fs.readdir(VIDEO_FOLDER);
  const videoFiles = [];

  for (const file of files) {
    const filePath = path.join(VIDEO_FOLDER, file);
    const ext = path.extname(file).toLowerCase();
    if (!VIDEO_EXTENSIONS.includes(ext)) continue;

    const stats = await fs.stat(filePath);
    if (!stats.isFile() || stats.size <= 0) continue;

    const isValid = await verifyVideoFile(filePath, ext);
    if (isValid) {
      videoFiles.push({
        name: file,
        size: stats.size,
        path: file,
        url: `app://video/${encodeURIComponent(file)}`
      });
    }
  }

  return videoFiles;
});
Enter fullscreen mode Exit fullscreen mode

The validator reads magic bytes for common formats and falls back to extension checks:

async function verifyVideoFile(filePath) {
  return new Promise((resolve) => {
    const stream = createReadStream(filePath, { start: 0, end: 11 });
    const chunks = [];

    stream.on('data', (chunk) => chunks.push(chunk));
    stream.on('end', () => {
      const hex = Buffer.concat(chunks).toString('hex');
      if (hex.includes('66747970')) return resolve(true); // MP4/M4V/MOV
      if (hex.startsWith('1a45dfa3')) return resolve(true); // WebM/MKV
      if (hex.startsWith('52494646') && hex.includes('415649')) return resolve(true); // AVI
      return resolve(true); // fallback: trust extension
    });
    stream.on('error', () => resolve(false));
  });
}
Enter fullscreen mode Exit fullscreen mode

The renderer: playlist UI and thumbnails

The UI is a simple, modern layout in index.html with an info panel and a scrollable playlist. The CSP aligns with Electron’s security recommendations: https://www.electronjs.org/fr/docs/latest/tutorial/security

<meta http-equiv="Content-Security-Policy" content="default-src 'self' app:; style-src 'self' 'unsafe-inline'; script-src 'self'">
<video id="videoPlayer" controls></video>
<div id="videoList" class="loading">Loading videos...</div>
<script src="renderer.js"></script>
Enter fullscreen mode Exit fullscreen mode

renderer.js fetches the list of videos via the preload API and builds the playlist. It also generates a thumbnail per item by seeking to a specific timestamp in a hidden <video> element:

async function loadVideoFiles() {
  const videoFiles = await window.electronAPI.getVideoFiles();
  const videoList = document.getElementById('videoList');
  videoList.innerHTML = '';

  videoFiles.forEach((videoFile, index) => {
    const item = document.createElement('div');
    item.className = 'video-item';

    const thumbnail = createThumbnail(videoFile); // see below
    const details = document.createElement('div');
    details.className = 'video-details';

    const name = document.createElement('div');
    name.className = 'video-item-name';
    name.textContent = videoFile.name;

    details.appendChild(name);
    item.appendChild(thumbnail);
    item.appendChild(details);

    item.addEventListener('click', () => loadVideo(videoFile, item));
    videoList.appendChild(item);

    if (index === 0) loadVideo(videoFile, item);
  });
}
Enter fullscreen mode Exit fullscreen mode

Thumbnail generation uses the HTML5 video API:

function createThumbnail(videoFile) {
  const container = document.createElement('div');
  container.className = 'video-thumbnail';

  const thumbVideo = document.createElement('video');
  thumbVideo.src = videoFile.url;
  thumbVideo.muted = true;
  thumbVideo.preload = 'metadata';

  const durationOverlay = document.createElement('div');
  durationOverlay.className = 'video-duration';
  durationOverlay.textContent = '--:--';
  container.appendChild(durationOverlay);

  thumbVideo.addEventListener('loadedmetadata', () => {
    durationOverlay.textContent = formatDuration(thumbVideo.duration);
    thumbVideo.currentTime = Math.min(1, thumbVideo.duration * 0.1);
  });

  thumbVideo.addEventListener('seeked', () => {
    container.insertBefore(thumbVideo, durationOverlay);
  });

  return container;
}
Enter fullscreen mode Exit fullscreen mode

When a user selects an item, we load and autoplay the video, and update the info panel:

function loadVideo(videoFile, itemEl) {
  const video = document.getElementById('videoPlayer');
  video.src = videoFile.url;     // app://video/<file>
  video.load();
  video.play().catch(() => {});
}
Enter fullscreen mode Exit fullscreen mode

Security checklist


Running the app

  1. Place videos in the video/ folder.
  2. Start the app:
   npm install
   npm start
Enter fullscreen mode Exit fullscreen mode

Conclusion

This project demonstrates a practical pattern for serving local files safely in Electron using a privileged custom protocol, a secure preload bridge, and a clean renderer UI. The same approach works for audio players, document viewers, or any workflow that needs efficient local file access without compromising security.

Top comments (0)