Building a Chrome Extension for Proxy Switching

Building a Chrome Extension for Proxy Switching

Switching proxies in Chrome normally means diving into system settings or using PAC files. A custom Chrome extension puts proxy switching in your toolbar — one click to switch between profiles, automatic proxy assignment per domain, and visual indicators showing your current proxy status.

This tutorial builds a complete proxy switching extension using Chrome’s Manifest V3 API.

Features

  • One-click proxy switching from toolbar popup
  • Multiple proxy profiles (home, work, country-specific)
  • Auto-switch rules by domain pattern
  • Current IP display in popup
  • Connection status indicator
  • Import/export proxy configurations
  • SOCKS5 and HTTP/HTTPS proxy support

Extension Structure

proxy-switcher/
├── manifest.json
├── background.js
├── popup/
│   ├── popup.html
│   ├── popup.css
│   └── popup.js
├── options/
│   ├── options.html
│   └── options.js
├── icons/
│   ├── icon16.png
│   ├── icon48.png
│   └── icon128.png
└── utils/
    └── proxy.js

Manifest

{
    "manifest_version": 3,
    "name": "Proxy Switcher Pro",
    "version": "1.0.0",
    "description": "Switch proxies with one click. Manage profiles and auto-switch rules.",
    "permissions": [
        "proxy",
        "storage",
        "webRequest",
        "tabs",
        "activeTab"
    ],
    "host_permissions": ["<all_urls>"],
    "background": {
        "service_worker": "background.js"
    },
    "action": {
        "default_popup": "popup/popup.html",
        "default_icon": {
            "16": "icons/icon16.png",
            "48": "icons/icon48.png",
            "128": "icons/icon128.png"
        }
    },
    "options_page": "options/options.html",
    "icons": {
        "16": "icons/icon16.png",
        "48": "icons/icon48.png",
        "128": "icons/icon128.png"
    }
}

Background Service Worker

The background script manages proxy configuration through Chrome’s proxy API.

// background.js

// Default profiles
const DEFAULT_PROFILES = {
    direct: {
        name: 'Direct (No Proxy)',
        mode: 'direct',
        color: '#4CAF50',
    },
};

// Apply proxy configuration
async function applyProxy(profile) {
    if (profile.mode === 'direct') {
        await chrome.proxy.settings.set({
            value: { mode: 'direct' },
            scope: 'regular',
        });
        updateBadge('OFF', '#4CAF50');
        return;
    }

    if (profile.mode === 'fixed_servers') {
        const config = {
            mode: 'fixed_servers',
            rules: {
                singleProxy: {
                    scheme: profile.scheme || 'http',
                    host: profile.host,
                    port: profile.port,
                },
                bypassList: profile.bypassList || [
                    'localhost',
                    '127.0.0.1',
                    '*.local',
                ],
            },
        };

        await chrome.proxy.settings.set({
            value: config,
            scope: 'regular',
        });

        updateBadge('ON', profile.color || '#2196F3');
    }

    if (profile.mode === 'pac_script') {
        await chrome.proxy.settings.set({
            value: {
                mode: 'pac_script',
                pacScript: { data: profile.pacScript },
            },
            scope: 'regular',
        });
        updateBadge('PAC', '#FF9800');
    }
}

function updateBadge(text, color) {
    chrome.action.setBadgeText({ text });
    chrome.action.setBadgeBackgroundColor({ color });
}

// Handle proxy authentication
chrome.webRequest.onAuthRequired.addListener(
    (details, callback) => {
        chrome.storage.local.get(['activeProfile', 'profiles'], (data) => {
            const profile = data.profiles?.[data.activeProfile];
            if (profile?.username && profile?.password) {
                callback({
                    authCredentials: {
                        username: profile.username,
                        password: profile.password,
                    },
                });
            } else {
                callback({});
            }
        });
    },
    { urls: ['<all_urls>'] },
    ['asyncBlocking']
);

// Auto-switch rules
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
    if (changeInfo.url) {
        const data = await chrome.storage.local.get(['rules', 'profiles']);
        const rules = data.rules || [];
        const profiles = data.profiles || {};

        for (const rule of rules) {
            if (matchesRule(changeInfo.url, rule.pattern)) {
                const profile = profiles[rule.profileId];
                if (profile) {
                    await applyProxy(profile);
                    break;
                }
            }
        }
    }
});

function matchesRule(url, pattern) {
    try {
        if (pattern.startsWith('/') && pattern.endsWith('/')) {
            const regex = new RegExp(pattern.slice(1, -1));
            return regex.test(url);
        }
        return url.includes(pattern);
    } catch {
        return false;
    }
}

// Listen for messages from popup
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (message.action === 'setProxy') {
        chrome.storage.local.get(['profiles'], async (data) => {
            const profile = data.profiles?.[message.profileId];
            if (profile) {
                await applyProxy(profile);
                await chrome.storage.local.set({
                    activeProfile: message.profileId,
                });
                sendResponse({ success: true });
            }
        });
        return true;
    }

    if (message.action === 'getStatus') {
        chrome.proxy.settings.get({ incognito: false }, (config) => {
            sendResponse({ config });
        });
        return true;
    }
});

