ADR-0011 — Callback data signing & validation hygiene
**Status.** Accepted.
Status. Accepted. Date. 2026-05-18.
Context
aiogram's default CallbackData factory serialises payloads as a colon-delimited string up to 64 bytes. Two risks:
- A user can forward another user's inline message and press its button; without per-user verification, the handler receives a callback whose payload was crafted for someone else.
- A user with a packet capture / inspecting Web app callbacks could replay or mutate a previously-issued button's payload.
We need every callback to be verifiable: emitted by us, intended for this user, not tampered with, not replayable beyond a reasonable window.
Decision — signed CallbackData
Every CallbackData factory in apps/bot/keyboards/factories.py wraps a base payload with a signature segment:
{prefix}:{nonce}:{ts}:{tail}:{sig}
Where:
prefix— a short, stable identifier of the action (e.g.tgt.add).nonce— a base64url(6 random bytes) issued at button render time, also stored in Redis keyedcb_nonce:{tg_user_id}:{nonce}with TTL =callback.signature.ttl_seconds(default 600 s).ts— render time (seconds since epoch, last 9 digits).tail— application payload, separated by additional:if multi-field, base64url-encoded.sig—base64url(HMAC_SHA256(callback_secret, f"{prefix}:{nonce}:{ts}:{tail}:{tg_user_id}"))[:8]— truncated, 8 bytes is enough at our scale and fits the 64-byte cap.
The callback_secret is a random 32-byte value held in infrastructure/crypto/aes.MASTER_KEY via HKDF (info=b"helios-cb-v1"). Hot rotation is supported by keeping the previous key for callback.signature.rotation_grace_seconds.
Decision — verification on every dispatch
A single global filter apps/bot/filters/signed_callback.py runs before any handler:
- Parse
prefix:nonce:ts:tail:sig. - Reject if
now - ts > callback.signature.ttl_seconds. - Recompute the HMAC. Reject if not matching (timing-safe compare).
- Look up Redis
cb_nonce:{tg_user_id}:{nonce}. Reject if absent (replay / cross-user). - Delete the nonce immediately when the action is non-idempotent (the factory marks idempotent actions with
idempotent=True). - Pass the typed payload to the handler.
A failed verification answers the callback with a localised error toast and emits bot.callback_rejected with the rejection reason.
Decision — typed payload contracts
The CallbackData factory uses Pydantic v2 dataclasses internally so payload fields are strongly typed at both render and dispatch. Handlers receive a payload: TypedPayload argument, not a raw string.
Decision — wider validation hygiene
Beyond callbacks:
- Mini App
initData— verified server-side on every API request (apps/api/middlewares/initdata.py) with HMAC ofdata_check_string. Cache freshness limitmini_app.initdata.max_age_seconds, default 86 400 (per Telegram guideline). - Web portal JWT (ADR-0007) — Telegram OIDC verified once at login; subsequent requests carry our own short-lived JWT with
aud=helios-portal, refreshed via cookie. - Webhook signatures — every plugin webhook (
payment_provider/) verifies HMAC with a per-route secret stored AES-encrypted inpayment_provider_configs. - API tokens — sha256-hashed at rest, scoped per route, rate-limited per token, optional IP pin.
- Pydantic everywhere at the boundary: FastAPI request bodies, JSON columns, plugin settings schemas — all validated.
@validated_actiondecorator at handler entry, layered on top of the role/tier filters, runs a feature-specific Pydantic schema against the parsed payload (extra defence beyond callback HMAC).- Rate limit per action category —
bot.callback.rate_per_minute(default 60), per-tg-user. Same token-bucket as the existing throttle.
Decision — server-issued ephemeral codes
When a Mini App page issues an action that involves a callback (e.g. "Save settings"), the page calls /v1/actions/issue which returns an opaque token containing the same prefix:nonce:ts:tail:sig shape, allowing the Mini App to round-trip an action through the bot inbox if it needs to (e.g. to send an invoice). This keeps callback semantics identical across surfaces.
Decision — what stays unsigned
Read-only navigation (menu transitions: menu:targets, menu:billing) is also signed even though it's idempotent — uniform behaviour beats special-casing, and the cost is one HMAC per render.
Consequences
- Cross-user replay is impossible (the HMAC binds to
tg_user_id). - Time-bounded actions can't be silently mutated.
- Every reject is observable in the audit log.
- Key rotation costs five seconds, not a deploy.
See also
apps/bot/keyboards/factories.pyapps/bot/filters/signed_callback.pyinfrastructure/crypto/hkdf.pytests/security/test_callback_signing.py