Blocking users without deleting anything in Phoenix

· · 16 views
Table of Contents

Sooner or later you have to remove someone from a service. The obvious tool is DELETE FROM users, and it is the wrong one for a block: it is irreversible, it takes the person's feeds, entries and reading history with it, it takes the evidence of what they did, and it does not even stop them — they register again with the same address.

What I wanted was a block that is non-destructive. The account is refused on every surface it could possibly be used from, its email cannot register again, and if I unblock it a week later, everything is exactly where it was. This post is how that works in SlashFeed, and why most of the difficulty is not in the part you would expect.

One timestamp

The whole feature hangs on two nullable columns:

field :blocked_at, :utc_datetime
field :blocked_reason, :string

blocked_at is the state. There is no status enum and no separate table of blocked accounts — a user is blocked if the timestamp is set. Blocking stamps it, unblocking nulls it, and nothing else about the account changes. The migration is purely additive, a catalog-only change on modern Postgres with a partial index on blocked_at IS NOT NULL.

block_user/3 does four things in one transaction: stamp the account, remember its email, delete its session tokens, and cancel its queued jobs. Sessions are the only rows it removes, because they are cheap to recreate and because deleting them is what forces open browser tabs to disconnect. API tokens, MCP tokens, passkeys and subscriptions all stay — they are simply refused for as long as the block lasts, which is exactly what makes unblocking lossless.

Refuse at the lookup, not at every call site

An account can authenticate in a dozen ways: session cookie, password, magic link, SSO, passkey, Nostr, REST tokens, Miniflux and Google Reader credentials, Fever, the two MCP servers. If "is this user blocked?" has to be remembered at each of those, one will be forgotten.

So the check goes where the user is resolved, and each lookup simply stops finding a blocked account:

case Repo.one(query) do
  {%User{deleted_at: deleted_at}, _inserted_at} when not is_nil(deleted_at) -> nil
  {%User{blocked_at: blocked_at}, _inserted_at} when not is_nil(blocked_at) -> nil
  other -> other
end

That is the session lookup: a blocked user's valid cookie resolves to nil, which every LiveView already treats as "not logged in". Password login returns the same generic "invalid email or password" a wrong password gets — a block must not be distinguishable from a typo. The API-token lookup answers {:error, :revoked} for a blocked owner, and every caller already maps that to a 401, so REST, Miniflux and Google Reader needed no change at all. Fever returns nil.

The flows that resolve a user from an external proof — SSO, passkeys, Nostr, post-MFA — have no password lookup to intercept. They all converge on one function instead, so that is where they are stopped:

def log_in_user(conn, user, params \\ %{}) do
  if Accounts.blocked?(user) do
    reject_blocked_login(conn, user)
  else
    do_log_in_user(conn, user, params)
  end
end

reject_blocked_login/2 also records a failed login in auth_events, so a blocked account trying to get back in shows up in the security monitoring rather than vanishing silently.

One deliberate asymmetry: Accounts.get_user_by_id/1 does not filter. Admin tooling — impersonation, the admin MCP, the Users page itself — has to be able to see a blocked account, or there would be no way to unblock it. The rule is simple: if the lookup is driven by a credential a client presented, use get_active_user_by_id/1; otherwise the unfiltered one is fine.

Not letting them back in

Blocking the account is half of it; the address has to stay blocked too. Blocked addresses go into their own table, blocked_emails, with a citext email column so the match is case-insensitive. The registration changeset rejects them with the same message an existing account would produce:

if Blocking.email_blocked?(email),
  do: add_error(changeset, :email, "has already been taken"),

The same wording is the point: the public forms must not be usable to probe which addresses an admin has blocked.

The part I like most is what this table doesn't have: a foreign key to users. Deleting an account anonymises its email to deleted-<id>@deleted.invalid, so a flag on the user row would stop protecting the address the moment the account was deleted. A separate table keyed on the address itself survives account deletion, so a deleted blocked account keeps its address barred. The cost is a blocked_by column holding the admin's username instead of a reference — for an audit field, that is a perfectly good trade.

It is honest about its limit, too: it matches the exact address. [email protected] and [email protected] are different strings, and normalising provider-specific aliasing would lock out legitimate people sharing a base address. A determined person can register a new address. Blocking raises the cost; it does not make re-entry impossible, and IP-level control belongs at the edge.

