Back to writing

5 September 20268 min read

Vercel for internal tools: when the audience is your own staff

Public sites and internal tools ask different access questions, and what changes when only 30 named people should be able to open a page.

Vercel is very good at hosting web applications. Push to a branch, get a build, get a URL. Every pull request gets its own preview address, the result is served from a global edge network, and the developer experience has been polished by a lot of people over a lot of years. If you are shipping a marketing site, a documentation site or a product front end, it is hard to argue with.

This post is not about whether that is good. It is about what changes when the page is not for the public. When the audience is 30 named people in operations and finance, the deployment problem is largely solved and the access problem is the whole job.

The access question is a different question

On a public site, access control is a decision about content, made once. On an internal tool, it is a decision made on every single request: is the person holding this URL one of the 30, and are they still one of the 30 today.

Vercel does have an answer here. Deployment Protection includes Vercel Authentication, which puts a deployment behind the Vercel team's own login, there is a shared password option, and there are shareable links that let a named person open a protected deployment. If the audience for the tool is the engineering team, who already have accounts and already belong to that project, this is a clean answer and you should use it. Exactly which protection options apply to a given plan is the sort of thing that moves, so their docs are the right place to check rather than a blog post.

The internal tool case is different because of who the audience is, not because of what the feature does. The warehouse supervisor who needs the stock reconciliation tool is not in the Vercel team. Letting her in means either a shared secret, a password or a link, or a decision about the account that also holds your production deploys. Both are reasonable, and both are a choice somebody has to keep making as people join and leave. For a lot of teams the answer is neither, so the tool ends up on a public URL with a password typed into the page instead.

Where the auth lives

There are two places the check can live: inside the app's own code, or in front of it. An AI coding tool will happily write you a login screen. That puts the thing deciding who gets in inside the code your operations lead cannot read, and makes every redeploy an opportunity to change it by accident.

OurTools puts it in front. The proxy in apps/proxy is a small Go service, and the tenant apps behind it have no public route at all. An app with its own image runs as a Cloud Run service with internal ingress and no unauthenticated callers. Static apps have no image and no service of their own: they are served by one shared service, ourtools-static, under the same ingress and invoker rules. The proxy reaches either with an OIDC ID token whose audience is that exact service URL, so a token minted for one tenant service is useless against another. The reasoning is in docs/adr/0001-gcp-platform.md.

gcloud run deploy ourtools-tenant-<slug> \
  --region <region> \
  --ingress internal \
  --no-allow-unauthenticated

What the app then receives is deliberately narrow. Every incoming header starting with X-OurTools- is deleted before the platform sets its own, so a caller cannot claim to be somebody else. The platform session cookie is not forwarded: it is set on the apex domain, which means the browser offers it to every subdomain, and passing it on would hand customer-written code a live ID token for the person using it. The request id is generated in the proxy rather than read from a header, so nobody can stamp their own value onto the logs of an app they do not own. A share link token arriving in the query string is swapped for a cookie and stripped before the request goes upstream, which keeps it out of the address bar, the history and any Referer the app sends onwards.

Verification itself is four checks and no shortcuts: the signature against Google's public keys, the issuer, the audience, and expiry. An unsigned algorithm is never accepted. Sign-in is Firebase Authentication with Identity Platform, with Google, Microsoft and email/password enabled, written up in docs/adr/0006-auth-identity-platform.md.

What an IT approver actually asks for

Approvers are not hostile and they are not curious. They have three questions, in this order: what tools do our people have, who can open each one, and how do I stop one right now. The /admin page in the dashboard exists to answer those three and carries nothing else. Billing, invites and API tokens live on a different page on purpose.

  • What we have. Every app in the workspace, its status, the region it runs in, and when it was last used.
  • Who can open it. Access resolves in a fixed order: an explicit rule for a person, then the workspace, then an email domain, then a share link, then the app's owner, who can always open their own tool whatever the rules say, then deny. Named people are printed by email address, not as a count, because a count is not an answer.
  • Whether it reaches outside. Only two kinds of rule do, an email domain and a share link, and the link is the sharper of the two because it works without signing in at all. Both are flagged with the reason spelled out: "anyone signed in with an @acme.com address", "anyone holding the share link, without signing in".
  • How to stop it. A kill switch, restricted to workspace admins.

One small thing on that page is worth copying. Someone who is not an admin gets the ordinary not found page, identical to a URL that was never a page, and there is a test that keeps it that way. A 403 would confirm that the workspace exists and has an admin page worth guarding, which is the first thing anyone probing wants to learn.

Region comes up in the same conversation. The app owner picks it per app, from 43 regions in packages/shared/src/regions.ts, and it is fixed once the first version is deployed. That constraint is honest rather than lazy: a managed database follows the app's region, and a Cloud SQL instance's region cannot be changed after it is created.

What an audit log is for

