Skip to main content

One class is allowed to talk to the gateway

00:03:11

I built a multi-tenant SaaS on top of an open-source messaging gateway (the gateway, Evolution API, does the hard protocol work; my product is the org-scoping, billing, dashboards, and developer API around it). Each tenant connects through their own instance, and each instance has a secret key. To make a call on a tenant's behalf, you have to decrypt that key. Which means the dangerous question in the whole system is: where, exactly, does a decrypted key live?

The answer that let me stop worrying about it: in exactly one class, and nowhere else.

The secret with one home

There's a single class permitted to decrypt a key or make a call to the gateway. Everything else in the application goes through it, and it never hands the key back out:

php
// The ONLY class permitted to decrypt a tenant key or call the gateway.
final class GatewayClient
{
    use Macroable; // lets tests stub gateway calls without touching the network

    private function __construct(
        private readonly string $key,
        private readonly string $baseUrl,
    ) {}

    // Shared, node-level calls use the key from config.
    public static function forNode(): self
    {
        return new self(config('gateway.key'), config('gateway.url'));
    }

    // Per-tenant calls decrypt that tenant's key, and this is the ONLY place that decryption happens.
    public static function forInstance(Instance $instance): self
    {
        return new self(Crypt::decryptString($instance->encrypted_key), $instance->base_url);
    }

    // Public methods take and return typed DTOs. Never a raw array, never the key.
    public function sendMessage(SendMessageData $data): MessageResult { /* the one place that calls out */ }
}

Three decisions are doing the work here, and each one closes a leak:

  • Crypt::decryptString appears in this file and no other. If you want to find every place a tenant key is exposed in plaintext, it's one grep, one file, a handful of lines. That's the entire attack surface for "where could a key leak," and it fits on a screen.
  • The constructor is private; you get an instance through forNode or forInstance. There's no way to build one with a key from somewhere unexpected. The two named constructors are the only two ways a key enters the class, and they make the caller's intent explicit at the call site.
  • Public methods speak DTOs, not arrays. A typed input and a typed result mean a raw gateway payload, which might carry the key or other tenant secrets, never escapes into a controller, a log line, or an Inertia prop by accident. The boundary is the type system, not your memory.

Why a rule in code beats a rule in a wiki

I could have written "please only decrypt keys in the gateway layer" in a contributing guide. Everyone would have agreed, and within a few months someone in a hurry would have pulled a key out to debug something and left it there. A convention is only as strong as the most rushed person who touches the code.

Making it structural removes the judgment call. There's no public way to get a decrypted key out of GatewayClient, so a teammate who needs to talk to the gateway has exactly one door, and it's the safe one. The design doesn't ask anyone to remember the rule, because the rule is the only thing the code lets you do.

The general principle

Find the most dangerous thing in your system, the secret, the irreversible action, the call that costs money, and give it exactly one home. Make every other part of the app reach it through a typed door that never hands the dangerous thing back. Then "is this secret handled safely everywhere?" stops being a question you answer by auditing the whole codebase, and becomes a property you can see by reading one file. In a multi-tenant system, where one leaked key is one tenant's data exposed to another, that's the difference between a worry and a non-issue.