The harder half: background work

Refusing the front door is the easy part. An account does things when nobody is logged in: a digest email is due, a webhook should fire, an automation triggers on a new entry, an agentic feed runs on a schedule, a newsletter arrives. Blocking has to stop work done on the account's behalf, and that is spread across a dozen workers and recipient queries.

Two tools make it tractable. For queries that pick recipients, a macro:

defmacro not_blocked(user_id) do
  quote do
    fragment(
      "NOT EXISTS (SELECT 1 FROM users bu WHERE bu.id = ? AND bu.blocked_at IS NOT NULL)",
      unquote(user_id)
    )
  end
end

Used as where: not_blocked(uf.user_id). It is written as NOT EXISTS so it stays a cheap anti-join on the partial index, and an id with no matching user counts as not blocked — the macro filters blocked accounts out, it does not assert that the account exists. For workers, whose jobs carry an id rather than a struct, there is the function form: if Blocking.blocked_id?(id), do: {:cancel, :user_blocked}.

Jobs that were already queued when the block happened are handled once, at block time, by cancelling them rather than deleting them:

Repo.update_all(
  from(j in "oban_jobs",
    where: j.state in ["available", "scheduled", "retryable"],
    where: fragment("?->>'user_id'", j.args) == ^user_id
  ),
  set: [state: "cancelled", cancelled_at: now]
)

Cancelled, so the row stays as evidence; and only jobs that have not started, so one already executing finishes and the worker's own blocked_id?/1 check stops whatever comes after it. It is plain SQL rather than Oban.cancel_all_jobs/1 because that function is a no-op under the Inline engine the test suite uses, and I wanted the cancellation tested for real.

There is one deliberate exception. Batched fan-out jobs (user_ids: [...]) are not cancelled at block time, because they carry other people's notifications — cancelling one would silently drop a message for ten innocent recipients. The delivery worker skips the blocked recipient instead.

I will be straightforward about the weakness of this half: nothing enforces it centrally. A new per-user worker that forgets not_blocked/1 or blocked_id?/1 will keep working on a blocked user's behalf, and no test will catch it unless someone writes one. The mitigation is documentation and a project rule, not a mechanism. If I find a good way to make "a worker that touches a user without checking" fail a test, that is the next improvement.

The admin side

On /admin/users every row has a Block button behind a confirm dialog, and a blocked row shows a badge whose tooltip carries the date and the reason, with an Unblock button in its place. A separate Blocked emails panel lets an admin bar an address that has no account at all — useful for an address someone has been abusing — and unblocking an address also unblocks an account that owns it.

The operation is idempotent: blocking a blocked user keeps the original timestamp and reason, so a second admin clicking the button cannot overwrite who did it and why.

What it deliberately doesn't cover

Work that does not act on the user's behalf keeps running: graph sync, the periodic survey prompt, ML retention, partition maintenance. Webhook events received from a third party about the account are still received. And a text-to-speech job that was already executing when the block landed runs to completion; new ones cannot be requested, because there is no session to request them from. I wrote these down in the docs rather than leaving them to be discovered.

Takeaways

  • Model a block as state on the account — one nullable timestamp — not as deletion. Deleting is irreversible, destroys evidence, and does not stop the person returning.
  • Refuse at the lookup, not at each call site. Make every place that resolves a user from a credential stop finding a blocked one, and the dozen authentication paths mostly fix themselves.
  • Find the function that all the external-proof flows converge on (log_in_user/3) and put one check there. SSO, passkeys and Nostr then need no code of their own.
  • Make a block indistinguishable from a wrong password, and make the registration error identical to "already taken", so neither form leaks which accounts or addresses are blocked.
  • Keep the do-not-register list in its own table with no foreign key, so anonymising or deleting the account does not release the address.
  • Background work is the hard half. Give workers and recipient queries two small, obvious tools (blocked_id?/1, a not_blocked/1 macro), cancel queued jobs instead of deleting them, and leave batched jobs alone and skip the recipient.
  • Be honest where the design relies on discipline rather than enforcement, and write down what it does not cover.

The satisfying property is how little is destroyed. Everything a blocked user owns is still in the database, refused rather than removed — which turns the scariest button in the admin into one a person can press and, if they were wrong, undo.