The moment your application talks to somebody else's — Stripe, Shopify, GitHub, an email provider — you have acquired a credential problem. Someone's token now lives in your system. It has to be stored, scoped to the right customer, used from the right place, and revoked when things go wrong.

The path of least resistance is an environment variable and a comment saying "TODO: encrypt this." Tiknix takes a more structured route, and the structure is worth understanding even if you never use its connectors, because the shape generalizes to any integration you build.

Encrypted at rest, with a real cipher

lib/EncryptionService.php uses libsodium's authenticated symmetric encryption:

$nonce      = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);  // 24 bytes, per value
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $key);
$encrypted  = base64_encode($nonce . $ciphertext);

sodium_memzero($key);   // wipe the key from memory when done

Three details separate this from the usual improvised version:

  • Authenticated encryption. secretbox is XSalsa20-Poly1305 — tampering with the ciphertext causes decryption to fail rather than silently produce garbage.
  • A fresh random nonce per value, prepended to the ciphertext. Nonce reuse is the classic way to destroy a stream cipher's security, and this design makes it structurally impossible.
  • sodium_memzero() after use. The key does not linger in process memory waiting to appear in a core dump.

The key comes from conf/config.ini [security] app_key — which means that file is a secret. Not "should probably be private." A secret. Out of version control, restricted permissions, backed up somewhere you can actually find it, because losing it means every stored token becomes unrecoverable noise.

Connectors behind one interface

services/connectors/ holds a registry of connector classes — Stripe, Shopify, Instagram at present — behind ConnectorInterface and AbstractConnector. Adding a provider means adding a class, not editing the OAuth flow.

The generic OAuth path is one sequence for every registry-driven connector:

GET  /connections/connect/<type>?id=<instance>&env=prod
       -> signed state (OAuthStateService) -> provider consent screen
GET  /connections/callback/<type>
       -> connector->exchangeCode() -> encrypted row in `connections`

The signed state parameter is the CSRF defense for OAuth — an unsigned state is how you get tricked into attaching an attacker's account to a victim's session. It is handled once, centrally, for every connector, which is precisely the kind of detail that gets skipped when each integration rolls its own flow.

GitHub keeps a separate path (personal access token, then publish-to-branch-and-open-a-PR) because its use case is genuinely different. The codebase says so in a comment rather than pretending the abstraction is cleaner than it is.

Scoping: member, instance, environment

A connection is bound to a member, an instance, and an environment (dev / staging / prod). All three matter:

  • Member — a connection is only ever readable or usable by the member who owns the instance it belongs to. Ownership is checked, not assumed.
  • Instance — one customer's Shopify token cannot be reached from another customer's app.
  • Environment — your staging deployment does not hold live payment credentials, which removes an entire class of expensive accident.

The broker key: a capability, not a secret

This is the most interesting piece of the design. Tokens are held only on the control plane. A running instance never gets the Stripe key. Instead it gets a broker key, and calls the MCP gateway, which makes the upstream call on its behalf.

The properties are worth listing precisely — the code's own docblock does:

The broker key is a revocable, hash-stored capability that lets a builder instance call the MCP gateway to reach ITS OWN connected stores. It decrypts nothing, is scoped to a single instance, and is killed by one flag flip. The raw key is shown exactly once (at mint); only its sha-256 hash lives in the DB.

And then the sentence that shows the threat model was actually thought through:

This is a capability, NOT a secret worth custody — losing it exposes, at worst, rate-limited, audited API use of that one instance's stores until it is revoked.

Compare the two blast radiuses. Leak a Stripe secret key: an attacker has your Stripe account, they can move money, and rotation is a scramble. Leak a broker key: an attacker can make rate-limited, audited calls scoped to one instance's connected stores, until someone flips a flag.

The design goal is not preventing every leak — it is making leaks survivable. That is the more mature security posture, and it shows up in the storage decision too: only the SHA-256 hash is persisted, so a database compromise does not yield working keys.

The pattern, extracted

Even if you never touch Tiknix's connectors, this shape is reusable:

  1. Encrypt credentials at rest with an authenticated cipher and a per-value nonce.
  2. Hold them in one place — a control plane — rather than distributing them to every process that needs the capability.
  3. Hand out scoped, revocable capabilities instead of the credentials themselves.
  4. Store capability hashes, not capabilities.
  5. Show a raw secret exactly once, at creation.
  6. Make revocation a single, fast operation — because it will be done in a hurry, at a bad time.
  7. Scope by owner, tenant, and environment, and enforce all three on every access.
Honest notes
  • app_key is a single point of failure. Lose it and every stored connection is unrecoverable; leak it plus a database dump and every token is exposed. It deserves the same handling as a production database password — and a documented rotation plan, which is more work than it sounds because rotation means re-encrypting every stored value.
  • The connector registry is small. Stripe, Shopify, Instagram, plus the bespoke GitHub path. Real, and not a marketplace. Adding a provider is a class you write.
  • The broker adds a hop. Every brokered call goes through the gateway — more latency, and a component whose availability now matters. The security win is worth it; the operational cost is real and should be monitored.
  • The connections hub is admin-scoped. Connecting a store is an administrative action in the current design, not a self-service member flow.
  • Encryption at rest is not encryption in use. A decrypted token exists in process memory while a request runs. That is unavoidable; it is worth knowing rather than assuming otherwise.
  • Audit logs only help if someone reads them. "Rate-limited and audited" is a strong mitigation on paper and inert in practice unless something alerts on anomalies.