Codemojex · C7.1 · a dive of The Live Surface on Phoenix
Identity is verified, never trusted.
Telegram hands the Mini App a signed initData string. The floor verifies that signature with one pure function, exchanges it for a SES-branded session in the shared Valkey through one handshake — the sole mint — and gates every player-acting route on the :auth plug. The old unauthenticated mint (POST /api/players) is retired: a player exists only behind a verified handshake.
Grounding — echo/…/codemojex/init_data.ex (verify/3 · check_hash/3) · codemojex_web/controllers/auth_controller.ex (handshake/2) · session.ex (mint/3 · resolve/1 · revoke/1) · tables.ex (:cm_sessions · :tracking) · codemojex_web/auth.ex · cm.4 §9.
secret_key = :crypto.mac(:hmac, :sha256, "WebAppData", token) — the WebApp derivation, which DIFFERS from the older Login-Widget SHA256(token). Fail-closed: a nil token is {:error, :no_token} before any HMAC runs.The pure verifier
init_data.exCodemojex.InitData.verify/3 is a function over a string and a token: no HTTP, no Repo, no session store. The same verify/3 is provable offline with a test-signed fixture, so the HMAC path runs with no live Telegram. Every rejection lands in the closed error set — :missing | :malformed | :no_token | :bad_hash | :stale — and the verifier is fail-closed: a nil token (no bot token configured) returns {:error, :no_token} rather than authenticating.
# The data-check-string excludes BOTH "hash" and "signature" (pinned footgun),
# sorts the remaining keys, joins `key=value` with newlines. The compare is
# constant-time over the lower-case hex digest.
defp check_hash(raw, hash, token) do
data_check_string =
raw
|> Map.drop(["hash", "signature"])
|> Enum.sort_by(fn {k, _v} -> k end)
|> Enum.map_join("\n", fn {k, v} -> k <> "=" <> v end)
secret_key = :crypto.mac(:hmac, :sha256, "WebAppData", token)
expected =
:crypto.mac(:hmac, :sha256, secret_key, data_check_string)
|> Base.encode16(case: :lower)
if :crypto.hash_equals(expected, hash), do: :ok, else: {:error, :bad_hash}
end
The algorithm is pinned against the live Telegram WebApp docs, not memory, and two of its steps are the ones naive ports get wrong. First, the data-check-string excludes both hash and signature: signature is the newer Ed25519 third-party field, and a present signature left in the string corrupts the check. Second, the secret-key derivation is the WebApp one — HMAC-SHA256(key: "WebAppData", msg: token) — not the older Login-Widget SHA256(token).
The compare is :crypto.hash_equals(expected, hash) — constant-time, never ==: a non-constant-time compare is a timing oracle. Freshness rejects when now - auth_date > max_age_seconds (@default_max_age_seconds 86_400; :infinity disables the window), and opts[:now] injects the clock so a fixture is deterministic.
cm.4 §9 A4 pins the posture: a fixture signed with a known test token accepts; one flipped field is {:error, :bad_hash}; a present signature field is excluded (accept still holds); and a mutation flipping :crypto.hash_equals to a truthy constant must make the suite fail — the net-zero spot-check.
The handshake — the sole mint
auth_controller.ex · session.exPOST /api/auth/:platform (the AuthController.handshake/2 action) is the SOLE SES mint — the “issue here, verify everywhere else” point and the single writer the read-only-edge model depends on. The route is open: it issues the bearer, so there is no SES to present yet; the trust is in the initData signature, not a session.
def handshake(conn, %{"platform" => platform} = params) do
init_data = params["initData"] || get_init_data_header(conn)
with {:ok, %{tg_user_id: uid} = claims} <-
Codemojex.InitData.verify(init_data, Codemojex.Bot.token(), max_age_seconds: @max_age_seconds),
{:ok, plr} <- Codemojex.resolve_player_by_tg(uid, name: display_name(claims)),
{:ok, ses} <- Codemojex.Session.mint(plr, platform, %{"tg_user_id" => uid}) do
json(conn, %{session: ses, player: plr})
else
# A verify / resolve / mint failure is unauthenticated, not a server error.
# Render 401 here (the FallbackController's default for an unknown reason is
# 400, which would mis-signal a verify failure).
{:error, _reason} -> unauthorized(conn)
_ -> unauthorized(conn)
end
end
The chain is three verified steps. Codemojex.resolve_player_by_tg/2 is resolve-or-create: two handshakes for the same Telegram user resolve the same PLR — one players row, raced first-touch included (cm.4 §9 A2). Codemojex.Session.mint/3 generates EchoData.BrandedId.generate!("SES") — a 14-byte branded id, the 3-letter namespace SES + 11 Base62 over a ts|node|seq snowflake. The durable identity is the PLR (in Postgres); the SES references it and is ephemeral — Valkey-TTL + re-handshake on loss.
Any verify / resolve / mint failure renders a deterministic 401, never a 500, so a forged, stale, or unconfigured-token request is refused at the door. And the free-money gap is closed: POST /api/players is retired (cm.4 §9 A5 — the route is removed; a request is 404). The session value itself is JSON (Jason), the cross-language contract: EchoStore.Table.put frames it as SET ecc:{sessions}:<SES> (version<>json) PX ttl_ms — never :erlang.term_to_binary.
json.Unmarshal — read-only), bitmapist marking by the durable PLR, and SES Graft-durability are forward rungs; every one reads the same documented SES shape, and the floor forecloses none of them.The handshake reads initData from the initData body param, or the x-telegram-init-data header as a fallback; @max_age_seconds 86_400 is the freshness window it passes to the verifier. The browser pipeline runs the same exchange through CodemojexWeb.MiniAppAuth, landing the SES in the Plug session for LiveView — the only SES writer on the browser path, so the single-writer model holds on both surfaces.
The session and its revocation
tables.ex · auth.ex:cm_sessions is the first MUTABLE EchoStore.Table — kind "SES", coherence: :tracking. The two existing caches (:cm_games, :cm_emojisets) run coherence: :none only because each holds an entity that is immutable for its life. A session is mutable and revocable, so :none here would be a security defect: a revoked SES surviving in L1 would keep authenticating.
EchoStore.Table.invalidate drops both layers; the :tracking push (RESP3 CLIENT TRACKING) evicts the row from every BEAM holder’s L1 immediately — the next resolve/1 is {:error, :unknown}, the next request 401s. Revocation is a guarantee, not best-effort.# The auth sessions — the FIRST mutable table (cm.4). `:tracking` so a
# revoke (a DEL on ecc:{sessions}:<SES>) is pushed by Valkey and evicts the
# row from every BEAM holder's L1 immediately. The loader is a clean miss
# (a SES has no relational system of record — it lives only in Valkey), so
# an unknown/expired/revoked SES is a fetch miss → the :auth plug 401s.
Supervisor.child_spec(
{EchoStore.Table,
name: @sessions,
kind: "SES",
loader: &load_session/1,
coherence: :tracking,
ttl_ms: sessions_ttl,
max_size: 100_000,
connector: connector},
id: :cm_sessions_table
)
The loader is the quiet half of the design: a SES has no relational system of record — it lives only in Valkey — so load_session/1 answers a clean miss ({:error, :not_found}). An unknown, expired, or revoked SES is therefore a fetch miss, Codemojex.Session.resolve/1 maps it to {:error, :unknown}, and the request 401s. On a hit, resolve/1 re-puts the row unchanged — the sliding-TTL move re-stamps the version and the PX deadline, so an active player never re-handshakes mid-play.
The read side is the :auth plug (CodemojexWeb.Auth): it reads Authorization: Bearer <SES>, resolves through Codemojex.Session.resolve/1, and assigns conn.assigns.player — the ONLY identity a player-acting action reads. The router pipes the player-acting JSON routes through [:api, :auth]. The plug carries NO dev/test bypass (F2, D-2): a leaked auth-skip minting access into the shared Valkey would fool every service, so the suite mints a real SES via a test/support helper rather than a trust flag.
cm.4 §9 A3 names THE REVOCATION INVARIANT: mint a SES, prove an action authenticates with it, revoke/1 — and the NEXT request with that SES is 401, because :tracking evicted it from L1 and the DEL cleared L2. A10 holds the chapter’s privacy line: the game secret never crosses the wire (the privacy story scenarios stay green; the auth cutover does not touch the views).
References
sources · relatedSources
- Telegram — Bots: Web Appsthe initData signature algorithm the verifier is pinned against.
- Twitter Engineering — Announcing Snowflakethe ts|node|seq shape under the SES id.
- Helland — Life beyond Distributed Transactionsentities behind boundaries; the durable PLR against the ephemeral SES.
Related
- C7 — The Live Surface on Phoenixthe chapter this dive opens
- C1 — The Game as Branded Systemsthe SES is a branded id
- BCS — the architecture lawonly identities cross a boundary