· 9 min read

External collaborators without IdP sprawl

An external contractor needed access to one of our tools through Claude for six weeks. The default options were all wrong. Here's the third path we built into mcpgate — per-service guests with magic-link login — and the pitfall we shipped, reverted, and learned from.

A contractor joins your team for a six-week engagement on one product area. They need to use Claude against your Confluence and Jira — not Slack, not GitLab, not Drive. Just those two. By the end of the engagement they should be gone, and "gone" should mean gone, not "still has a dormant account that survives the next access review by accident."

This is not an exotic case. Anyone running an MCP gateway for a team hits it within the first two quarters: outside collaborators, auditors, partner-org engineers, short-term agency staff. The mechanics of giving them just-right access turn out to be much harder than the request sounds — and they are what pulled the guest model into the gateway as its own primitive.

The three options that were already in the room

Before writing any code we walked through what people already do. Each one fails in a different way.

Option 1: Add them to your IdP as a B2B guest. Microsoft Entra calls these "external users." Google Workspace calls them "essentials." All of them surface the same trade-off: the guest gets visibility into your directory, often counts against a license tier, and you now have to make a separate decision about which Azure groups or Google OUs they belong to. The blast radius is wider than the actual access you wanted to grant. And when the engagement ends, deprovisioning is a multi-step process that crosses your IT team's calendar.

Option 2: Share an employee account or generate a service-account credential the contractor uses. Audit collapses immediately — you can no longer tell whether a deleted Jira issue was the contractor or the engineer the account belongs to. MFA either breaks (shared TOTP secret, which is exactly the failure mode the auditor was hired to catch) or works for one person and not the other. Token rotation becomes a coordination problem. This is the option that always looks tempting at 6 pm on a Friday and is always wrong on Monday morning.

Option 3: Spin up a second gateway instance just for outsiders. Looks clean. Read the operational implications: two installations to update, two sets of OAuth client credentials per upstream service (because each gateway must register independently with Jira, Confluence, Drive, …), two audit logs that must be cross-referenced for any compliance question, two Slack channels for alerts, two Grafana dashboards. The complexity tax compounds with every new outsider engagement and never reduces.

None of these are wrong because the people who pick them are wrong. They are wrong because the gateway didn't have a primitive that matched the request. The request is "this person reaches these services, full stop, for this window." Every option above answers a different question.

What we wanted the primitive to look like

Working backwards from "this person reaches these services, full stop":

  • The unit of authorization is a list of services attached to an email address. Not a role with predefined permissions; not a group with a name; not a tier. The list is the policy.
  • The login mechanism is independent of the authorization. We can't require the guest to enrol in our IdP — the whole point is that they shouldn't be there. But we also can't require them to install anything or share secrets out of band.
  • Lifecycle is the admin's responsibility, but mistakes should be cheap. Setting an expiry date should be normal. Revoking should be one click. There should be no "soft-deleted" state where a former guest can still hit a stale session.
  • Everything else — audit logging, PII sanitization, policy hooks, throughput thresholds — should treat the guest identically to an employee. The gateway already has good machinery for those. Forking it for guests would be the worst outcome.

That last point ruled out a lot of clever ideas. We didn't want guests in their own audit log or with their own PII rules. The whole compliance story stays coherent because every actor goes through the same observability surface; we'd defeat it by splitting.

The shape that landed

A guest record is a small Redis JSON blob keyed by the SHA-256 hash of the email address. The fields that matter:

  • email_hash — the lookup key. SHA-256 hex of the lowercased email.
  • services — the allowlist. An array like ["jira", "confluence"].
  • invited_by, invited_at, note — provenance for the admin team page.
  • expires_at — ISO timestamp, optional. If set, the record stops authorizing once the time passes.
  • auth_method"magic_link" for the default flow. Other values are reserved for future paths.
  • preferred_idp — a UX hint that highlights the matching sign-in button on the login page. Pure cosmetics — the actual authorization is still by email hash.
  • last_seen_at — stamped on every successful login, so the admin can tell at a glance which invites have been picked up and which are still sitting in someone's inbox.
  • email_encrypted — the plaintext address, envelope-encrypted so the admin team page can render "vendor@acme.com" rather than an opaque hash.

The plaintext email is not the lookup key — only the hash is. That matches the gateway-wide rule that PII never appears in Redis keys, Postgres identifiers, or any persistent key namespace. We still need the plaintext somewhere — otherwise the admin team page would show a wall of opaque hashes instead of "vendor@acme.com" — so we envelope-encrypt it on the record itself, decryptable in the admin UI but not visible in Redis dumps.

The login path: the guest enters their email on the login page, the gateway checks whether a guest record exists, and if it does, sends a single-use magic link valid for 15 minutes. The link carries a signed JWT — no Redis storage for the link itself, so a flood of invalid attempts doesn't bloat the store. When the link is clicked, a session is created, the record's last_seen_at is stamped, and the guest is in.

The authorization path: every request resolves the actor's email from the session, hashes it, and looks up tool_guest:{email_hash}. If the lookup hits, the request's target service is checked against the record's allowlist. Anything outside the list returns 403 at the gateway boundary — the upstream API is never reached. If the lookup misses, the request falls through to the normal employee authorization path. Two paths, one decision per request, no branching in the upstream proxy.

