All posts
You can't ship an API key in a one-click deploy template
Gabe 18 min read

You can't ship an API key in a one-click deploy template

A one-click deploy template that shows account data has a problem no tutorial mentions: it lives on a public URL, so it can't hold a shared API key without handing every visitor the whole account. Here's how we shipped an inbound-email inbox to the Railway marketplace as one deploy: the OAuth self-registration that replaces the key, one core that runs on six hosts, and a Connect button that wires the webhook for you.

The Deploy button is the easy part. What breaks is the moment you realize the app it deploys has to read the account’s data (an inbox, a dashboard, a logs viewer) and it is sitting at a URL anyone can open. Drop your mk_live_… key into the template as an environment variable and every person who finds the link is now reading your mail. That is the whole problem, and it shapes every decision below:

your app public deploy URL baked mk_live_ key anyone with the link every domain's mail the entire account · leak visitor signs in OAuth → their token only the domains that visitor owns the same public URL, two ways to answer "who is asking?"
A shared key answers "who is asking?" with "the deployer" for everyone. Per-user OAuth answers it with the actual visitor.

Here is the piece that has no such problem, so we can start with it: the inbound webhook. MailKite signs every delivery, so the receiver just verifies the signature. It needs a secret, not a session, and it is the same one line in every language (this is mailkite, our SDK, not a hand-rolled HMAC):

// POST /inbound: public on purpose. MailKite signs it, no user is involved.
app.post("/inbound", async (c) => {
  const raw = await c.req.text(); // verify the RAW bytes; re-serialized JSON breaks the HMAC
  const sig = c.req.header("x-mailkite-signature") ?? "";
  if (!MailKite.verifyWebhook(sig, raw, secret)) return c.text("bad signature", 401);

  const event = JSON.parse(raw);
  if (event.type === "email.received") await store.put(toStoredMessage(event));
  return c.body(MailKite.replyOk(), 200, { "Content-Type": "application/json" });
});

That is the receiving half of an email platform in ten lines. The whole thing (this exact app) is the companion repo: mailkite/mailkite-inbound-inbox, and it is what the Deploy button at the end of this post launches. Everything hard is on the other half, the part that renders the mailbox, because that is the part that has to know who is looking.

The public URL can’t hold a shared key

A deployed inbox is a web page that lists your messages. To list them it needs to read your account, and the tutorial version of “read your account” is an API key in an env var. On a private backend that is fine.

On a template that anyone can deploy and then reach at something.up.railway.app, that same key is a data breach with a nice UI. It does not know who opened the page, so it answers for everyone.

You can try to gate the page behind a password, but now you are building auth into a demo, storing password hashes, handling reset flows. And you still have a single key behind the wall that can read every domain on the account, so one leak (a logged env var, a misconfigured proxy, a shared screenshot) is still total.

The honest DIY version of this template is not “add a login.” It is “do not put a long-lived, account-wide credential on a public host at all.” The two approaches differ on exactly one question, and it changes everything downstream:

ApproachAnswers “who is asking?” asThe app can readBlast radius if it leaks
Baked API key (env var)the deployer, for every visitorevery domain on the accounttotal: all mail, all domains
Per-user OAuth (this template)the actual signed-in visitoronly that visitor’s own domainsone visitor’s short-lived token

Sign in as the user, not with a key

The app should carry no key of its own. Instead, each visitor signs in with their own MailKite account, and the app acts as that visitor for the duration of their session. We use OAuth 2.1 with PKCE, and the useful trick for a template is dynamic client registration (RFC 7591): the app registers itself with the authorization server at runtime, so there is no client id or client secret for the deployer to create and paste. Zero OAuth config. The gate is small:

async function gate(c, next) {
  const user = await auth.resolve(c);              // valid session? (refreshes the token)
  if (!user) return c.redirect(`/auth/login?returnTo=${enc(c.req.path)}`);

  const client = clientFor(user.accessToken);      // an SDK client that IS this user
  const domains = await client.listDomains();      // ...so it only sees their domains
  c.set("ownedDomains", new Set(domains.map((d) => d.domain.toLowerCase())));
  await next();
}

// GET /: the store is shared, so scope every read to domains this user owns:
const inbox = (await store.list()).filter((m) => owned.has(domainOf(m.toAddr)));

The store is a single SQLite file, but the inbox is not “everything in the file.” It is filtered to the domains the signed-in user owns, so two different people who deploy from the same image, or one shared instance, can never read each other’s mail. There is no key to leak because the only credential in play is a short-lived access token that belongs to the person currently looking at the screen.

Flip the auth model below and change who opened the page. One shared store, the same five messages. The only thing that changes is who is allowed to read them:

One detail that will bite you: platforms like Railway, Render and Fly terminate TLS at a proxy and forward plain HTTP to your process. If you build the OAuth redirect_uri from the request’s own protocol you get http://, and a strict authorization server rejects it. Read x-forwarded-proto (falling back to the request protocol) when you construct any self-referential URL. Same bug, same fix, for the webhook URL later.

One core, six hosts

We wanted this template to run on Railway, Render, Fly, DigitalOcean, Docker, and Cloudflare Workers without maintaining six codebases. The move that made that cheap: put all the logic in a runtime-agnostic core and inject its dependencies as interfaces, so each host is a thin adapter.