An audit log is not for catching people. It is for answering a question weeks later, in a format somebody who does not work with you will accept. That changes what it has to be made of.

  • It records eight kinds of event: deploy, access, share, env_change, rollback, kill, login and database_change.
  • It is append-only in the strict sense. The control plane exports one insert and no update or delete anywhere. The app id and the actor id are nullable with ON DELETE SET NULL, and the app slug is copied into the metadata, so deleting an app does not erase the record of what was done to it. Deleting a person leaves the events standing with a null actor, and those rows read as "deleted account": the act survives, the name attached to it does not.
  • A fixed list of keys is stripped before the write: value, values, ciphertext, nonce, secret, token, password, connectionString, databaseUrl. Stripped, not rejected, because dropping a metadata key is always better than losing the event.
  • The export keeps the machine name next to the plain English one. "Opened the app" is what a reader understands; access is what a filter, a grep and a test can rely on when the wording is improved later.
  • Every cell in the CSV is neutralised against spreadsheet formula injection. The file is full of app names, email addresses and reasons that people typed, and it gets opened in Excel by the person least expecting a surprise.

Any member of the workspace can take the export, not only admins. That was a deliberate call, and the comment on the route says why.

An audit log that only an admin can read is an audit log nobody checks.

The export does carry a row ceiling, because it is the one endpoint whose natural size is the whole table.

Who turns it off when someone leaves

This is the question that decides whether a company can live with internal tools built this way. Not the login. The leaving.

There are two lists, and conflating them is the classic mistake. There is workspace membership, and there is the named list on each individual app.

Removing someone from the workspace does three things in one transaction: it revokes every API token they held in that workspace, deletes the membership, and writes an access event recording their email address and the role they had. The confirmation says plainly that any apps they own stay in the workspace, because that is the next thing the admin is about to wonder.

The per-app named lists are separate rows, on purpose, because a share panel belongs to the app owner rather than to IT. So the admin page prints something specific for a rule naming a user who is no longer a member: "someone who has left the workspace". Naming the leftover is the whole point of the wording. A count would hide it, and the person tidying up would never know there was anything to tidy.

Then there is how fast a change lands. A share or a revoke through the API bumps a version counter on the app, and that counter is part of the proxy's cache key, so the change takes effect on the very next request instead of waiting out a timer. A membership change does not bump it, so that one is bounded by the 30 second access cache. The kill switch is bounded by the 5 second app cache, and the order of operations there is deliberate: the database write happens first and the Cloud Run call second, because the proxy refuses a killed app from its own row lookup. Stopping the container is the cleanup, not the control. An admin hitting a kill switch must never be told to try again later.

// apps/proxy/config.go
AppCacheTTL    = 5 * time.Second   // ceiling on a killed app still being served
AccessCacheTTL = 30 * time.Second  // ceiling on a membership change landing

What is the same, and what is narrower

Plenty is the same. Every deploy is a version, rollback is one click, and a build that fails never replaces the version that works. Apps sleep when nobody is using them and wake on the next request.

Some of it is narrower, and saying so is more useful than pretending otherwise. The supported stacks are static HTML and JavaScript, Vite, Next.js, and single-file Python with Flask or FastAPI. Anything else is rejected at upload with a message naming what is supported and a link to a guide written for coding agents to read. Vercel's framework support is far wider than that, and a preview URL for every pull request is a workflow OurTools simply does not have. If your team lives in pull requests, that difference matters and it points at Vercel.

There is also a hard 25 MB ceiling on a single request through the proxy. Fine for a spreadsheet upload, wrong for a video pipeline.

Start by finding out what is already out there

Most of this is academic until you know how many of these tools your company already has. There is a free scan at ourtools.app/scan that uses public information only and needs no account.

It runs on two public sources. The first is certificate transparency: every subdomain that gets a TLS certificate lands in a public log, so the scan reads those logs for names pointing at the platforms it knows, alongside a short wordlist of likely subdomains resolved through DNS. The second is your own public website, read for links to those platforms. It then opens each candidate page once, the way any visitor would, recording the address, the page title and the platform fingerprint.

It does not use a web search engine, and the report says so on its face. The reasoning is in docs/adr/0007-scan-without-a-search-engine.md: a whole-web programmable engine can no longer be created, and nine of the fourteen platform hostnames the scan cares about are on the Public Suffix List, which a site-restricted engine refuses as a pattern. That is the same property that stops acme.lovable.app and rival.lovable.app sharing cookies, so it is working as intended. The cost is real coverage: an app that has never been issued its own certificate and is linked from nowhere will not appear.

It never logs in. It never uses a cookie or an API key, never follows a redirect into a login page, does not guess file names or try admin paths, and does not store page text, screenshots or anything that looks like personal data.

vercel.app and v0.app are on the list it checks, alongside lovable.app, bolt.host, replit.app, repl.co, base44.app and netlify.app, because an exported Lovable or Bolt project usually ends up on one of those.

You get a summary at a private link straight away: how many apps were found, which platforms they are on, and the date each one first appeared. If the address you enter is at the domain being scanned, a link to the full report goes to your mailbox, with every address found, the risk score and the reasons written out. Opening that link is what proves the mailbox is yours.

See what your own team has already put online

The free scan looks for apps published on the tools people build with, using nothing but public information. No account needed.

Run the free scan