// Initialize on install
chrome.runtime.onInstalled.addListener(async () => {
    const data = await chrome.storage.local.get(['profiles']);
    if (!data.profiles) {
        await chrome.storage.local.set({
            profiles: DEFAULT_PROFILES,
            activeProfile: 'direct',
            rules: [],
        });
    }
    updateBadge('OFF', '#4CAF50');
});

Popup Interface

<!-- popup/popup.html -->
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <link rel="stylesheet" href="popup.css">
</head>
<body>
    <div class="container">
        <h2>Proxy Switcher</h2>

        <div id="current-status" class="status-bar">
            <span id="status-text">Loading...</span>
            <span id="current-ip" class="ip-display"></span>
        </div>

        <div id="profiles-list" class="profiles"></div>

        <div class="actions">
            <button id="add-profile" class="btn btn-add">+ Add Profile</button>
            <button id="open-options" class="btn btn-settings">Settings</button>
        </div>

        <div id="add-form" class="form hidden">
            <input id="profile-name" placeholder="Profile Name" />
            <select id="profile-scheme">
                <option value="http">HTTP</option>
                <option value="https">HTTPS</option>
                <option value="socks5">SOCKS5</option>
                <option value="socks4">SOCKS4</option>
            </select>
            <input id="profile-host" placeholder="Host (e.g., proxy.example.com)" />
            <input id="profile-port" placeholder="Port (e.g., 8080)" type="number" />
            <input id="profile-user" placeholder="Username (optional)" />
            <input id="profile-pass" placeholder="Password (optional)" type="password" />
            <div class="form-actions">
                <button id="save-profile" class="btn btn-save">Save</button>
                <button id="cancel-add" class="btn btn-cancel">Cancel</button>
            </div>
        </div>
    </div>
    <script src="popup.js"></script>
</body>
</html>
/* popup/popup.css */
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
    width: 320px;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    background: #1a1a2e;
    color: #eee;
}

