Make sign-up invitation-only

Prompt:

Only let people I invite use this for now. Everyone else should see a short coming-soon page.

Also works:

  • "Lock the app down to the beta group until launch."
  • "Nobody gets in without an invite yet, and search engines shouldn't see it."

What your tool does

  • Reads the add-sign-in skill (read_context, id skill:add-sign-in) and follows it.
  • Removes the work-account route first with configure_realm where a realm offers it.
  • For a notice page, keeps the public audience and writes the page into the application, with the session check on every page request and the optional header of step 5.
  • Asks you which of the two set-ups in step 1 you want, where the prompt does not say, and for the addresses to invite where it names none.

What you need

  • The email addresses of the people to invite, and a way to send each person their invitation link yourself.
  • For a notice page, its wording, in whichever language you choose.

Before your AI starts

This section is for your AI tool: what it checks and gathers before it begins. You don't need to do these steps yourself.

  • A connected, signed-in tool (Connect Claude Code or Codex).
  • A manifest that declares the accounts service, and the application's identifier (Add sign-in to your app).
  • Each realm's sign-in methods, from read_realm: invitation-only sign-up cannot be combined with the entra route.

Steps

1. Choose who may sign up

Two settings decide who may become a user and who may reach the pages. The realm's creation is open or invited. Under open, anyone who signs in becomes a user. Under invited, a first sign-in without an invitation creates no user and is refused unrecognized_invitation.

The manifest's audience sets who may reach the application. The platform checks it before it forwards a request.

  • public needs no end-user sign-in.
  • invited needs an end-user session and accepts new users only by invitation.
  • workforce allows only signed-in users of the declared Microsoft Entra tenant (Add work-account or Apple sign-in).

Pick one of three set-ups:

You want Audience Creation
Anyone may sign up public open, the new realm's default
Only invited people, every page behind sign-in except the webhook paths you list in session_free_paths invited Invitation-only by the audience
Only invited people, with a notice page of your own public invited, set on each realm with configure_realm

Under invited and workforce, a request without an end-user session is refused, on every path but /__account/ and the paths the audience lists as session-free. A visitor who opens a page is sent to the sign-in page, and any other request is refused 401 authentication_required.

A payment provider's webhook, or any other service's callback, has no session. To receive one under those two audiences, list its path in the audience's session_free_paths, for example {"kind": "invited", "session_free_paths": ["/webhooks/stripe"]}. The platform lets every request to a listed path through without checking who sent it. So the route at each listed path checks the provider's signature itself and refuses a request without a valid one.

The list has at most ten paths. Each starts with /, is not / alone, contains no spaces and no .., and is outside /__account/ and /__router/ in any letter case. A listed path also covers every path below it: /webhooks/stripe covers /webhooks/stripe/events, but not /webhooks/stripe-test. A public audience lists none, because it lets every request through already. A path that breaks a rule is refused manifest_invalid at its own place in the list.

A request whose path contains a dot segment, .., or an encoded dot, slash, backslash, or percent sign is never read as a listed path and is gated as usual.

The manifest's health path is gated like every other path. The platform's own deploy check still reaches it, and a request from outside without a session is refused unless the list names the path. Choosing the health path says what listing it opens.

One manifest covers every environment the application has, so the audience applies to each. Where the application has a development environment, anyone who knows its hostname can reach it and its data under a public audience. The platform does not publish that hostname, and every response on it includes X-Robots-Tag: noindex, nofollow. Treat development test data as public under a public audience, or declare an invited audience while the application is in development.

2. Turn it on

For every page behind sign-in, set the manifest's audience to {"kind": "invited"}, with any session_free_paths, and submit it. Every realm of the application is then invitation-only, whatever its creation setting. configure_realm refuses creation: "open", and read_realm still shows the configured value, with a detail saying creation is invitation-only because of the audience. To allow sign-ins without an invitation again, change the audience to public and resubmit the manifest.

For a notice page, keep the public audience and call configure_realm with creation: "invited", once for production and once with environment: "development" where development is on. A test checks these members against the action's request.

{ "application": "<application id>", "creation": "invited" }

A manifest declaring invited while a realm's sign-in methods include entra is refused declared_audience_contradicts_route; remove the route with configure_realm first. Under invitation-only sign-up, an emailed code is sent only to an address that belongs to a user of the realm or matches the invitation that browser opened. The page looks the same either way, so it does not reveal which addresses are known.

3. Invite each person

