An Invitation in Forgia is a standing entitlement attached to an email address. The Instructor pastes a roster, and for anyone without a confirmed account a row is written saying this address may have this Course, whenever it shows up. Confirm an account with that address and the Invitation becomes an Enrollment.
Every word of that hangs on that address. claim_invitations/1 looks up Invitations by exact email and nothing else — which is the right control, and also the whole problem. The invited person got an email at [email protected], clicked through to a blank registration form, typed the address they always type, and created a perfectly good account at [email protected] with no Course attached to it. The Invitation is still sitting there. Nobody is told.
So the sign-up link now carries the address it was sent to, and the registration screen opens already filled in.

The link is not a credential
The obvious instinct is to sign the thing. A Phoenix.Token, an expiry, a secret — the machinery you reach for when a URL grants access.
This URL grants nothing. Visiting it fills in a text field. An account only becomes real when the magic link lands in that inbox, and the Invitation is still claimed strictly by exact email, so forging [email protected] buys an attacker an account they cannot confirm, attached to a Course they were never invited to — which is to say, nothing at all. Signing it would have added a secret to rotate and forced an answer to a question the domain has already settled: an Invitation does not expire, so what would the token’s lifetime be?
Plain query param. Written down as ADR 0024, because “we deliberately did not sign this” is exactly the decision a new reader would otherwise assume was an oversight.
The lock has to be earned
The field is readonly — never disabled, which submits no value at all and would post a blank email straight past the lock.
But a read-only field that anyone can conjure by editing a URL is a trap. Someone hand-types ?email= with a typo and lands on a form they cannot correct. So the lock is not granted by the param; it is granted by the database agreeing with it:
flowchart TD
A["GET /users/register?email=…"] --> B{"usable email
in the URL?"}
B -- no --> N[":none
today's screen, untouched"]
B -- yes --> C{"does this address
already have an account?"}
C -- yes --> R[":redirect_to_login
send them to sign-in, prefilled"]
C -- no --> D{"any standing
Invitation for it?"}
D -- none --> P[":prefill_only
ordinary editable field, no banner"]
D -- one or more --> L[":locked
readonly + banner naming
the Course and the Instructor"]
A locked field therefore always means the same thing: we know who you are. Anything else — an Invitation already claimed, an address typed by hand, a param someone forged — is a prefilled convenience on a form that still works normally.

That whole diagram is one pure function over Invitations that have already been loaded. The context does the querying; InvitationLink.verdict/4 decides. Eighteen async: true unit tests, plain structs, no DataCase, no database — including the branches that are genuinely awkward to reach through a browser. The controller keeps one job: ask the question, render the answer.
The address that already has an account
An invited address may well already be registered — someone the Instructor invited to a second Course, say. Prefilling that into registration and locking it produces a very specific insult: the form rejects it with has already been taken, on a field the person is not allowed to edit.
So that case redirects to sign-in instead, prefilled the same way, with a flash saying the Course will be waiting.

Which is why the sign-in screen grew the same prefill, the same lock, and the same escape hatch. The Invitation is what earns the lock on both screens, so an account that was registered but never confirmed gets redirected here and is still locked once it arrives.
The first version of that screen showed two things: the flash explaining the redirect, and the invitation banner naming the Course. Both correct, both required by the acceptance criteria, and stacked on top of each other they were two identical green boxes saying “Elixir 101” twice. The flash wins — it says everything the banner does and explains why the URL changed — so the banner stands down whenever a flash is already speaking. On a direct visit to a locked sign-in URL there is no flash, and the banner appears as normal.
Every locked field needs a way out

“Use a different email” goes back to the bare URL with the field unlocked, and says plainly what the trade is: another address does not carry the course. That sentence is the entire justification for locking anything. Without it the screen is just being obstinate.
And a person who wants to defeat the lock does not need the link anyway — devtools will strip readonly in two seconds. That is fine, and it is worth being precise about why: they can submit whatever address they like, and it will register a perfectly ordinary account with no Course on it, because claim_invitations/1 binds to the email. The lock is a guardrail against a typo, not a security control. The security control was never in the browser.

The oracle that opened underneath it
Here is the part that was not in the original sketch.
GET /users/register used to be static. Now it asks the database two questions: does this address have an account, and what was it invited to — and it answers both in the rendered page, one via the redirect, the other by naming the Course and the Instructor in the banner.
That is an enumeration oracle. Anyone who can guess an address can ask this endpoint whether it exists here and which Courses it was invited to.
We took it deliberately, and the reasoning is in the ADR. The disclosed facts are low-stakes — course titles are public catalog data, and the Instructor is the person who already chose to email that address — and a named Course is the entire reason the screen converts an Invitation that would otherwise sit unclaimed. Naming nothing would have been safer and pointless.
What we would not accept is that oracle being fast. The Throttle already meters the login and registration POSTs, sharing one pair of counters so that alternating between the two endpoints buys no extra budget. It now covers the GET as well — but only when ?email= is present, since a bare visit spends nothing and touches nothing. The sign-in GET joined too: it is where the redirect lands and it runs the identical query, so leaving it free would have reopened the hole one URL over. The mitigation is rate, not secrecy.
Two things only the browser found
The acceptance criteria are covered by tests — the verdict’s branches as pure units, the throttle and the redirect and the two email bodies as controller tests. All green. Then the browser run found two things anyway.
The duplicate green box was one, and it could only ever have been seen. Both elements render correctly in isolation; LiveViewTest and ConnTest have no opinion about two correct things looking silly stacked.
The second was funnier. My verification script walked all eleven scenarios, and from step six onward every assertion failed — no prefill, no lock, no banner. It looked exactly like the sign-in screen ignoring the query param.
It was the Throttle, working. Every request came from 127.0.0.1, so the whole walkthrough shared one bucket and blew the per-IP burst ceiling somewhere around step five; from there the screens under test were quietly serving 429s. The fix was to give each visit its own X-Forwarded-For, the same trick the throttle tests use — and then to pin one IP on purpose, as a scenario, to prove the metering is real.
sequenceDiagram
autonumber
participant I as Instructor
participant A as App
participant S as Invited person
I->>A: paste roster, send
Note over A: one email per row —
never one email to everybody
A-->>S: “Mário invited you to Elixir 101”
/users/register?email=ana%40example.com
S->>A: GET with the param
A->>A: standing Invitations for this address?
A-->>S: locked field + banner
S->>A: POST (never touched the field)
A-->>S: magic link to that inbox
S->>A: confirm
Note over A: claim_invitations/1 —
bound to the exact email
A-->>S: enrolled
That “one email per row” note is not decorative. A bulk invite must never become a single message addressed to everybody: an Instructor’s roster is a list of other people’s addresses, and CC/BCC would hand the whole thing to each recipient. The code already sent one message per row with a single To: — but “already true” is not a guarantee, so it is now a test that asserts cc == [] and bcc == [] on every message of a three-row invite, with a comment saying why, so nobody later optimizes a privacy leak into existence.
What is still hardcoded English
The new copy is not translated. The auth templates have been hardcoded English since they were written, and this item followed that rather than starting a gettext migration inside a security change.
There is a real asymmetry underneath that. The invitation email goes through gettext — every string in it is translatable — but an invited person has no account, so there is no stored Preferred Language to resolve and the notifier falls back to the site default. Both ends are English today for different reasons: the email because nobody has told us otherwise, the screen because nobody has translated it. The first will fix itself the day an Instructor can say what language a roster reads in. The second is a chore waiting for someone to do it.