Skip to content

Security Model & Best Practices

Security Model Overview

World of Warcraft employs a strict addon security model designed to prevent botting, unfair gameplay advantages, and exploitation. Addons run in a sandboxed Lua environment with no access to the filesystem, network sockets, or operating system commands. Blizzard carefully controls which API functions addons can call and under what circumstances, ensuring that addons enhance the user interface without automating gameplay.

Understanding these restrictions is essential for addon development — violating them results in blocked actions, tainted execution, or silent failures that can be difficult to debug.

Protected Functions

Certain API functions directly affect gameplay and are classified as protected. These functions can only be called from secure (Blizzard) code, or from addon code in response to a hardware event (keypress or mouse click) while not in combat.

Protected Functions

The following functions are protected and cannot be called freely from addon code:

Function Purpose
CastSpellByName() Cast a spell by name
TargetUnit() Target a specific unit
UseAction() Use an action bar slot
JumpOrAscendStart() Make the character jump
ToggleAutoRun() Toggle auto-run
StartAttack() Begin auto-attack
PetAttack() Command pet to attack
RunMacroText() Execute macro text
SetBinding() Set a key binding

These require a hardware event (real user input) and are fully blocked during combat lockdown.

Taint System

The taint system is Blizzard's mechanism for tracking which code and data originate from addons versus the default UI. This prevents addon code from interfering with secure Blizzard UI operations.

For full technical details, see the Secure Execution and Tainting article on the Warcraft wiki.

Secure vs. Tainted Code

  • Secure code — Blizzard's default UI code. It can call protected functions and modify secure frames.
  • Tainted code — Any code originating from an addon. It cannot call protected functions during combat.

How Taint Propagates

Taint spreads through execution like a virus:

  1. Reading a tainted variable taints the current execution path
  2. A tainted execution path taints any variables it writes
  3. Any function called from a tainted path becomes tainted
-- Example: taint propagation
local secureValue = SomeSecureFunction()  -- secure
local taintedValue = MyAddonVariable       -- tainted (addon-created)

-- Reading taintedValue taints this execution path
local result = secureValue + taintedValue  -- result is now TAINTED

-- This would fail if called during combat from a tainted path
-- TargetUnit("player")  -- ERROR: blocked by taint

Irrevocable Taint

Calling loadstring() or setfenv() irrevocably taints the resulting code. There is no way to "untaint" code produced by these functions. Avoid them if your code needs to interact with secure frames.

Checking Taint Status

Use issecurevariable() to inspect whether a variable is tainted:

local isTainted = not issecurevariable("SomeGlobalVariable")
if isTainted then
    print("SomeGlobalVariable is tainted by addon code")
end

-- Check a table field
local isTainted = not issecurevariable(SomeTable, "key")

Combat Lockdown

During combat, Blizzard locks down the secure UI environment. This is the most common source of addon errors for new developers.

Detecting Combat Lockdown

if InCombatLockdown() then
    print("In combat — protected actions are blocked!")
end