Call issue_invitation with the application's identifier and the person's email, and with environment: "development" for the development realm. It returns an invitation with its id, email, single-use url, and expires_at, 14 days away by default. The platform does not email it, so you deliver the URL yourself.

Opening the URL opens the application's sign-in page with the realm's sign-in methods. The page shows nothing of the invitation, so a spent or expired URL looks the same. The person signs in with a route that verifies the invited address. list_invitations shows the invitation as standing, then redeemed after the person signs in. Manage end users covers listing and revoking invitations.

4. A private beta: a notice page of your own

A private beta with a notice page takes three pieces your application already controls, and an optional fourth. The platform has no single switch for it. Each piece is a few lines of your own code, and the notice's words, language, and design stay yours:

  1. Invitation-only sign-up under a public audience (step 2).
  2. Sign-in required on every page: your backend verifies the session on every page request, as Tell which user sent a request shows. With invitation-only sign-up, every signed-in user was invited.
  3. A notice page for everyone else, this step.
  4. Optionally, a request header for your own tools (step 5).

The notice page needs the public audience. Under the invited audience, the platform sends a visitor without a session to the sign-in page before any code of yours runs, so the visitor never sees the notice page.

For a visitor without a session, serve a static page of your own. While the beta lasts, keep search engines out:

  • give the page <meta name="robots" content="noindex">;
  • send it with the response header X-Robots-Tag: noindex, nofollow;
  • leave every path allowed in /robots.txt, or serve none. A search engine reads noindex only on a page it may fetch, so Disallow: / would hide the noindex and leave the address listable.

The page is a file of your application, so its words and language are yours. Give it a link to the sign-in page for the people you invited.

5. Optional: a request header for your own tools

Your own tools may need to read the pages during the beta, such as a coding agent with a shell or a check script. For them, accept one request header, for example X-Show-Page. Serve the page to any request that includes the header, with any value. Send the same noindex headers, and Vary: X-Show-Page.

A person in a browser cannot set a request header, and a crawler sends none, so both still see the notice. A shell reads a page with curl -H "X-Show-Page: 1" <url>.

The header is a convenience, not security: anyone who learns its name can read the pages. That is the same protection a beta notice gives. Never let the header open a page that shows a user's data; keep such pages behind sign-in.

The platform's own documentation site uses the same pattern while the site is private, as its home page describes.

Expected result

Under the invited audience, submit_manifest responds with the realms confirmed, and read_realm shows the configured creation value with a detail saying creation is invitation-only because of the audience. Under a public audience with invitation-only sign-up, configure_realm and read_realm show creation: "invited".

issue_invitation returns the invitation's id, email, single-use url, and expires_at, and its detail says nothing was emailed. After the person uses the URL, list_end_users lists them with standing: "active" and the route they used, and list_invitations shows the row as redeemed.

On the deployed hostname:

  • a person who opens an invitation URL signs in and reaches the pages;
  • a sign-in without an invitation creates no user and is refused unrecognized_invitation;
  • under the invited audience, a visitor without a session is sent to the sign-in page;
  • under a public audience with a notice page, a visitor without a session receives your notice page with the noindex header, and /robots.txt disallows no path;
  • a request from a shell with the optional header receives the page, and a browser without a session still receives the notice.

Refusals

This table lists the refusals these steps most often meet; the refusals page lists every refusal with its cause and remedy.

Refusal Status Cause Remedy
unrecognized_invitation 403 Under invitation-only sign-up, a first sign-in came without a valid invitation for that address. Issue a new invitation for the address the person signs in with, and deliver its URL.
creation_contradicts_audience 409 configure_realm named creation: "open" under the invited audience. Leave creation out or name invited. To allow sign-ins without an invitation, change the audience to public and resubmit the manifest.
declared_audience_contradicts_route 409 submit_manifest declared the invited audience while a realm's sign-in methods include entra. Remove entra from that realm's sign_in_methods with configure_realm, then submit the manifest again.
invited_realm_refuses_work_account 409 configure_realm combined invitation-only creation with the entra route. Leave entra out of sign_in_methods (Add work-account or Apple sign-in).
manifest_invalid 400 A session_free_paths entry breaks a rule of step 1. The detail names its place in the list. Correct or remove that path and submit the manifest again.
authentication_required 401 Under an invited or workforce audience, a request other than a page visit came without an end-user session, to a path the audience does not list as session-free. The person signs in at /__account/signin first.
invalid_request 400 issue_invitation named no valid email. Give the person's address and call again.