AI client (guest) Claude / ChatGPT Session resolve email SHA-256 hash Redis lookup tool_guest:{hash} 30s cache Allowlist check requested service in record.services? Forward upstream Jira / Confluence / … 403 at gateway upstream never touched in list not in list Audit log — every decision recorded (actor hash, service, action, result, timestamp)

This diagram is for the guest path specifically. Employee requests bypass the Redis lookup entirely — their authorization comes from the configured IdP group memberships, evaluated once at session start. Both paths land in the same audit log so the operator's view of "what happened" is unified.

The hot path uses a small in-process cache — 30-second TTL, invalidated on every write — because is_authorized_email() lands here on every single request and we didn't want a Redis round-trip per call.

The pitfall we shipped and reverted in the same evening

This part of the story is here on purpose, because it captures the kind of mistake the model itself made tempting.

Once OIDC sign-in was wired up — so that a guest whose email is on a configured provider could click "sign in with Google" instead of waiting for a magic link — we ran into a cosmetic problem. A guest who signed in via OIDC sometimes showed up in both the Members and the Guests sections of the admin team page. The same person, two rows, slightly different metadata.

The "fix" we shipped, as v2.0.903 at 22:30 on 26 May 2026: on a successful OIDC sign-in matching a guest record, revoke the guest record — on the theory that the OIDC path is now the canonical login and the guest record is redundant. The duplicate disappears, the team page cleans up.

That shipped, and broke things almost immediately. The per-service allowlist on the guest record is the whole reason the guest model exists. Deleting it on OIDC sign-in didn't promote the user to an authenticated employee with the same scoped access — it dropped them into the broader "external" bucket the IdP grants by default, which is much wider than the one or two services the admin had originally invited them for. We had quietly broken the principle that motivated the model.

v2.0.904 reverted it 28 minutes later, at 22:58 the same evening. The bridge now stamps last_seen_at on OIDC sign-in but leaves the guest record entirely alone. The duplicate-row display is a known cosmetic issue, and the right fix for it is visual de-duplication in the team page (merge into one row with a "Guest" badge), not data deletion. There is a comment block in src/auth/oidc_bootstrap/bridge.py that records this exact attempt and why it was reverted, in case anyone in the future feels the same intuitive pull.

The lesson: when a feature is built around scoped permission, any automation that runs in the direction of "remove the scope because the user has another login path now" is almost always wrong. The scope is the point.

The shapes this fits

Three categories of collaborator the model is built for — the same three that come up most often in conversations with other teams running gateways:

  • A contract designer or developer on a fixed-scope engagement. Grant the one or two services they actually need (e.g. figma and jira, or gitlab and a single Jira project). They cannot see anything else — which they don't need and which would be awkward to explain to their primary client anyway.
  • An external auditor on a time-bounded review. Grant read-only on the relevant services with an expiry date on the invite. Access lapses automatically when the date passes; the audit log entries from the engagement remain queryable for evidence retention.
  • A partner-org engineer collaborating on a shared integration. Grant the specific services in scope. The partner has their own corporate IdP; you do not federate, do not enrol, do not negotiate a cross-tenant trust. Two-minute invite, two-minute revoke at the end of the project.

Each of these is a multi-day procurement-and-IT exercise under any of the three default options earlier. Under the guest model they become two-minute decisions by the team lead.

What is still open

The biggest gap: federated guest IdP. The current model works because magic-link is the floor — it works for any email. OIDC sign-in is supported but only against IdPs we have configured on our gateway. If a partner-org wants their engineers to sign in against their own Microsoft Entra tenant, we cannot do that today; they have to use the magic link instead. The design for it is being worked on — the term we are using internally is "multi-IDP guest auth" — and the right answer routes through the existing OIDC bootstrap path so we are not building a parallel auth subsystem.

The smaller gap: sub-service granularity. The allowlist today is service-level. "jira but only project PBE" is not expressible in the record itself. The escape hatch is a policy hook — hooks see the actor's email and can branch on it — but that puts the logic in code rather than in the admin UI. Whether to lift this into a structured per-service config or leave it to hooks is an open question; we have not had enough cases yet to know which way the curve points.

What I would do differently

Two things, looking back.

I would have written the doc page (the concept page) at the same time as the code, rather than six months later. The model is not technically complex, but the why-not of each alternative is the part that drives adoption, and that lives best in prose. Without it, the feature exists in the admin UI but the team's instinct is still to default to the three wrong options because those are the ones they were trained on.

And I would have written down the OIDC auto-revoke decision in the design phase, instead of discovering it in production. The pull of "let's clean up the duplicate" is strong precisely when the model is still abstract enough that the scope feels like an implementation detail. Saying out loud "the scope is the point, never delete it from any automation" before shipping any feature involving the guest record would have saved one release-cycle of revert.

Where to go from here

The concept page is the operational reference — how guests are invited, what the admin team page does, how authorization is evaluated, what the limits are. If you are running an mcpgate instance and want to try it: an admin invites a test address, the link arrives, you click it and you have a guest session against a scoped service list. The same audit trail you already use shows the new actor.

If your gateway is connected to ten services and someone outside your team needs to use one of them through Claude for a week, this is the primitive that turns that into a two-minute decision rather than a procurement workflow.