During Combat Lockdown, You Cannot:

  • Call any protected function
  • Create new secure frames (frames using secure templates)
  • Modify secure frame attributes (e.g., changing a SecureActionButton's action)
  • Show or hide secure frames

Pattern: Queue Actions for After Combat

The standard pattern is to queue desired actions during combat and execute them when combat ends via the PLAYER_REGEN_ENABLED event:

local addonName, ns = ...

ns.pendingActions = {}

local frame = CreateFrame("Frame")
frame:RegisterEvent("PLAYER_REGEN_ENABLED")

frame:SetScript("OnEvent", function(self, event)
    if event == "PLAYER_REGEN_ENABLED" then
        for _, action in ipairs(ns.pendingActions) do
            action()
        end
        wipe(ns.pendingActions)
    end
end)

-- Helper to run an action now, or queue it for after combat
function ns:RunOrQueue(action)
    if InCombatLockdown() then
        table.insert(self.pendingActions, action)
        print("Action queued — will execute after combat.")
    else
        action()
    end
end

-- Usage example: safely show a secure frame
ns:RunOrQueue(function()
    MySecureFrame:Show()
end)

Tip

Always check InCombatLockdown() before performing any protected operation. The queue-and-execute pattern above is the idiomatic way to handle this.

Hardware Event Requirements

Many gameplay-affecting actions require a real hardware event — an actual keypress or mouse click from the user. Synthetic events generated by code do not satisfy this requirement.

This means you cannot programmatically:

  • Cast spells
  • Target units
  • Use items
  • Interact with the world

To create buttons that perform these actions, use SecureActionButtonTemplate:

local btn = CreateFrame("Button", "MyAddonCastButton", UIParent, "SecureActionButtonTemplate")
btn:SetAttribute("type", "spell")
btn:SetAttribute("spell", "Fireball")
btn:SetSize(40, 40)
btn:SetPoint("CENTER")

-- The button's texture/visual setup
local tex = btn:CreateTexture(nil, "BACKGROUND")
tex:SetAllPoints()
tex:SetTexture(133230)  -- Fireball icon

-- When the user CLICKS this button, it casts Fireball
-- This works because it's a real hardware event routed through secure code

Warning

You must set secure button attributes outside of combat. Attempting to call SetAttribute() on a secure frame during InCombatLockdown() will silently fail.

What Addons Cannot Do

Understanding the boundaries of the sandbox helps avoid wasted effort and security violations.

Addon Restrictions

  • No filesystem access — Cannot read, write, or list files (SavedVariables are the only persistence mechanism, managed by the game client)
  • No network access — Cannot open sockets, make HTTP requests, or communicate outside the game (addon messages are the only inter-player communication)
  • No system commands — Cannot execute OS commands, launch processes, or interact with the operating system
  • No gameplay automation — Cannot cast spells, target units, use items, or move the character without a real hardware event
  • No secure frame modification in combat — Cannot show, hide, or change attributes on secure frames during combat lockdown
  • No cross-addon SavedVariables — Cannot directly read another addon's saved data (use addon messages or public APIs instead)
  • No raw memory access — Cannot read or modify game memory; only the exposed Lua API is available

Best Practices

Performance

Avoid Table Creation in Frequently Called Code

Creating tables in OnUpdate or other high-frequency handlers generates garbage that triggers expensive garbage collection pauses.

-- BAD: creates a new table every frame
frame:SetScript("OnUpdate", function(self, elapsed)
    local data = { x = 0, y = 0, z = 0 }
    -- ... use data ...
end)

-- GOOD: reuse a table with wipe()
local data = { x = 0, y = 0, z = 0 }
frame:SetScript("OnUpdate", function(self, elapsed)
    wipe(data)
    data.x, data.y, data.z = 0, 0, 0
    -- ... use data ...
end)

Throttle OnUpdate Handlers

OnUpdate fires every frame (potentially 60–240+ times per second). Throttle it to avoid wasting CPU:

local THROTTLE_INTERVAL = 0.1  -- 10 updates per second max
local elapsed_acc = 0

frame:SetScript("OnUpdate", function(self, elapsed)
    elapsed_acc = elapsed_acc + elapsed
    if elapsed_acc < THROTTLE_INTERVAL then
        return
    end
    elapsed_acc = elapsed_acc - THROTTLE_INTERVAL

    -- Your actual update logic here
end)

Tip

If you only need a one-time delayed action, use C_Timer.After(seconds, callback) instead of an OnUpdate handler. It's cleaner and automatically cleans up.

Cache Globals as Locals

Lua looks up global variables through a table hash every access. Caching frequently used globals as local upvalues is measurably faster in hot paths:

-- Cache at file scope (top of your .lua file)
local pairs = pairs
local ipairs = ipairs
local format = string.format
local tinsert = table.insert
local GetTime = GetTime
local UnitHealth = UnitHealth
local UnitHealthMax = UnitHealthMax

Prefer Events Over Polling

Events are always more efficient than polling with OnUpdate:

-- BAD: polling every frame
frame:SetScript("OnUpdate", function()
    local hp = UnitHealth("player")
    -- react to hp changes
end)

-- GOOD: event-driven
frame:RegisterEvent("UNIT_HEALTH")
frame:SetScript("OnEvent", function(self, event, unit)
    if unit == "player" then
        local hp = UnitHealth("player")
        -- react to hp changes
    end
end)

Unregister One-Time Events

If you only need an event once (e.g., initialization), unregister it immediately:

frame:RegisterEvent("PLAYER_LOGIN")
frame:SetScript("OnEvent", function(self, event)
    if event == "PLAYER_LOGIN" then
        self:UnregisterEvent("PLAYER_LOGIN")
        -- One-time initialization logic
    end
end)

Code Organization

Event Dispatch Table

Use a dispatch table instead of long if/elseif chains for event handling:

local addonName, ns = ...

local frame = CreateFrame("Frame")

local events = {}

function events:PLAYER_LOGIN()
    print(addonName .. " loaded!")
end

function events:PLAYER_ENTERING_WORLD(isInitialLogin, isReloadingUI)
    if isInitialLogin then
        print("Welcome!")
    end
end

function events:ADDON_LOADED(loadedAddon)
    if loadedAddon == addonName then
        -- Initialize SavedVariables, etc.
    end
end

for event in pairs(events) do
    frame:RegisterEvent(event)
end

frame:SetScript("OnEvent", function(self, event, ...)
    events[event](self, ...)
end)

Mixin Pattern for Composition

Use mixins to share behavior across objects without deep inheritance chains:

-- Define a mixin
local HealthBarMixin = {}

function HealthBarMixin:UpdateHealth(unit)
    local hp = UnitHealth(unit)
    local maxHp = UnitHealthMax(unit)
    self.healthBar:SetValue(hp / maxHp)
end

function HealthBarMixin:SetHealthColor(r, g, b)
    self.healthBar:SetStatusBarColor(r, g, b)
end

-- Apply the mixin to a frame
local myFrame = CreateFrame("Frame", nil, UIParent)
Mixin(myFrame, HealthBarMixin)

-- Now myFrame has :UpdateHealth() and :SetHealthColor()

Namespace Encapsulation

Always use the addon namespace to encapsulate your code and avoid global pollution:

-- At the top of every file
local addonName, ns = ...

-- Everything goes in the namespace
ns.CONSTANTS = {
    MAX_ITEMS = 50,
    DEFAULT_COLOR = { r = 1, g = 1, b = 1 },
}

function ns:Initialize()
    -- Addon initialization
end

-- Local upvalues for performance-critical code
local MAX_ITEMS = ns.CONSTANTS.MAX_ITEMS

Addon Communication Best Practices

Addons can communicate with other instances of the same addon (or cooperating addons) across players using addon messages.

Registering a Prefix

Every addon message channel requires a prefix (max 16 characters):

local PREFIX = "MyAddon"
C_ChatInfo.RegisterAddonMessagePrefix(PREFIX)

Sending Messages

-- Send to party/raid/guild
C_ChatInfo.SendAddonMessage(PREFIX, "hello", "PARTY")
C_ChatInfo.SendAddonMessage(PREFIX, "sync:data", "RAID")
C_ChatInfo.SendAddonMessage(PREFIX, "version:1.2.3", "GUILD")

-- Send to a specific player (whisper)
C_ChatInfo.SendAddonMessage(PREFIX, "request:config", "WHISPER", "PlayerName")

Message Limits

  • Maximum message size: 255 bytes per message
  • Throttle: approximately 10 messages per second before the server starts dropping them
  • Prefix: maximum 16 characters

Exceeding these limits causes messages to be silently dropped.

Receiving Messages

local frame = CreateFrame("Frame")
frame:RegisterEvent("CHAT_MSG_ADDON")
frame:SetScript("OnEvent", function(self, event, prefix, message, channel, sender)
    if prefix ~= PREFIX then return end

    -- Parse and handle the message
    local command, data = strsplit(":", message, 2)
    if command == "sync" then
        -- Handle sync data
    elseif command == "version" then
        -- Handle version check
    end
end)

Batching and Compression for Heavy Communication

For syncing large datasets, batch your data and consider compression:

local addonName, ns = ...

-- Simple message batching
function ns:SendLargeData(data, channel)
    local serialized = ns:Serialize(data)  -- your serialization logic
    local chunks = {}

    -- Split into 250-byte chunks (leave room for header)
    for i = 1, #serialized, 250 do
        table.insert(chunks, serialized:sub(i, i + 249))
    end

    -- Send with a small delay between chunks to avoid throttle
    for i, chunk in ipairs(chunks) do
        C_Timer.After((i - 1) * 0.12, function()
            local header = format("CHUNK:%d:%d:", i, #chunks)
            C_ChatInfo.SendAddonMessage(PREFIX, header .. chunk, channel)
        end)
    end
end

Tip

For serious data synchronization needs, consider using the LibSerialize and LibDeflate libraries available through the addon ecosystem, rather than writing your own serialization and compression.