Chrome Extension Enterprise Policies — Best Practices

29 min read

Enterprise Policy Patterns for Chrome Extensions

Enterprise environments impose strict requirements on Chrome extensions: managed configuration, forced installation, policy-driven feature flags, and compliance logging. This guide covers eight patterns for building extensions that integrate cleanly with Google Admin Console and enterprise policy infrastructure.

Cross-references: Security Hardening Management Permission

1. Detecting Managed/Enterprise Environment {#1-detecting-managedenterprise-environment}

Determine at runtime whether your extension is running in a managed (enterprise) context so you can adjust behavior accordingly.

Explanation: Chrome exposes chrome.storage.managed for enterprise-configured values. If the storage area is empty or inaccessible, the extension is running in an unmanaged environment. You can also check chrome.runtime.id against force-installed extension behavior or inspect the install type via chrome.management.getSelf().

// detect-managed.ts
interface ManagedEnvironment {
  isManaged: boolean;
  installType: chrome.management.ExtensionInstallType;
  policies: Record<string, unknown>;
}

async function detectManagedEnvironment(): Promise<ManagedEnvironment> {
  // Check install type first
  const self = await chrome.management.getSelf();
  const isForceInstalled =
    self.installType === "admin" || self.installType === "sideload";

  // Attempt to read managed storage
  let policies: Record<string, unknown> = {};
  let hasManagedStorage = false;

  try {
    policies = await chrome.storage.managed.get(null);
    hasManagedStorage = Object.keys(policies).length > 0;
  } catch {
    // chrome.storage.managed throws if no managed schema exists
    // or if the extension is not in a managed environment
    hasManagedStorage = false;
  }

  return {
    isManaged: isForceInstalled || hasManagedStorage,
    installType: self.installType,
    policies,
  };
}

// Usage in service worker
chrome.runtime.onInstalled.addListener(async () => {
  const env = await detectManagedEnvironment();
  if (env.isManaged) {
    console.log("Running in managed environment:", env.policies);
    await applyEnterprisePolicies(env.policies);
  }
});

Gotchas:


2. Reading Enterprise Policies with chrome.storage.managed {#2-reading-enterprise-policies-with-chromestoragemanaged}

Read and react to administrator-configured policies stored in Chrome’s managed storage area.

Explanation: Managed storage is a read-only storage area populated by IT administrators via Google Admin Console or Windows Group Policy. Your extension defines expected keys and types in a managed schema file, and administrators set the values. The extension reads them like any other storage area but cannot write to it.

// managed-storage.ts
interface ExtensionPolicies {
  serverUrl: string;
  enableAnalytics: boolean;
  maxCacheSize: number;
  allowedDomains: string[];
  proxyConfig?: {
    host: string;
    port: number;
    auth: boolean;
  };
}

const POLICY_DEFAULTS: ExtensionPolicies = {
  serverUrl: "https://api.default.example.com",
  enableAnalytics: true,
  maxCacheSize: 50,
  allowedDomains: [],
};

async function readPolicies(): Promise<ExtensionPolicies> {
  try {
    const managed = await chrome.storage.managed.get(null);
    return { ...POLICY_DEFAULTS, ...managed } as ExtensionPolicies;
  } catch {
    // No managed storage available; use defaults
    return POLICY_DEFAULTS;
  }
}

/**
 * Read a single policy with type safety.
 */
async function getPolicy<K extends keyof ExtensionPolicies>(
  key: K
): Promise<ExtensionPolicies[K]> {
  try {
    const result = await chrome.storage.managed.get(key);
    return (result[key] ?? POLICY_DEFAULTS[key]) as ExtensionPolicies[K];
  } catch {
    return POLICY_DEFAULTS[key];
  }
}

// Usage
const serverUrl = await getPolicy("serverUrl");
const allowedDomains = await getPolicy("allowedDomains");

Gotchas:


3. Schema for Managed Storage (managed_schema.json) {#3-schema-for-managed-storage-managed-schemajson}

Define the structure of your managed storage so Google Admin Console and Chrome know what policies your extension accepts.

Explanation: The managed_schema.json file (referenced from manifest.json under storage.managed_schema) uses JSON Schema format to declare every policy key, its type, description, and optional default. Chrome validates incoming policy values against this schema and ignores non-conforming values.

// This is the manifest.json entry pointing to the schema:
// {
//   "storage": {
//     "managed_schema": "managed_schema.json"
//   }
// }

// managed_schema.json (not TypeScript, but shown here for completeness)
const managedSchema = {
  type: "object" as const,
  properties: {
    serverUrl: {
      title: "API Server URL",
      description: "The base URL for the backend API server.",
      type: "string",
      default: "https://api.default.example.com",
    },
    enableAnalytics: {
      title: "Enable Analytics",
      description: "Whether to send anonymous usage analytics.",
      type: "boolean",
      default: true,
    },
    maxCacheSize: {
      title: "Maximum Cache Size (MB)",
      description: "Maximum local cache size in megabytes.",
      type: "integer",
      minimum: 10,
      maximum: 500,
      default: 50,
    },
    allowedDomains: {
      title: "Allowed Domains",
      description: "List of domains where the extension is active.",
      type: "array",
      items: { type: "string" },
      default: [],
    },
    proxyConfig: {
      title: "Proxy Configuration",
      description: "Optional proxy server configuration.",
      type: "object",
      properties: {
        host: { type: "string" },
        port: { type: "integer", minimum: 1, maximum: 65535 },
        auth: { type: "boolean", default: false },
      },
      required: ["host", "port"],
    },
  },
};

/**
 * Validate policies at runtime against expected types.
 * Useful as a safety net even though Chrome validates the schema.
 */
function validatePolicies(
  policies: Record<string, unknown>
): string[] {
  const errors: string[] = [];

  if (policies.serverUrl && typeof policies.serverUrl !== "string") {
    errors.push("serverUrl must be a string");
  }
  if (
    policies.maxCacheSize &&
    (typeof policies.maxCacheSize !== "number" ||
      policies.maxCacheSize < 10 ||
      policies.maxCacheSize > 500)
  ) {
    errors.push("maxCacheSize must be an integer between 10 and 500");
  }
  if (
    policies.allowedDomains &&
    !Array.isArray(policies.allowedDomains)
  ) {
    errors.push("allowedDomains must be an array of strings");
  }

  return errors;
}

Gotchas:


4. Force-Installed Extension Behavior {#4-force-installed-extension-behavior}

Handle the unique constraints and capabilities of extensions that are force-installed by enterprise policy.

Explanation: Force-installed extensions (install type "admin") cannot be uninstalled by the user, may have elevated permissions granted by policy, and should behave more conservatively (no onboarding flows, no review prompts). They may also receive different update schedules controlled by the organization.

// force-install.ts
interface ForceInstallConfig {
  skipOnboarding: boolean;
  hideUninstallOption: boolean;
  suppressRatingPrompts: boolean;
  respectPolicyPermissions: boolean;
}

async function getForceInstallConfig(): Promise<ForceInstallConfig> {
  const self = await chrome.management.getSelf();
  const isForceInstalled = self.installType === "admin";

  return {
    skipOnboarding: isForceInstalled,
    hideUninstallOption: isForceInstalled,
    suppressRatingPrompts: isForceInstalled,
    respectPolicyPermissions: isForceInstalled,
  };
}

/**
 * Adapt the extension's first-run experience based on install type.
 */
async function handleFirstRun(): Promise<void> {
  const config = await getForceInstallConfig();

  if (config.skipOnboarding) {
    // Force-installed: apply defaults silently, log to admin console
    const policies = await chrome.storage.managed.get(null);
    await chrome.storage.local.set({
      setupComplete: true,
      configSource: "enterprise-policy",
      ...policies,
    });
    console.log("Enterprise setup completed automatically");
    return;
  }

  // User-installed: show onboarding flow
  await chrome.tabs.create({
    url: chrome.runtime.getURL("onboarding.html"),
  });
}

/**
 * Check if specific permissions were granted by policy.
 * Policy-granted permissions cannot be revoked by the user.
 */
async function checkPolicyPermissions(): Promise<{
  granted: string[];
  revocable: boolean;
}> {
  const permissions = await chrome.permissions.getAll();
  const self = await chrome.management.getSelf();

  return {
    granted: permissions.permissions ?? [],
    // Policy-granted permissions on force-installed extensions
    // are not revocable by the user
    revocable: self.installType !== "admin",
  };
}

Gotchas:


5. Policy-Based Feature Flags {#5-policy-based-feature-flags}

Use managed storage policies to enable or disable features, allowing IT administrators to customize extension behavior per organization.

Explanation: Feature flags via managed storage give admins granular control without requiring extension updates. Define each feature as a boolean or enum policy, read the values at startup, and gate functionality accordingly. Cache the resolved flags in local storage for fast synchronous access in content scripts.

// feature-flags.ts
interface FeatureFlags {
  enableScreenCapture: boolean;
  enableClipboardAccess: boolean;
  enableExternalSharing: boolean;
  dataSyncMode: "off" | "local" | "cloud";
  uiTheme: "system" | "light" | "dark";
  maxFileUploadMB: number;
}

const DEFAULT_FLAGS: FeatureFlags = {
  enableScreenCapture: false,
  enableClipboardAccess: true,
  enableExternalSharing: true,
  dataSyncMode: "cloud",
  uiTheme: "system",
  maxFileUploadMB: 25,
};

class PolicyFeatureFlags {
  private flags: FeatureFlags = { ...DEFAULT_FLAGS };
  private listeners: Set<(flags: FeatureFlags) => void> = new Set();

  async initialize(): Promise<void> {
    await this.loadFlags();

    // Listen for policy changes pushed by admin
    chrome.storage.managed.onChanged.addListener((changes) => {
      this.applyChanges(changes);
    });
  }

  private async loadFlags(): Promise<void> {
    try {
      const policies = await chrome.storage.managed.get(null);
      this.flags = { ...DEFAULT_FLAGS, ...policies } as FeatureFlags;
    } catch {
      this.flags = { ...DEFAULT_FLAGS };
    }

    // Cache in local storage for content script access
    await chrome.storage.local.set({ featureFlags: this.flags });
    this.notifyListeners();
  }

  private applyChanges(
    changes: Record<string, chrome.storage.StorageChange>
  ): void {
    for (const [key, { newValue }] of Object.entries(changes)) {
      if (key in this.flags) {
        (this.flags as Record<string, unknown>)[key] = newValue;
      }
    }
    chrome.storage.local.set({ featureFlags: this.flags });
    this.notifyListeners();
  }

  isEnabled(flag: keyof FeatureFlags): boolean {
    const value = this.flags[flag];
    if (typeof value === "boolean") return value;
    if (typeof value === "string") return value !== "off";
    return true;
  }

  get<K extends keyof FeatureFlags>(key: K): FeatureFlags[K] {
    return this.flags[key];
  }

  onChange(listener: (flags: FeatureFlags) => void): () => void {
    this.listeners.add(listener);
    return () => this.listeners.delete(listener);
  }

  private notifyListeners(): void {
    for (const listener of this.listeners) {
      listener({ ...this.flags });
    }
  }
}

// Singleton
export const featureFlags = new PolicyFeatureFlags();

Gotchas:


6. Enterprise Extension Deployment via Google Admin Console {#6-enterprise-extension-deployment-via-google-admin-console}

Structure your extension for deployment through Google Workspace admin controls.

Explanation: Google Admin Console allows IT administrators to force-install, pin, or block extensions for their organization. Your extension must be published to the Chrome Web Store (public, unlisted, or private). The admin configures the extension ID, update URL, and managed policies through the Admin Console or a JSON policy file.

// deployment-config.ts

/**
 * Generate the policy JSON template that IT admins paste into
 * Google Admin Console under Apps > Chrome > Apps & Extensions.
 *
 * This is documentation/tooling, not runtime code.
 */
function generatePolicyTemplate(extensionId: string): object {
  return {
    [extensionId]: {
      installation_mode: "force_installed",
      update_url:
        "https://clients2.google.com/service/update2/crx",
      policy: {
        serverUrl: "https://api.corp.example.com",
        enableAnalytics: false,
        maxCacheSize: 100,
        allowedDomains: ["*.corp.example.com", "*.internal.example.com"],
        proxyConfig: {
          host: "proxy.corp.example.com",
          port: 8080,
          auth: true,
        },
      },
    },
  };
}

/**
 * Self-report deployment information for admin dashboards.
 */
async function reportDeploymentInfo(): Promise<{
  extensionId: string;
  version: string;
  installType: string;
  platform: string;
  chromeVersion: string;
}> {
  const manifest = chrome.runtime.getManifest();
  const self = await chrome.management.getSelf();
  const platformInfo = await chrome.runtime.getPlatformInfo();

  const info = {
    extensionId: chrome.runtime.id,
    version: manifest.version,
    installType: self.installType,
    platform: `${platformInfo.os}_${platformInfo.arch}`,
    chromeVersion: navigator.userAgent.match(
      /Chrome\/(\d+\.\d+\.\d+\.\d+)/
    )?.[1] ?? "unknown",
  };

  return info;
}

/**
 * Verify the extension is running from the expected source.
 * Prevents sideloaded copies from connecting to corporate APIs.
 */
function verifyDeploymentIntegrity(): boolean {
  const expectedId = "abcdefghijklmnopqrstuvwxyz012345"; // your CWS ID
  const currentId = chrome.runtime.id;

  if (currentId !== expectedId) {
    console.error(
      "Extension is not running from the expected source.",
      `Expected: ${expectedId}, Got: ${currentId}`
    );
    return false;
  }
  return true;
}

Gotchas:


7. Reporting and Compliance Logging {#7-reporting-and-compliance-logging}

Log extension activity for enterprise audit trails and compliance monitoring.

Explanation: Enterprise environments require detailed audit logs: who did what, when, and what policies were in effect. Your extension should log significant actions to an enterprise logging endpoint. The logging infrastructure must be resilient (queue and retry on network failure) and respectful of privacy policies.

// compliance-logger.ts
interface AuditEntry {
  timestamp: string;
  action: string;
  userId?: string;
  details: Record<string, unknown>;
  policySnapshot: Record<string, unknown>;
  extensionVersion: string;
}

class ComplianceLogger {
  private queue: AuditEntry[] = [];
  private flushInterval: ReturnType<typeof setInterval> | null = null;
  private endpoint: string = "";

  async initialize(): Promise<void> {
    try {
      const policies = await chrome.storage.managed.get([
        "serverUrl",
        "enableAnalytics",
      ]);
      this.endpoint = `${policies.serverUrl}/api/v1/audit`;
    } catch {
      // No managed storage; logging is disabled
      return;
    }

    // Restore any queued entries from previous session
    const stored = await chrome.storage.local.get("auditQueue");
    if (stored.auditQueue) {
      this.queue = stored.auditQueue;
    }

    // Flush every 30 seconds
    this.flushInterval = setInterval(() => this.flush(), 30_000);
  }

  async log(action: string, details: Record<string, unknown>): Promise<void> {
    if (!this.endpoint) return;

    let policySnapshot: Record<string, unknown> = {};
    try {
      policySnapshot = await chrome.storage.managed.get(null);
    } catch {
      // continue without policy snapshot
    }

    const entry: AuditEntry = {
      timestamp: new Date().toISOString(),
      action,
      details,
      policySnapshot,
      extensionVersion: chrome.runtime.getManifest().version,
    };

    this.queue.push(entry);

    // Persist queue to survive service worker termination
    await chrome.storage.local.set({ auditQueue: this.queue });

    // Flush immediately if queue is large
    if (this.queue.length >= 50) {
      await this.flush();
    }
  }

  private async flush(): Promise<void> {
    if (this.queue.length === 0 || !this.endpoint) return;

    const batch = [...this.queue];
    this.queue = [];

    try {
      const response = await fetch(this.endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ entries: batch }),
      });

      if (!response.ok) {
        // Put entries back in queue for retry
        this.queue = [...batch, ...this.queue];
      }
    } catch {
      // Network error; restore queue
      this.queue = [...batch, ...this.queue];
    }

    await chrome.storage.local.set({ auditQueue: this.queue });
  }

  destroy(): void {
    if (this.flushInterval) {
      clearInterval(this.flushInterval);
    }
    this.flush();
  }
}

// Singleton
export const complianceLogger = new ComplianceLogger();

// Usage
// complianceLogger.log("settings_changed", { key: "theme", oldValue: "light", newValue: "dark" });
// complianceLogger.log("data_exported", { format: "csv", recordCount: 1250 });

Gotchas:


8. Handling Policy Changes at Runtime {#8-handling-policy-changes-at-runtime}

React to real-time policy updates pushed by administrators without requiring extension restart.

Explanation: When an administrator changes a policy in Google Admin Console, Chrome propagates the change to the device on its next policy sync (typically within minutes). The chrome.storage.managed.onChanged event fires when policies update. Your extension should handle these changes gracefully, applying them immediately where possible and notifying the user when a restart is required.

// policy-change-handler.ts
type PolicyChangeHandler = (
  key: string,
  oldValue: unknown,
  newValue: unknown
) => void | Promise<void>;

class PolicyChangeManager {
  private handlers = new Map<string, PolicyChangeHandler[]>();
  private globalHandlers: PolicyChangeHandler[] = [];

  initialize(): void {
    chrome.storage.managed.onChanged.addListener(
      (changes: Record<string, chrome.storage.StorageChange>) => {
        this.handleChanges(changes);
      }
    );
  }

  /**
   * Register a handler for changes to a specific policy key.
   */
  on(key: string, handler: PolicyChangeHandler): () => void {
    const handlers = this.handlers.get(key) ?? [];
    handlers.push(handler);
    this.handlers.set(key, handlers);

    return () => {
      const current = this.handlers.get(key) ?? [];
      this.handlers.set(
        key,
        current.filter((h) => h !== handler)
      );
    };
  }

  /**
   * Register a handler for any policy change.
   */
  onAny(handler: PolicyChangeHandler): () => void {
    this.globalHandlers.push(handler);
    return () => {
      this.globalHandlers = this.globalHandlers.filter(
        (h) => h !== handler
      );
    };
  }

  private async handleChanges(
    changes: Record<string, chrome.storage.StorageChange>
  ): Promise<void> {
    for (const [key, { oldValue, newValue }] of Object.entries(changes)) {
      console.log(`Policy changed: ${key}`, { oldValue, newValue });

      // Fire key-specific handlers
      const keyHandlers = this.handlers.get(key) ?? [];
      for (const handler of keyHandlers) {
        try {
          await handler(key, oldValue, newValue);
        } catch (error) {
          console.error(`Policy handler error for "${key}":`, error);
        }
      }

      // Fire global handlers
      for (const handler of this.globalHandlers) {
        try {
          await handler(key, oldValue, newValue);
        } catch (error) {
          console.error("Global policy handler error:", error);
        }
      }
    }
  }
}

// Setup
const policyManager = new PolicyChangeManager();
policyManager.initialize();

// Handle server URL changes: reconnect to new backend
policyManager.on("serverUrl", async (_key, _old, newValue) => {
  await reinitializeApiClient(newValue as string);
});

// Handle domain allowlist changes: update content script matching
policyManager.on("allowedDomains", async (_key, _old, newValue) => {
  const domains = newValue as string[];
  await chrome.storage.local.set({ allowedDomains: domains });
  // Notify all active content scripts
  const tabs = await chrome.tabs.query({});
  for (const tab of tabs) {
    if (tab.id) {
      chrome.tabs.sendMessage(tab.id, {
        type: "POLICY_UPDATE",
        key: "allowedDomains",
        value: domains,
      }).catch(() => {
        // Tab may not have content script injected
      });
    }
  }
});

// Log all policy changes for compliance
policyManager.onAny(async (key, oldValue, newValue) => {
  await complianceLogger.log("policy_changed", {
    key,
    oldValue,
    newValue,
  });
});

Gotchas:

Part of the Chrome Extension Guide by theluckystrike. Built at zovo.one.