So you built an EA worth selling? Now you need to stop one purchase from becoming a thousand free copies, without punishing the people who actually paid. The usual first instincts are license key inputs, account-number locks, and DLL wrappers, and they all share the same problems: they treat the customer as the threat, they generate support tickets every time someone reinstalls MetaTrader, and they still get cracked.
Here is a different approach, one that holds up well in practice: keep the entitlement on a server you control, have the EA ask about it over HTTP on a slow timer, and cache the answer in a fail-closed state file. No keys to type, no activation counters, no DLLs. The whole thing fits in a few hundred lines, and the complete compilable example is attached to this post.
The moving parts
Three pieces, two of them yours to host:
- An entitlement record on your server: this email owns this product, active or not. Your shop, your database, your call how it gets there.
- A check endpoint: one HTTPS route that answers a single question, "does this email hold an active entitlement for this product?", with a single bit.
- A gate inside the EA: a slow timer asks the endpoint, writes the answer to a small local state file, and the trading logic reads only that file.
The separation matters. The trading path never blocks on the network, and the network path never touches trading state. A dropped connection cannot flatten a position.
Principles worth keeping
- The server is the source of truth. The EA never contains a license, so there is no key to leak, share, or brute-force.
- No license keys at all. An email the customer already knows beats a 32-character string they have to find in an inbox from eight months ago.
- Fail closed, gracefully. Every weird path (missing file, corrupt JSON, clock skew) resolves to "not licensed". But a verified "yes" stays valid through a grace window, so a 3am DNS blip on a broker VPS does not stop a bot mid-session.
- Skip the obfuscation arms race. Someone determined enough will crack client-side checks, yours included. The goal is to make buying easier than cracking, and to protect your server and customer data properly, which is the part you actually control.
The HTTP call from the EA
MQL5 gives you one tool for this, WebRequest, and it comes with two
gotchas. It only works from EAs and scripts (not indicators), and the URL
must be allowlisted by the user under Tools, Options, Expert Advisors,
"Allow WebRequest for listed URL". Handle the not-allowlisted error
explicitly, because every customer hits it once:
// One entitlement check. Returns false when no usable answer came back;
// httpOk reports whether the server answered 200 at all.
bool CallCheckEndpoint(bool &entitled, bool &httpOk, string &reason)
{
entitled = false;
httpOk = false;
reason = "no_response";
string body = StringFormat(
"{\"email\":\"%s\",\"productSlug\":\"%s\",\"nonce\":\"%s\",\"ts\":%I64d}",
JsonEscape(InpLicenceEmail), JsonEscape(InpProductSlug),
GenerateNonce(), (long)TimeGMT());
string signature;
if (!HmacSha256(CHECK_SECRET, body, signature))
{ reason = "sign_failed"; return false; }
char dataIn[], dataOut[];
StringToCharArray(body, dataIn, 0, StringLen(body));
string headers = "Content-Type: application/json\r\n"
"X-Signature: " + signature + "\r\n";
string respHeaders;
ResetLastError();
int status = WebRequest("POST", InpCheckUrl, headers, 10000,
dataIn, dataOut, respHeaders);
if (status == -1)
{
int err = GetLastError();
reason = (err == 4014 || err == 4060) ? "url_not_whitelisted"
: "request_failed";
if (reason == "url_not_whitelisted")
Print("Allow ", InpCheckUrl,
" under Tools > Options > Expert Advisors > Allow WebRequest.");
return false;
}
if (status == 200)
{
httpOk = true;
string json = CharArrayToString(dataOut);
entitled = (StringFind(json, "\"entitled\":true") >= 0);
reason = entitled ? "ok" : "not_active";
return true;
}
if (status == 401) reason = "auth_failed";
else if (status == 429) reason = "rate_limited";
else if (status == 404 || status == 503) reason = "service_unavailable";
else reason = "http_" + IntegerToString(status);
return true;
}
Details that earn their keep: the reasons are machine-stable codes, not
prose, because they get persisted and drive the grace logic later. The
timestamp and nonce in the body are not decoration, they feed the replay
protection below. And StringFind is a perfectly good JSON parser when
the contract is one boolean field you control both sides of.
Security between the endpoints
The wire between the EA and your server is the part most licensing write-ups skip, and it is where the real risks live. Work through it threat by threat.
Transport: HTTPS, no exceptions. WebRequest uses the system TLS
stack, so this costs you nothing. A plain-HTTP check endpoint hands every
coffee-shop router the ability to fake an "entitled" answer.
Authentication: a shared secret, with honest expectations. A secret baked into the EA at build time proves the request came from your build, which keeps random internet scanners and script kiddies off the endpoint. But be honest about its limits: it ships inside the compiled binary, so treat it as semi-public. Scope it so it can do exactly one thing, call this one endpoint and receive one bit back. Never reuse an API key that can read customer records, issue refunds, or touch anything else. Rotate it on each release.
Integrity and replay: sign the body. A static bearer header can be
lifted once and replayed forever. Signing the request body (which includes
a timestamp and a nonce) with HMAC-SHA256 is a real step up: the signature
only matches that exact body, and the server rejects bodies whose
timestamp is more than a few minutes old. MQL5 has no native HMAC, but it
has SHA-256 via CryptEncode, and HMAC is just two hashes with padded
keys (RFC 2104):
// HMAC-SHA256 from CryptEncode. Block size for SHA-256 is 64 bytes.
bool HmacSha256(const string key, const string message, string &hexOut)
{
uchar keyBytes[];
StringToCharArray(key, keyBytes, 0, StringLen(key));
uchar block[64];
ArrayInitialize(block, 0);
uchar empty[];
if (ArraySize(keyBytes) > 64)
{
uchar keyHash[];
if (CryptEncode(CRYPT_HASH_SHA256, keyBytes, empty, keyHash) <= 0)
return false;
ArrayCopy(block, keyHash, 0, 0, ArraySize(keyHash));
}
else if (ArraySize(keyBytes) > 0)
ArrayCopy(block, keyBytes, 0, 0, ArraySize(keyBytes));
uchar msg[];
StringToCharArray(message, msg, 0, StringLen(message));
uchar inner[];
ArrayResize(inner, 64 + ArraySize(msg));
for (int i = 0; i < 64; i++)
inner[i] = (uchar)(block[i] ^ 0x36);
ArrayCopy(inner, msg, 64, 0, ArraySize(msg));
uchar innerHash[];
if (CryptEncode(CRYPT_HASH_SHA256, inner, empty, innerHash) <= 0)
return false;
uchar outer[];
ArrayResize(outer, 64 + ArraySize(innerHash));
for (int i = 0; i < 64; i++)
outer[i] = (uchar)(block[i] ^ 0x5C);
ArrayCopy(outer, innerHash, 64, 0, ArraySize(innerHash));
uchar mac[];
if (CryptEncode(CRYPT_HASH_SHA256, outer, empty, mac) <= 0)
return false;
hexOut = "";
for (int i = 0; i < ArraySize(mac); i++)
hexOut += StringFormat("%02x", mac[i]);
return true;
}
Server side, treat the endpoint as hostile input. It is a public HTTP surface that attackers will find. Validate the body before trusting any field (malformed JSON, oversized payloads, injection attempts through the email field). Rate-limit per email and per IP, and alert on bursts: a credential-stuffing run against your check endpoint looks like thousands of "no" answers in a row. Return the minimum, one boolean, no customer data, no "this email exists but owns a different product" hints.
Here is the whole endpoint as an ASP.NET Core minimal API. Any stack works, the contract is what matters:
app.MapPost("/v1/license/check", async (HttpRequest http, IEntitlementStore store) =>
{
using var reader = new StreamReader(http.Body);
var body = await reader.ReadToEndAsync();
// 1. Authenticate before parsing anything.
var signature = http.Headers["X-Signature"].ToString();
if (!HmacSha256.Verify(body, signature, AppSecrets.CheckSecret))
return Results.Unauthorized();
var check = JsonSerializer.Deserialize<LicenseCheck>(body);
if (check is null
|| string.IsNullOrWhiteSpace(check.Email)
|| string.IsNullOrWhiteSpace(check.ProductSlug))
return Results.BadRequest();
// 2. Reject stale timestamps so a captured request cannot be replayed.
var ageSeconds = Math.Abs(
DateTimeOffset.UtcNow.ToUnixTimeSeconds() - check.Ts);
if (ageSeconds > 300)
return Results.Unauthorized();
// 3. Answer one question with one bit, nothing more.
var entitled = await store.IsEntitledAsync(check.Email, check.ProductSlug);
return Results.Ok(new { entitled });
});
One more honest note: none of this stops someone from patching the compiled EA to skip the gate entirely. Channel security protects your server, your customer list, and your paying users. The binary protects itself only as far as effort goes, which is exactly why the effort belongs here and not in a packer.
Fail closed on the client
The EA never trades off a live HTTP response. It trades off a local state file that the check path maintains, and the read path is paranoid:
bool IsLicensed()
{
if (MQLInfoInteger(MQL_TESTER))
return true; // the strategy tester never phones home
LicenseState s;
if (!LoadState(s)) // missing or unreadable file
return false; // fail closed, never open
if (TimeGMT() - s.verifiedAt > STALENESS_SECONDS)
return false; // too long since a verified "yes"
return s.entitled;
}
Two windows do the balancing. A verified "yes" survives transient network trouble for a grace window (24h works well), so an outage does not strand paying customers. But no successful check inside the staleness ceiling (48h) means not licensed, full stop, so "block the endpoint in the firewall and keep running forever" is not a workaround. Write the state file atomically (write a temp file, then move it over the target) so a crash mid-write can never produce a half-written file that the gate then has to interpret.
Wiring it into the EA lifecycle
Keep the check completely off the tick path. A chart timer fires every minute, and the refresh logic throttles itself to roughly one real HTTP call per day, with a per-chart random stagger so twenty charts of the same product do not all phone home in the same second:
int g_jitter = 0;
int OnInit()
{
MathSrand((int)(GetTickCount() ^ (uint)TimeLocal() ^ (uint)ChartID()));
g_jitter = MathRand() % 600; // de-sync charts by up to 10 minutes
EventSetTimer(60);
RefreshLicense(); // resolve at attach, not a minute later
return(INIT_SUCCEEDED);
}
void OnDeinit(const int reason) { EventKillTimer(); }
void OnTimer() { RefreshLicense(); } // internally throttled to ~24h
void OnTick()
{
if (!IsLicensed())
return; // stop NEW decisions; never force-close open positions
// ... strategy ...
}
That last comment is a policy decision worth stating out loud: a revoked or lapsed entitlement should stop new trading, not dump open positions at market. Closing a customer's positions because your license server had a bad day is how you end up on a forum thread you do not want to be on.
What not to bother with
- Hardware locks and activation counters. They punish reinstalls, which honest customers do constantly, and crackers bypass anyway.
- Obfuscation arms races. Every hour spent on packers is an hour not spent on the strategy, and antivirus vendors and broker VPS providers flag the result.
- Phoning home with anything beyond the question. Send what the entitlement check needs and nothing else. Not trade history, not account numbers, not balance. It is a license check, not telemetry.