src/core · createApp() routes · OAuth gate · verify · server-rendered UI no runtime imports · pure logic injected seams (interfaces): MessageStore ApiClient Auth Node adapter better-sqlite3 Workers adapter D1 Railway · Render · Fly · DigitalOcean · Docker Cloudflare tests inject fake seams → the whole app runs offline, no account
createApp() takes its store, API client, and auth as arguments. Production wires the real ones; tests wire fakes.

createApp() receives everything it touches, so the same function powers Node and Workers, and the tests wire fakes to exercise the entire app with no network:

export function createApp({ store, auth, clientFor, webhookSecret }: AppDeps): Hono { /* … */ }

// Node entry:
createApp({ store: new SqliteStore(dbPath), auth: createMailKiteAuth(), clientFor: (t) => new MailKite(t) });
// Cloudflare entry:
createApp({ store: new D1Store(env.DB), auth: createMailKiteAuth(), clientFor: (t) => new MailKite(t) });

Two rules kept the adapters thin. First, the stores create their tables lazily (CREATE TABLE IF NOT EXISTS on first use), so there is no migration step for a one-click deploy to run. Second, one multi-stage Dockerfile is the build unit for the five container hosts; each platform just points at it. Railway’s config is four lines, and it is entirely about the container, not the app:

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": { "builder": "DOCKERFILE", "dockerfilePath": "Dockerfile" },
  "deploy": { "healthcheckPath": "/healthz", "restartPolicyType": "ON_FAILURE" }
}

The app reads PORT from the environment (every host injects it), answers /healthz for the health check, and writes its SQLite file to a mounted volume. The one trap worth stating out loud: the volume mount path has to match the DB path the app writes to. Mount at /data but write to /var/data and the database is on the ephemeral layer, silently wiped on every redeploy.

Connecting the webhook without a config step

After deploy, the inbox is empty until MailKite knows where to POST inbound mail. The tutorial answer is “go to the dashboard and paste your deploy URL into the webhook field.” We can do better, because the app already has the signed-in user’s token, so it can wire the webhook itself. The inbox shows a Connect button per domain, and clicking it does the plumbing.

The one thing it must not do is clobber a webhook the user already has. Inbound mail fans out to every matching route, so blindly pointing the *@domain catch-all at this demo would copy the user’s real mail (a support forward, an agent) into it. So: if the domain has no routes, take the catch-all; if it already routes mail, add a dedicated inbox@domain address instead. Either way, capture the route’s signing secret in the response and cache it, which is what lets the receiver verify with no secret configured at deploy time:

app.post("/connect", async (c) => {
  const routes = (await client.listRoutes()).filter((r) => inDomain(r.match_pattern, domain));
  if (routes.length === 0) {
    const { signingSecret } = await client.setWebhook(domainId, { url: `${self}/inbound` });
    await store.putSecret(signingSecret);           // /inbound verifies against this now
  } else {
    const { signing_secret } = await client.createRoute({
      match: `inbox@${domain}`, action: "webhook", destination: `${self}/inbound`,
    });
    await store.putSecret(signing_secret);
  }
});

Because each route signs with its own secret and the app collects them at Connect time, the receiver verifies an incoming signature against the set of secrets it has cached. Trying each is safe: the signature binds the body, so only the right secret validates. The net result is a template with zero required secrets. Deploy it, sign in, click Connect, send yourself an email.

Publishing it, and the wedge that made it worth doing

Getting listed on a marketplace like Railway is the un-automatable part: you build the project, attach a volume to the service (a volume, not a bucket, and mounted at the path your app writes to), describe the one optional variable, and click Publish. The composer mints a URL with your referral code baked in, and that URL becomes the button.

The reason it was worth doing at all is the gap in what is already there. The email templates on every deploy marketplace are outbound only. Resend’s Railway template sends; nothing on the platform receives. “An email arrived at your verified domain, here is the JSON at your webhook” is the half nobody ships as one deploy, and it is the half we build.

So to be clear about the recommendation and our stake in it: we build MailKite, this inbox template is us dogfooding it, and everything above (the verification, the OAuth, the routing) is what the platform does for you. If you would rather assemble it yourself, the honest alternatives are Postmark’s inbound parsing, SES plus a Lambda, Cloudflare Email Workers, or self-hosting Haraka behind your own MX record. Those are the right call when you want to own the mail server, or when a webhook platform is one dependency too many.

The pitch here is only that we did the annoying parts (the MX edge, the retries, the signature verification, the per-route secrets) so the template could be this small.

Before you deploy it, you can read and run the whole thing in the browser: the createApp() seam from the diagram, the OAuth gate, and the host adapters, in one editor:

Run the template in your browser: Open mailkite-inbound-inbox in StackBlitz.

If you want the running version, this is the button. It is the repo above; it deploys, signs you in, and connects a domain with no key anywhere:

Deploy on Railway

The general shape transfers to any template that renders account data: keep the public webhook receiver stateless and signature-verified, keep no long-lived credential on the box, borrow the visitor’s identity through OAuth for anything that reads their data, and let one core handle every host. The source is all there if you want to lift the shape for your own template.

Discuss this post: Hacker News Share on X Share on LinkedIn

Related posts