.container { padding: 16px; }
h2 { font-size: 16px; margin-bottom: 12px; color: #e94560; }

.status-bar {
    background: #16213e;
    padding: 10px 12px;
    border-radius: 6px;
    margin-bottom: 12px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-size: 13px;
}

.ip-display { color: #0f3460; font-family: monospace; font-size: 12px; }

.profile-item {
    background: #16213e;
    padding: 10px 12px;
    margin-bottom: 6px;
    border-radius: 6px;
    cursor: pointer;
    display: flex;
    justify-content: space-between;
    align-items: center;
    transition: background 0.2s;
    border-left: 3px solid transparent;
}

.profile-item:hover { background: #1a2744; }
.profile-item.active { border-left-color: #e94560; background: #1a2744; }
.profile-item .name { font-size: 13px; font-weight: 500; }
.profile-item .info { font-size: 11px; color: #888; }
.profile-item .delete { color: #666; cursor: pointer; padding: 2px 6px; }
.profile-item .delete:hover { color: #e94560; }

.btn {
    padding: 8px 14px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
    font-size: 12px;
    margin-right: 6px;
}

.btn-add { background: #e94560; color: white; }
.btn-settings { background: #333; color: #ccc; }
.btn-save { background: #4CAF50; color: white; }
.btn-cancel { background: #666; color: white; }

.actions { margin-top: 12px; }

.form {
    margin-top: 12px;
    background: #16213e;
    padding: 12px;
    border-radius: 6px;
}

.form input, .form select {
    width: 100%;
    padding: 8px;
    margin-bottom: 8px;
    background: #0f3460;
    border: 1px solid #333;
    color: #eee;
    border-radius: 4px;
    font-size: 12px;
}

.form-actions { display: flex; gap: 8px; }
.hidden { display: none; }
// popup/popup.js

document.addEventListener('DOMContentLoaded', async () => {
    await loadProfiles();
    await checkCurrentIP();

    document.getElementById('add-profile').addEventListener('click', () => {
        document.getElementById('add-form').classList.toggle('hidden');
    });

    document.getElementById('cancel-add').addEventListener('click', () => {
        document.getElementById('add-form').classList.add('hidden');
    });

    document.getElementById('save-profile').addEventListener('click', saveProfile);

    document.getElementById('open-options').addEventListener('click', () => {
        chrome.runtime.openOptionsPage();
    });
});

async function loadProfiles() {
    const data = await chrome.storage.local.get(['profiles', 'activeProfile']);
    const profiles = data.profiles || {};
    const active = data.activeProfile || 'direct';
    const container = document.getElementById('profiles-list');

    container.innerHTML = '';

    for (const [id, profile] of Object.entries(profiles)) {
        const div = document.createElement('div');
        div.className = `profile-item ${id === active ? 'active' : ''}`;
        div.innerHTML = `
            <div>
                <div class="name">${profile.name}</div>
                <div class="info">${profile.mode === 'direct' ? 'No proxy' : `${profile.scheme || 'http'}://${profile.host}:${profile.port}`}</div>
            </div>
            ${id !== 'direct' ? `<span class="delete" data-id="${id}">&times;</span>` : ''}
        `;

        div.addEventListener('click', (e) => {
            if (!e.target.classList.contains('delete')) {
                switchProxy(id);
            }
        });

        const deleteBtn = div.querySelector('.delete');
        if (deleteBtn) {
            deleteBtn.addEventListener('click', (e) => {
                e.stopPropagation();
                deleteProfile(id);
            });
        }

        container.appendChild(div);
    }
}

async function switchProxy(profileId) {
    chrome.runtime.sendMessage(
        { action: 'setProxy', profileId },
        async (response) => {
            if (response?.success) {
                await loadProfiles();
                setTimeout(checkCurrentIP, 1000);
            }
        }
    );
}

async function saveProfile() {
    const name = document.getElementById('profile-name').value;
    const scheme = document.getElementById('profile-scheme').value;
    const host = document.getElementById('profile-host').value;
    const port = parseInt(document.getElementById('profile-port').value);
    const username = document.getElementById('profile-user').value;
    const password = document.getElementById('profile-pass').value;

    if (!name || !host || !port) {
        alert('Name, host, and port are required');
        return;
    }

    const id = name.toLowerCase().replace(/\s+/g, '-');
    const data = await chrome.storage.local.get(['profiles']);
    const profiles = data.profiles || {};

    profiles[id] = {
        name,
        mode: 'fixed_servers',
        scheme,
        host,
        port,
        username: username || undefined,
        password: password || undefined,
        color: '#2196F3',
    };

    await chrome.storage.local.set({ profiles });
    document.getElementById('add-form').classList.add('hidden');
    await loadProfiles();
}

async function deleteProfile(id) {
    const data = await chrome.storage.local.get(['profiles', 'activeProfile']);
    delete data.profiles[id];

    if (data.activeProfile === id) {
        data.activeProfile = 'direct';
        chrome.runtime.sendMessage({ action: 'setProxy', profileId: 'direct' });
    }

    await chrome.storage.local.set({
        profiles: data.profiles,
        activeProfile: data.activeProfile,
    });
    await loadProfiles();
}

async function checkCurrentIP() {
    const statusText = document.getElementById('status-text');
    const ipDisplay = document.getElementById('current-ip');

    try {
        const response = await fetch('https://httpbin.org/ip');
        const data = await response.json();
        ipDisplay.textContent = data.origin;
        statusText.textContent = 'Connected';
        statusText.style.color = '#4CAF50';
    } catch {
        ipDisplay.textContent = '';
        statusText.textContent = 'Connection error';
        statusText.style.color = '#f44336';
    }
}

Auto-Switch Rules

Configure rules in the options page to automatically switch proxies based on the URL pattern:

// options/options.js

async function addRule() {
    const pattern = document.getElementById('rule-pattern').value;
    const profileId = document.getElementById('rule-profile').value;

    if (!pattern || !profileId) return;

    const data = await chrome.storage.local.get(['rules']);
    const rules = data.rules || [];
    rules.push({ pattern, profileId, enabled: true });
    await chrome.storage.local.set({ rules });
    loadRules();
}

// Example rules:
// Pattern: "amazon.com" -> Profile: "us-proxy"
// Pattern: "/\.jp$/" -> Profile: "japan-proxy"
// Pattern: "netflix.com" -> Profile: "uk-proxy"

Loading the Extension

  1. Open chrome://extensions/
  2. Enable “Developer mode”
  3. Click “Load unpacked” and select the proxy-switcher/ directory
  4. The extension icon appears in your toolbar

Internal Links

FAQ

Does this extension work with Manifest V3?

Yes. This extension is built for Manifest V3, which is required for all new Chrome extensions. The proxy API, storage API, and webRequest API all work with Manifest V3’s service worker architecture.

Can the extension handle SOCKS5 proxies?

Yes. Chrome’s proxy API supports HTTP, HTTPS, SOCKS4, and SOCKS5 proxy protocols. Set the scheme field to “socks5” in the profile configuration. The extension passes the scheme to chrome.proxy.settings.

How do auto-switch rules work?

When you navigate to a URL, the background service worker checks it against your rules in order. The first matching rule applies its proxy profile. Rules support plain string matching (the URL contains the pattern) or regex patterns wrapped in forward slashes.

Will proxy credentials be stored securely?

Chrome’s storage.local is sandboxed per extension but not encrypted. For sensitive credentials, consider using Chrome’s storage.session (cleared when the browser closes) or prompting for credentials each session. Never sync credentials with storage.sync.

Can I use PAC scripts for advanced routing?

Yes. The extension supports PAC (Proxy Auto-Configuration) scripts through Chrome’s pac_script mode. Write a PAC script that returns different proxies based on URL patterns, and the extension will apply it. PAC scripts offer the most flexible routing but are harder to debug.


Related Reading

Scroll to Top