#776 - implement 50k character limit for vote comment and support passing form errors through a cache in the form module instead of via flash in the session
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index cca450a..113f17e 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -146,7 +146,7 @@
 
     Allows an admin to browse as another user.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=BrowseAsUserForm,
         submit_label="Browse as this user",
     )
@@ -320,7 +320,7 @@
 
     committee_choices = [(c.key, c.display_name) for c in committees_with_keys]
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=DeleteCommitteeKeysForm,
         submit_label="Delete all keys for selected committee",
         defaults={"committee_key": committee_choices},
@@ -416,7 +416,7 @@
     else:
         releases_widget_with_help = htpy.p(".text-muted")["No releases found in the database."]
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=DeleteReleaseForm,
         submit_label="Delete selected releases permanently",
         submit_classes="btn-danger",
@@ -452,7 +452,7 @@
     if not config.get().ALLOW_TESTS:
         return quart.abort(404)
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Delete all OpenPGP keys for test user",
         empty=True,
@@ -511,7 +511,7 @@
 
     Check public signing key details.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Check public signing key details",
         empty=True,
@@ -540,7 +540,7 @@
 
     Display the form to regenerate KEYS files.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Regenerate all KEYS files",
         empty=True,
@@ -582,7 +582,7 @@
 
     Update keys from remote data.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Update keys",
         empty=True,
@@ -623,7 +623,7 @@
     """
     URL: GET /ldap
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=LdapLookupForm,
         submit_label="Lookup",
     )
@@ -662,7 +662,7 @@
         end = time.perf_counter_ns()
         log.info(f"LDAP search took {(end - start) / 1000000} ms")
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=LdapLookupForm,
         submit_label="Lookup",
         defaults={"uid": uid_query, "email": email_query},
@@ -806,7 +806,7 @@
 
     Update projects from remote data.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Update projects",
         empty=True,
@@ -861,7 +861,7 @@
         rows = await data.execute_query(stmt)
         token_counts = [(row[0], row[1]) for row in rows]
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=RevokeUserTokensForm,
         submit_label="Revoke all tokens",
     )
@@ -900,7 +900,7 @@
     """
     URL: GET /rotate-jwt-key
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=RotateJwtKeyForm,
         submit_label="Rotate JWT key",
     )
@@ -1072,7 +1072,7 @@
 
     Display the page with a button to toggle between admin and user views.
     """
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=form.Empty,
         submit_label="Toggle view",
         empty=True,
@@ -1133,7 +1133,7 @@
         _require_debug_and_allow_tests()
     except base.ASFQuartException:
         return quart.abort(404)
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=ValidateJwtForm,
         submit_label="Validate JWT",
         textarea_rows=8,
@@ -1175,7 +1175,7 @@
             "message": str(exc),
         }
 
-    rendered_form = form.render(
+    rendered_form = await form.render(
         model_cls=ValidateJwtForm,
         submit_label="Validate JWT",
         textarea_rows=8,
diff --git a/atr/blueprints/admin.py b/atr/blueprints/admin.py
index e0a75fc..df4c8eb 100644
--- a/atr/blueprints/admin.py
+++ b/atr/blueprints/admin.py
@@ -99,7 +99,7 @@
             try:
                 kwargs[form_param_name] = await enhanced_session.form_validate(form_cls, context)
             except pydantic.ValidationError as e:
-                return await common.flash_form_error(form_cls, e)
+                return await common.flash_form_error(form_cls, enhanced_session, e)
             if form_safe_params:
                 await common.validate_safe_fields(kwargs[form_param_name], form_safe_params, kwargs)
 
diff --git a/atr/blueprints/common.py b/atr/blueprints/common.py
index 704c906..7465750 100644
--- a/atr/blueprints/common.py
+++ b/atr/blueprints/common.py
@@ -233,10 +233,8 @@
             setattr(instance, name, temp[name])
 
 
-async def flash_form_error(form_cls: type, error: pydantic.ValidationError) -> Any:
+async def flash_form_error(form_cls: type, session: web.Public, error: pydantic.ValidationError) -> Any:
     """Flash form validation errors and return a redirect to the current page."""
-    import json
-
     errors = error.errors()
     if len(errors) == 0:
         raise RuntimeError("Validation failed, but no errors were reported")
@@ -244,7 +242,8 @@
     flash_data = form.flash_error_data(form_cls, errors, form_data_raw)
     summary = form.flash_error_summary(errors, flash_data)
     await quart.flash(summary, category="error")
-    await quart.flash(json.dumps(flash_data), category="form-error-data")
+    if isinstance(session, web.Committer):
+        await form.validation_cache_add(session.asf_uid, quart.request.path, flash_data)
     return quart.redirect(quart.request.path)
 
 
diff --git a/atr/blueprints/post.py b/atr/blueprints/post.py
index 0a8a7e5..58acc39 100644
--- a/atr/blueprints/post.py
+++ b/atr/blueprints/post.py
@@ -26,7 +26,7 @@
 import quart
 
 import atr.blueprints.common as common
-import atr.form
+import atr.form as form
 import atr.log as log
 import atr.models.safe as safe
 import atr.web as web
@@ -81,10 +81,10 @@
                     case web.Committer() as committer:
                         kwargs[form_param_name] = await committer.form_validate(form_cls, context)
                     case None:
-                        form_data = await atr.form.quart_request()
-                        kwargs[form_param_name] = atr.form.validate(form_cls, form_data, context=context)
+                        form_data = await form.quart_request()
+                        kwargs[form_param_name] = form.validate(form_cls, form_data, context=context)
             except pydantic.ValidationError as e:
-                return await common.flash_form_error(form_cls, e)
+                return await common.flash_form_error(form_cls, enhanced_session, e)
             if form_safe_params:
                 await common.validate_safe_fields(kwargs[form_param_name], form_safe_params, kwargs)
 
diff --git a/atr/form.py b/atr/form.py
index f84a7e4..4db0b5c 100644
--- a/atr/form.py
+++ b/atr/form.py
@@ -18,12 +18,13 @@
 from __future__ import annotations
 
 import enum
-import json
 import pathlib
 import re
+import time
 import types
 from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, TypeAliasType, get_args, get_origin
 
+import asfquart
 import htpy
 import markupsafe
 import pydantic
@@ -75,6 +76,9 @@
     URL = "url"
 
 
+_FORM_ERROR_TTL_SECONDS: Final[float] = 60.0
+
+
 def csrf_input() -> htm.VoidElement:
     csrf_token = utils.generate_csrf()
     return htpy.input(type="hidden", name="csrf_token", value=csrf_token)
@@ -151,7 +155,13 @@
 
 
 def label(
-    description: str, documentation: str | None = None, *, default: Any = ..., widget: Widget | None = None, **kwargs
+    description: str,
+    documentation: str | None = None,
+    *,
+    default: Any = ...,
+    widget: Widget | None = None,
+    max_length: int | None = None,
+    **kwargs,
 ) -> Any:
     extra: dict[str, Any] = {}
     if widget is not None:
@@ -160,7 +170,7 @@
         extra["documentation"] = documentation
     if len(kwargs) > 0:
         extra.update(kwargs)
-    return pydantic.Field(default, description=description, json_schema_extra=extra)
+    return pydantic.Field(default, description=description, json_schema_extra=extra, max_length=max_length)
 
 
 def name_and_label(form_cls: type[Form], i: int, loc: tuple[str | int, ...]) -> tuple[str, str]:
@@ -236,19 +246,7 @@
     raise ValueError(f"Discriminator value {discriminator_value} not found in union type: {alias_value}")
 
 
-def _get_flash_error_data() -> dict[str, Any]:
-    flashed_error_messages = quart.get_flashed_messages(category_filter=["form-error-data"])
-    if flashed_error_messages:
-        try:
-            first_message = flashed_error_messages[0]
-            if isinstance(first_message, str):
-                return json.loads(first_message)
-        except (json.JSONDecodeError, IndexError):
-            pass
-    return {}
-
-
-def render(  # noqa: C901
+async def render(  # noqa: C901
     model_cls: type[Form],
     action: str | None = None,
     form_classes: str = ".atr-canary.py-4",
@@ -266,6 +264,7 @@
     skip: list[str] | None = None,
     confirm: str | None = None,
     submit_disabled: bool = False,
+    uid: str | None = None,
 ) -> htm.Element:
     if action is None:
         action = quart.request.path
@@ -279,7 +278,9 @@
     elif border and (".px-" not in form_classes):
         form_classes += ".px-5"
 
-    flash_error_data: dict[str, Any] = _get_flash_error_data() if use_error_data else {}
+    error_data = {}
+    if use_error_data and uid:
+        error_data = await _validation_cache_get(uid, quart.request.path)
     field_rows: list[htm.Element] = []
     hidden_fields: list[htm.Element | htm.VoidElement | markupsafe.Markup] = []
     hidden_fields.append(csrf_input())
@@ -294,7 +295,7 @@
         hidden_field, row = _render_row(
             field_info,
             field_name,
-            flash_error_data,
+            error_data,
             defaults,
             errors,
             textarea_rows,
@@ -346,8 +347,8 @@
     return htm.form(form_classes, **form_attrs)[form_children]
 
 
-def render_block(block: htm.Block, *args, **kwargs) -> None:
-    rendered = render(*args, **kwargs)
+async def render_block(block: htm.Block, *args: Any, **kwargs: Any) -> None:
+    rendered = await render(*args, **kwargs)
     block.append(rendered)
 
 
@@ -575,6 +576,19 @@
     return pydantic.TypeAdapter(model_cls).validate_python(form, context=context)
 
 
+async def validation_cache_add(uid: str, path: str, data: dict[str, Any]) -> None:
+    if asfquart.APP is not None:
+        cache = asfquart.APP.extensions["form_validation_error_cache"]
+        lock = asfquart.APP.extensions["form_validation_error_cache_lock"]
+
+        now = time.monotonic()
+        async with lock:
+            stale_keys = [k for k, (_, stored_at) in cache.items() if (now - stored_at) > _FORM_ERROR_TTL_SECONDS]
+            for k in stale_keys:
+                del cache[k]
+            cache[(uid, path)] = (data, now)
+
+
 def value(type_alias: Any) -> Any:
     # This is for unwrapping from Literal for discriminators
     if hasattr(type_alias, "__value__"):
@@ -1045,3 +1059,21 @@
         elements.append(error_div)
 
     return htm.div[elements] if (len(elements) > 1) else elements[0]
+
+
+async def _validation_cache_get(uid: str, path: str) -> dict[str, Any]:
+    if asfquart.APP is None:
+        return {}
+    cache = asfquart.APP.extensions["form_validation_error_cache"]
+    lock = asfquart.APP.extensions["form_validation_error_cache_lock"]
+    key = (uid, path)
+
+    async with lock:
+        entry = cache.pop(key, None)
+
+    if entry is None:
+        return {}
+    data, stored_at = entry
+    if (time.monotonic() - stored_at) > _FORM_ERROR_TTL_SECONDS:
+        return {}
+    return data
diff --git a/atr/get/announce.py b/atr/get/announce.py
index 32e765c..77f87a8 100644
--- a/atr/get/announce.py
+++ b/atr/get/announce.py
@@ -99,6 +99,7 @@
         default_body=default_body,
         default_download_path_suffix=default_download_path_suffix,
         download_path_description=f"The URL will be {description_download_prefix} plus this suffix",
+        uid=session.asf_uid,
     )
 
     return await template.blank(
@@ -164,6 +165,7 @@
     default_body: str,
     default_download_path_suffix: str,
     download_path_description: str,
+    uid: str,
 ) -> htm.Element:
     """Render the announce page."""
     page = htm.Block()
@@ -237,7 +239,7 @@
             "body": default_body,
         }
 
-        form.render_block(
+        await form.render_block(
             page,
             model_cls=shared.announce.AnnounceForm,
             action=util.as_url(post.announce.selected, project_key=release.project.key, version_key=release.version),
@@ -253,6 +255,7 @@
             form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
             border=True,
             wider_widgets=True,
+            uid=uid,
         )
     else:
         page.p[htm.strong[announce_msg]]
diff --git a/atr/get/checks.py b/atr/get/checks.py
index 261fb9a..94d4bf6 100644
--- a/atr/get/checks.py
+++ b/atr/get/checks.py
@@ -178,7 +178,7 @@
     if release.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
         for path in all_paths:
             delete_file_forms[str(path)] = str(
-                form.render(
+                await form.render(
                     model_cls=draft.DeleteFileForm,
                     action=util.as_url(
                         post.draft.delete_file, project_key=str(project_key), version_key=str(version_key)
diff --git a/atr/get/committees.py b/atr/get/committees.py
index 4aaa0fc..410dc62 100644
--- a/atr/get/committees.py
+++ b/atr/get/committees.py
@@ -71,7 +71,7 @@
         algorithms=shared.algorithms,
         now=datetime.datetime.now(datetime.UTC),
         email_from_key=util.email_from_uid,
-        update_committee_keys_form=form.render(
+        update_committee_keys_form=await form.render(
             model_cls=shared.keys.UpdateCommitteeKeysForm,
             action=util.as_url(post.keys.keys),
             submit_label="Regenerate KEYS file",
diff --git a/atr/get/distribution.py b/atr/get/distribution.py
index 32b4d9b..cf0ccc4 100644
--- a/atr/get/distribution.py
+++ b/atr/get/distribution.py
@@ -119,7 +119,7 @@
         ]
         block.table(".table.table-striped.table-bordered")[tbody]
 
-        delete_form = form.render(
+        delete_form = await form.render(
             model_cls=shared.distribution.DeleteForm,
             action=util.as_url(post.distribution.delete, project_key=str(project_key), version_key=str(version_key)),
             form_classes=".d-inline-block.m-0",
@@ -214,7 +214,7 @@
     )
 
     # Render the distribution form
-    form_html = form.render(
+    form_html = await form.render(
         model_cls=shared.distribution.DistributionAutomateForm,
         submit_label="Distribute",
         action=action,
@@ -290,7 +290,7 @@
     )
 
     # Render the distribution form
-    form_html = form.render(
+    form_html = await form.render(
         model_cls=shared.distribution.DistributionRecordForm,
         submit_label="Record distribution",
         action=action,
diff --git a/atr/get/draft.py b/atr/get/draft.py
index 538915d..6e6a013 100644
--- a/atr/get/draft.py
+++ b/atr/get/draft.py
@@ -71,14 +71,14 @@
         version_key=str(version_key),
         file_path=str(file_path),
     )
-    sha512_form = form.render(
+    sha512_form = await form.render(
         model_cls=shared.draft.HashGen,
         action=hashgen_action,
         submit_label="Generate SHA512",
         submit_classes="btn-outline-secondary",
         empty=True,
     )
-    sbom_form = form.render(
+    sbom_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             post.draft.sbomgen,
diff --git a/atr/get/finish.py b/atr/get/finish.py
index 70b2c07..874621d 100644
--- a/atr/get/finish.py
+++ b/atr/get/finish.py
@@ -111,6 +111,7 @@
         rc_analysis=rc_analysis,
         distribution_tasks=tasks,
         announce_disable_message=announce_msg,
+        uid=session.asf_uid,
     )
 
 
@@ -196,19 +197,20 @@
     return release, source_files_rel, target_dirs, deletable_dirs, rc_analysis_result, tasks
 
 
-def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]]) -> htm.Element:
+async def _render_delete_directory_form(deletable_dirs: list[tuple[str, str]], uid: str) -> htm.Element:
     """Render the delete directory form."""
     section = htm.Block()
 
     section.h2["Delete an empty directory"]
 
-    form.render_block(
+    await form.render_block(
         section,
         shared.finish.DeleteEmptyDirectoryForm,
         defaults={"directory_to_delete": deletable_dirs},
         submit_label="Delete empty directory",
         submit_classes="btn-danger",
         form_classes=".mb-4",
+        uid=uid,
     )
 
     return section.collect()
@@ -370,6 +372,7 @@
     rc_analysis: RCTagAnalysisResult,
     distribution_tasks: Sequence[sql.Task],
     announce_disable_message: str,
+    uid: str,
 ) -> str:
     """Render the finish page using htm.py."""
     page = htm.Block()
@@ -410,10 +413,10 @@
 
     # Delete directory form
     if deletable_dirs:
-        page.append(_render_delete_directory_form(deletable_dirs))
+        page.append(await _render_delete_directory_form(deletable_dirs, uid=uid))
 
     # Remove RC tags section
-    page.append(_render_rc_tags_section(rc_analysis))
+    page.append(await _render_rc_tags_section(rc_analysis))
 
     # Custom styles
     page_styles = """
@@ -479,7 +482,7 @@
     ]
 
 
-def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element:
+async def _render_rc_tags_section(rc_analysis: RCTagAnalysisResult) -> htm.Element:
     """Render the remove RC tags section."""
     section = htm.Block()
 
@@ -493,7 +496,7 @@
             _render_rc_preview_table(rc_analysis.affected_paths_preview) if rc_analysis.affected_paths_preview else "",
         ]
 
-        form.render_block(
+        await form.render_block(
             section,
             shared.finish.RemoveRCTagsForm,
             submit_label="Remove RC tags",
diff --git a/atr/get/ignores.py b/atr/get/ignores.py
index 290b9f7..995080b 100644
--- a/atr/get/ignores.py
+++ b/atr/get/ignores.py
@@ -47,28 +47,29 @@
     content = htm.div[
         htm.h1["Ignored checks"],
         htm.p[f"Manage ignored checks for project {project_key!s}."],
-        _add_ignore(str(project_key)),
-        _existing_ignores(ignores),
+        _add_ignore(session.asf_uid, str(project_key)),
+        _existing_ignores(session.asf_uid, ignores),
     ]
 
     return await template.blank("Ignored checks", content, javascripts=["ignore-form-change"])
 
 
-def _add_ignore(project_key: str) -> htm.Element:
+async def _add_ignore(uid: str, project_key: str) -> htm.Element:
     form_path = util.as_url(post.ignores.ignores, project_key=project_key)
     block = htm.Block(htm.div)
     block.h2["Add ignore"]
     block.p["Add a new ignore for a check result."]
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.ignores.AddIgnoreForm,
         action=form_path,
         submit_label="Add ignore",
+        uid=uid,
     )
     return block.collect()
 
 
-def _check_result_ignore_card(cri: sql.CheckResultIgnore) -> htm.Element:
+async def _check_result_ignore_card(uid: str, cri: sql.CheckResultIgnore) -> htm.Element:
     h3_id = cri.id or ""
     h3_asf_uid = cri.asf_uid
     h3_created = util.format_datetime(cri.created)
@@ -78,12 +79,13 @@
     update_form_block = htm.Block(htm.div)
     form_path_update = util.as_url(post.ignores.ignores, project_key=cri.project_key)
     status = shared.ignores.sql_to_ignore_status(cri.status)
-    form.render_block(
+    await form.render_block(
         update_form_block,
         model_cls=shared.ignores.UpdateIgnoreForm,
         action=form_path_update,
         submit_label="Update ignore",
         form_classes="",
+        uid=uid,
         defaults={
             "id": cri.id or 0,
             "release_glob": cri.release_glob or "",
@@ -98,7 +100,7 @@
 
     # Delete form
     delete_form_block = htm.Block(htm.div)
-    form.render_block(
+    await form.render_block(
         delete_form_block,
         model_cls=shared.ignores.DeleteIgnoreForm,
         action=form_path_update,
@@ -117,8 +119,8 @@
     return card
 
 
-def _existing_ignores(ignores: list[sql.CheckResultIgnore]) -> htm.Element:
+def _existing_ignores(uid: str, ignores: list[sql.CheckResultIgnore]) -> htm.Element:
     return htm.div[
         htm.h2["Existing ignores"],
-        [_check_result_ignore_card(cri) for cri in ignores] or htm.p["No ignores found."],
+        [_check_result_ignore_card(uid, cri) for cri in ignores] or htm.p["No ignores found."],
     ]
diff --git a/atr/get/keys.py b/atr/get/keys.py
index de1977a..7e99f3a 100644
--- a/atr/get/keys.py
+++ b/atr/get/keys.py
@@ -38,7 +38,7 @@
 
 
 @get.typed
-async def add(_session: web.Committer, _keys_add: Literal["keys/add"]) -> str:
+async def add(session: web.Committer, _keys_add: Literal["keys/add"]) -> str:
     """
     URL: /keys/add
     Add a new public signing key to the user's account.
@@ -54,12 +54,13 @@
         htm.h1(".mb-4")["Add your OpenPGP key"],
         htm.p["Add your public key to use for signing release artifacts."],
     ]
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.AddOpenPGPKeyForm,
         action=util.as_url(post.keys.add),
         submit_label="Add OpenPGP key",
         cancel_url=util.as_url(keys),
+        uid=session.asf_uid,
         defaults={
             "selected_committees": committee_choices,
         },
@@ -143,7 +144,7 @@
         committee_choices = [(c.key, c.display_name or c.key) for c in user_committees]
         current_committee_keys = [c.key for c in key.committees]
 
-        # form.render_block(
+        # await form.render_block(
         #     pmc_div,
         #     model_cls=shared.keys.UpdateKeyCommitteesForm,
         #     action=util.as_url(post.keys.details, fingerprint=fingerprint),
@@ -226,9 +227,9 @@
         htm.a(".btn.btn-outline-primary", href=util.as_url(ssh_add))["Add your SSH key"],
     ]
 
-    _openpgp_keys(page, list(user_keys))
-    _ssh_keys(page, list(user_ssh_keys))
-    _committee_keys(page, list(user_committees_with_keys))
+    await _openpgp_keys(page, list(user_keys))
+    await _ssh_keys(page, list(user_ssh_keys))
+    await _committee_keys(page, list(user_committees_with_keys))
 
     return await template.blank(
         "Manage keys",
@@ -255,11 +256,12 @@
         ]
     ]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.AddSSHKeyForm,
         action=util.as_url(post.keys.ssh_add),
         submit_label="Add SSH key",
+        uid=session.asf_uid,
     )
 
     return await template.blank(
@@ -270,15 +272,16 @@
 
 
 @get.typed
-async def upload(_session: web.Committer, _keys_upload: Literal["keys/upload"]) -> str:
+async def upload(session: web.Committer, _keys_upload: Literal["keys/upload"]) -> str:
     """
     URL: /keys/upload
     Upload a KEYS file containing multiple OpenPGP keys.
     """
-    return await shared.keys.render_upload_page()
+    uid = session.asf_uid
+    return await shared.keys.render_upload_page(uid=uid)
 
 
-def _committee_keys(page: htm.Block, user_committees_with_keys: list[sql.Committee]) -> None:
+async def _committee_keys(page: htm.Block, user_committees_with_keys: list[sql.Committee]) -> None:
     page.h2("#your-committee-keys")["Your committee's keys"]
     page.div(".mb-4")[htm.a(".btn.btn-outline-primary", href=util.as_url(upload))["Upload a KEYS file"]]
 
@@ -314,7 +317,7 @@
                     " but you can also use the form below to manually regenerate it.",
                 ]
 
-                form.render_block(
+                await form.render_block(
                     page,
                     model_cls=shared.keys.UpdateCommitteeKeysForm,
                     action=util.as_url(post.keys.keys),
@@ -357,7 +360,7 @@
     return key, is_owner
 
 
-def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> None:
+async def _openpgp_keys(page: htm.Block, user_keys: list[sql.PublicSigningKey]) -> None:
     page.h3["Your OpenPGP keys"]
     if user_keys:
         thead = htm.thead[
@@ -380,7 +383,7 @@
             else:
                 row.td(".text-break.px-2.align-middle")["No PMCs associated"]
             with row.block(htm.td, classes=".px-2") as td:
-                form.render_block(
+                await form.render_block(
                     td,
                     model_cls=shared.keys.DeleteOpenPGPKeyForm,
                     action=util.as_url(post.keys.keys),
@@ -423,7 +426,7 @@
     return row_div.collect()
 
 
-def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
+async def _ssh_keys(page: htm.Block, user_ssh_keys: list[sql.SSHKey]) -> None:
     page.h3["Your SSH keys"]
     if user_ssh_keys:
         grid = htm.Block(htm.div, classes=".d-grid.gap-4")
@@ -447,7 +450,7 @@
                 htm.pre(".mt-3")[key.key],
             ]
 
-            form.render_block(
+            await form.render_block(
                 card_block,
                 model_cls=shared.keys.DeleteSSHKeyForm,
                 action=util.as_url(post.keys.keys),
diff --git a/atr/get/manual.py b/atr/get/manual.py
index e654689..6ddbce5 100644
--- a/atr/get/manual.py
+++ b/atr/get/manual.py
@@ -56,7 +56,7 @@
     if not release.vote_manual:
         raise RuntimeError("This page is for manual votes only")
 
-    content = _render_resolve_page(release)
+    content = await _render_resolve_page(release, uid=session.asf_uid)
 
     return await template.blank(
         title="Resolve vote",
@@ -135,7 +135,7 @@
     ]
 
     cancel_url = util.as_url(compose.selected, project_key=release.project.key, version_key=release.version)
-    manual_form = form.render(
+    manual_form = await form.render(
         model_cls=form.Empty,
         submit_label="Start manual vote",
         cancel_url=cancel_url,
@@ -152,7 +152,7 @@
     return page.collect()
 
 
-def _render_resolve_page(release: sql.Release) -> htm.Element:
+async def _render_resolve_page(release: sql.Release, uid: str) -> htm.Element:
     page = htm.Block()
 
     back_url = util.as_url(vote.selected, project_key=release.project.key, version_key=release.version)
@@ -161,11 +161,12 @@
     page.h1[f"Resolve vote for {release.short_display_name}"]
     page.p["This is a manual vote resolution."]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.manual.ResolveVoteForm,
         form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
         submit_label="Resolve vote",
+        uid=uid,
     )
 
     return page.collect()
diff --git a/atr/get/projects.py b/atr/get/projects.py
index 4ad788c..add5d3b 100644
--- a/atr/get/projects.py
+++ b/atr/get/projects.py
@@ -65,12 +65,13 @@
 
     committee_display_name = committee.name or committee.key.title()
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.projects.AddProjectForm,
         action=util.as_url(post.projects.add_project, committee_key=committee.key),
         submit_label="Add project",
         cancel_url=util.as_url(committees.view, name=committee.key),
+        uid=session.asf_uid,
         defaults={
             "committee_key": committee.key,
         },
@@ -104,7 +105,7 @@
 
     delete_forms: dict[str, htm.Element] = {}
     for project in projects:
-        delete_forms[str(project.key)] = form.render(
+        delete_forms[str(project.key)] = await form.render(
             model_cls=shared.projects.DeleteSelectedProject,
             action=util.as_url(post.projects.delete),
             form_classes=".d-inline-block.m-0",
@@ -199,10 +200,10 @@
 
     if project.status == sql.ProjectStatus.ACTIVE:
         if can_edit:
-            page.append(_render_compose_form(project))
-            page.append(_render_vote_form(project))
-            page.append(_render_finish_form(project))
-            page.append(_render_trusted_publishing_form(project))
+            page.append(await _render_compose_form(session.asf_uid, project))
+            page.append(await _render_vote_form(session.asf_uid, project))
+            page.append(await _render_finish_form(session.asf_uid, project))
+            page.append(await _render_trusted_publishing_form(project))
         else:
             page.append(_render_policy_readonly(project))
 
@@ -214,7 +215,7 @@
         page.append(await _render_releases_sections(project, candidate_drafts, candidates, previews, full_releases))
 
         if project.created_by == session.uid:
-            page.append(_render_delete_section(project))
+            page.append(await _render_delete_section(project))
 
         if project.committee:
             if (project.committee.key in session.committees) or is_privileged:
@@ -335,7 +336,7 @@
     return card.collect()
 
 
-def _render_compose_form(project: sql.Project) -> htm.Element:
+async def _render_compose_form(uid: str, project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Compose options"]
@@ -347,11 +348,12 @@
     else:
         atr_tag_yaml = ""
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.ComposePolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
             submit_label="Save",
+            uid=uid,
             defaults={
                 "project_key": str(project.key),
                 "license_check_mode": project.policy_license_check_mode,
@@ -368,11 +370,11 @@
     return card.collect()
 
 
-def _render_delete_section(project: sql.Project) -> htm.Element:
+async def _render_delete_section(project: sql.Project) -> htm.Element:
     section = htm.Block(htm.div)
     section.h2["Actions"]
 
-    delete_form = form.render(
+    delete_form = await form.render(
         shared.projects.DeleteProjectForm,
         action=util.as_url(post.projects.view, name=str(project.key)),
         form_classes="",
@@ -394,7 +396,7 @@
     return card.collect()
 
 
-def _render_finish_form(project: sql.Project) -> htm.Element:
+async def _render_finish_form(uid: str, project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Finish options"]
@@ -416,11 +418,12 @@
     )
 
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.FinishPolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
             submit_label="Save",
+            uid=uid,
             defaults={
                 "project_key": project.key,
                 "announce_release_subject": project.policy_announce_release_subject or "",
@@ -609,15 +612,14 @@
 
     return sections.collect()
 
-
-def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
+async def _render_trusted_publishing_form(project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Trusted Publishing"]
     ]
 
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.TrustedPublishingPolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
@@ -636,8 +638,7 @@
         )
     return card.collect()
 
-
-def _render_vote_form(project: sql.Project) -> htm.Element:
+async def _render_vote_form(uid: str, project: sql.Project) -> htm.Element:
     card = htm.Block(htm.div, classes=".card.mb-4")
     card.div(".card-header.bg-light.d-flex.justify-content-between.align-items-center")[
         htm.h3(".mb-0")["Release policy - Vote options"]
@@ -683,11 +684,12 @@
     )
 
     with card.block(htm.div, classes=".card-body") as card_body:
-        form.render_block(
+        await form.render_block(
             card_body,
             model_cls=shared.projects.VotePolicyForm,
             action=util.as_url(post.projects.view, name=str(project.key)),
             submit_label="Save",
+            uid=uid,
             defaults=defaults_dict,
             form_classes=".atr-canary.py-4.px-5",
             border=True,
diff --git a/atr/get/revisions.py b/atr/get/revisions.py
index 2cae322..4a30e41 100644
--- a/atr/get/revisions.py
+++ b/atr/get/revisions.py
@@ -183,7 +183,7 @@
 
     if revision_history:
         for revision, files_diff in revision_history:
-            _render_revision_card(
+            await _render_revision_card(
                 page, revision, files_diff, latest_revision_number, phase_key, project_key, version_key
             )
     else:
@@ -213,10 +213,10 @@
     return span.collect(separator=" ")
 
 
-def _render_revision_actions(body: htm.Block, revision: sql.Revision, project_key: str, version_key: str) -> None:
+async def _render_revision_actions(body: htm.Block, revision: sql.Revision, project_key: str, version_key: str) -> None:
     body.h3(".fs-6.fw-semibold.mt-3.atr-sans")["Actions"]
     body.div(".mt-3")[
-        form.render(
+        await form.render(
             model_cls=shared.revisions.SetRevisionForm,
             form_classes="",
             submit_classes="btn-sm btn-outline-danger",
@@ -227,7 +227,7 @@
     ]
 
 
-def _render_revision_card(
+async def _render_revision_card(
     page: htm.Block,
     revision: sql.Revision,
     files_diff: "FilesDiff",
@@ -262,7 +262,7 @@
             is_draft = phase_key == "draft"
             revision_is_preview = revision.phase.value.lower() == "release_preview"
             if (revision.number != latest_revision_number) and (is_draft or revision_is_preview):
-                _render_revision_actions(card_body, revision, project_key, version_key)
+                await _render_revision_actions(card_body, revision, project_key, version_key)
 
 
 def _render_revision_header(revision: sql.Revision, latest_revision_number: str | None) -> htm.Element:
diff --git a/atr/get/sbom.py b/atr/get/sbom.py
index d343628..d77fd83 100644
--- a/atr/get/sbom.py
+++ b/atr/get/sbom.py
@@ -114,12 +114,12 @@
             ]
             if len(augmented_bom_versions) > 0:
                 last_augmented_bom = max(augmented_bom_versions)
-        _augment_section(block, release, task_result, latest_augment, last_augmented_bom)
+        await _augment_section(block, release, task_result, latest_augment, last_augmented_bom)
 
     _conformance_section(block, task_result)
     _license_section(block, task_result)
 
-    _vulnerability_scan_section(
+    await _vulnerability_scan_section(
         block, str(project_key), str(version_key), str(file_path), task_result, osv_tasks, is_release_candidate
     )
 
@@ -130,7 +130,7 @@
     return await template.blank("SBOM report", content=block.collect())
 
 
-def _augment_section(
+async def _augment_section(
     block: htm.Block,
     release: sql.Release,
     task_result: results.SBOMToolScore,
@@ -154,7 +154,7 @@
 
     if len(augments) == 0:
         block.p["We can attempt to augment this SBOM with additional data."]
-        form.render_block(
+        await form.render_block(
             block,
             model_cls=shared.sbom.AugmentSBOMForm,
             submit_label="Augment SBOM",
@@ -168,7 +168,7 @@
         else:
             block.p["This SBOM was augmented by ATR at revision ", htm.code[augments[-1]], "."]
             block.p["We can perform augmentation again to check for additional new data."]
-            form.render_block(
+            await form.render_block(
                 block,
                 model_cls=shared.sbom.AugmentSBOMForm,
                 submit_label="Re-augment SBOM",
@@ -660,10 +660,10 @@
     block.append(new_block)
 
 
-def _vulnerability_scan_button(block: htm.Block) -> None:
+async def _vulnerability_scan_button(block: htm.Block) -> None:
     block.p["You can perform a new vulnerability scan."]
 
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.sbom.ScanSBOMForm,
         submit_label="Scan file",
@@ -713,7 +713,7 @@
         _vulnerability_results_from_bom(vulns, block, scans, previous_vulns)
 
 
-def _vulnerability_scan_section(
+async def _vulnerability_scan_section(
     block: htm.Block,
     project: str,
     version: str,
@@ -746,12 +746,14 @@
 
     if not is_release_candidate:
         if in_progress_task is not None:
-            _vulnerability_scan_status(block, in_progress_task, project, version, file_path)
+            await _vulnerability_scan_status(block, in_progress_task, project, version, file_path)
         else:
-            _vulnerability_scan_button(block)
+            await _vulnerability_scan_button(block)
 
 
-def _vulnerability_scan_status(block: htm.Block, task: sql.Task, project: str, version: str, file_path: str) -> None:
+async def _vulnerability_scan_status(
+    block: htm.Block, task: sql.Task, project: str, version: str, file_path: str
+) -> None:
     status_text = task.status.value.replace("_", " ").capitalize()
     block.p[f"Vulnerability scan is currently {status_text.lower()}."]
     block.p["Task ID: ", htm.code[str(task.id)]]
@@ -761,4 +763,4 @@
             htm.code[task.error],
             ". Additional details are unavailable from ATR.",
         ]
-        _vulnerability_scan_button(block)
+        await _vulnerability_scan_button(block)
diff --git a/atr/get/start.py b/atr/get/start.py
index 2e99cc2..faf3fd3 100644
--- a/atr/get/start.py
+++ b/atr/get/start.py
@@ -46,7 +46,7 @@
         )
 
     releases = await interaction.all_releases(project)
-    content = await _render_page(project, releases)
+    content = await _render_page(project, releases, uid=session.asf_uid)
     return await template.blank(
         title=f"Start a new release for {project.display_name}",
         content=content,
@@ -89,7 +89,7 @@
             return "Ⓡ"
 
 
-async def _render_page(project: sql.Project, releases: list[sql.Release]) -> htm.Element:
+async def _render_page(project: sql.Project, releases: list[sql.Release], uid: str) -> htm.Element:
     page = htm.Block()
 
     page.h1[f"Start a new release for {project.display_name}"]
@@ -98,7 +98,7 @@
         htm.strong["release candidate draft"],
         ". You can then add files to this draft before promoting it for voting.",
     ]
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.start.StartReleaseForm,
         form_classes=".atr-canary.py-4.px-5.border.rounded",
@@ -106,6 +106,7 @@
         submit_label="Start new release",
         cancel_url=util.as_url(root.index),
         defaults={"project_key": project.key},
+        uid=uid,
     )
     if releases:
         page.h2(".mt-5")["Existing releases"]
diff --git a/atr/get/test.py b/atr/get/test.py
index f1ccba4..7faf8ef 100644
--- a/atr/get/test.py
+++ b/atr/get/test.py
@@ -48,7 +48,7 @@
     """
     URL: /test/empty
     """
-    empty_form = form.render(
+    empty_form = await form.render(
         model_cls=form.Empty,
         submit_label="Submit empty form",
         action="/test/empty",
@@ -149,13 +149,13 @@
     """
     URL: /test/multiple
     """
-    apple_form = form.render(
+    apple_form = await form.render(
         model_cls=shared.test.AppleForm,
         submit_label="Order apples",
         action="/test/multiple",
     )
 
-    banana_form = form.render(
+    banana_form = await form.render(
         model_cls=shared.test.BananaForm,
         submit_label="Order bananas",
         action="/test/multiple",
@@ -187,7 +187,7 @@
         htpy.label(class_="btn btn-outline-danger", for_="vote_2")["-1"],
     ]
 
-    single_form = form.render(
+    single_form = await form.render(
         model_cls=shared.test.SingleForm,
         submit_label="Submit",
         action="/test/single",
diff --git a/atr/get/tokens.py b/atr/get/tokens.py
index be603aa..6746bc9 100644
--- a/atr/get/tokens.py
+++ b/atr/get/tokens.py
@@ -30,7 +30,7 @@
 
 
 @get.typed
-async def tokens(_session: web.Committer, _tokens: Literal["tokens"]) -> str:
+async def tokens(session: web.Committer, _tokens: Literal["tokens"]) -> str:
     """
     URL: /tokens
     """
@@ -46,16 +46,17 @@
         token is shown only once when you create it. You can revoke tokens
         you no longer need."""
     ]
-    add_form = form.render(
+    add_form = await form.render(
         model_cls=shared.tokens.AddTokenForm,
         form_classes=".mb-0",
         submit_label="Generate token",
+        uid=session.asf_uid,
     )
     page.div(".card.mb-4")[
         htm.div(".card-header")["Generate new token"],
         htm.div(".card-body")[add_form],
     ]
-    _build_tokens_table(page, tokens_list)
+    await _build_tokens_table(page, tokens_list)
 
     page.h2["JSON Web Token (JWT)"]
     jwt_section = htm.Block()
@@ -65,7 +66,7 @@
         in the Authorization header as a Bearer token when invoking the
         protected endpoints."""
     ]
-    form.render_block(
+    await form.render_block(
         jwt_section,
         model_cls=form.Empty,
         action=util.as_url(post.tokens.jwt_post),
@@ -94,7 +95,7 @@
     )
 
 
-def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalAccessTokenSafe]) -> None:
+async def _build_tokens_table(page: htm.Block, tokens_list: list[types.PersonalAccessTokenSafe]) -> None:
     if not tokens_list:
         page.p["No tokens found."]
         return
@@ -104,7 +105,7 @@
         if not t.id:
             continue
 
-        delete_form = form.render(
+        delete_form = await form.render(
             model_cls=shared.tokens.DeleteTokenForm,
             action=util.as_url(post.tokens.tokens),
             form_classes=".mb-0",
diff --git a/atr/get/upload.py b/atr/get/upload.py
index 94ef20c..82128d5 100644
--- a/atr/get/upload.py
+++ b/atr/get/upload.py
@@ -100,11 +100,12 @@
         )
     )
 
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.upload.AddFilesForm,
         submit_label="Add files",
         form_classes=".atr-canary.py-4.px-5",
+        uid=session.asf_uid,
     )
 
     block.append(htpy.div("#upload-progress-container.d-none"))
@@ -123,11 +124,12 @@
         " page for this draft once the task is queued.",
     ]
 
-    form.render_block(
+    await form.render_block(
         block,
         model_cls=shared.upload.SvnImportForm,
         submit_label="Queue SVN import task",
         form_classes=".atr-canary.py-4.px-5",
+        uid=session.asf_uid,
     )
 
     block.h2(id="rsync-upload")["Rsync upload"]
diff --git a/atr/get/user.py b/atr/get/user.py
index 2f3f354..a6110dc 100644
--- a/atr/get/user.py
+++ b/atr/get/user.py
@@ -66,7 +66,7 @@
 
         block.h3["Delete cache"]
         block.p["Remove your cached session information:"]
-        delete_cache_form = form.render(
+        delete_cache_form = await form.render(
             model_cls=shared.user.DeleteCacheForm,
             submit_label="Delete my cache",
             submit_classes="btn-danger",
@@ -78,7 +78,7 @@
 
         block.h3["Cache current session"]
         block.p["Press the button below to cache your current session information:"]
-        cache_form = form.render(
+        cache_form = await form.render(
             model_cls=shared.user.CacheUserForm,
             submit_label="Cache me!",
             submit_classes="btn-primary",
diff --git a/atr/get/vote.py b/atr/get/vote.py
index c4f7a4e..4113bfd 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -554,13 +554,14 @@
         version_key=release.version,
     )
     vote_comment_template = release.project.policy_vote_comment_template
-    cast_vote_form = form.render(
+    cast_vote_form = await form.render(
         model_cls=shared.vote.CastVoteForm,
         action=vote_action_url,
         submit_label="Submit vote",
         form_classes=".atr-canary.py-4.px-5.mb-4.border.rounded",
         custom={"decision": vote_widget},
         defaults={"comment": vote_comment_template},
+        uid=session.asf_uid,
     )
     page.append(cast_vote_form)
 
diff --git a/atr/get/voting.py b/atr/get/voting.py
index 22c4ca3..ec0cd2f 100644
--- a/atr/get/voting.py
+++ b/atr/get/voting.py
@@ -101,6 +101,7 @@
             default_body=default_body,
             min_hours=min_hours,
             keys_warning=keys_warning,
+            uid=session.asf_uid,
         )
 
         return await template.blank(
@@ -146,6 +147,7 @@
     default_body: str,
     min_hours: int,
     keys_warning: bool,
+    uid: str,
 ) -> htm.Element:
     page = htm.Block()
 
@@ -212,7 +214,7 @@
         ],
     ]
 
-    vote_form = form.render(
+    vote_form = await form.render(
         model_cls=shared.voting.StartVotingForm,
         submit_label="Send vote email",
         cancel_url=cancel_url,
@@ -226,6 +228,7 @@
             "subject": custom_subject_widget,
             "body": custom_body_widget,
         },
+        uid=uid,
         skip=["email_cc", "email_bcc"],
     )
     page.append(vote_form)
diff --git a/atr/post/keys.py b/atr/post/keys.py
index e5351d4..5e5171e 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -217,7 +217,7 @@
 
 @post.typed
 async def upload(
-    _session: web.Committer,
+    session: web.Committer,
     _keys_upload: Literal["keys/upload"],
     upload_form: shared.keys.UploadKeysForm,
 ) -> str:
@@ -225,11 +225,12 @@
     URL: /keys/upload
     Upload or fetch a KEYS file containing multiple OpenPGP keys.
     """
+    uid = session.asf_uid
     match upload_form:
         case shared.keys.UploadFileForm() as upload_file_form:
-            return await _upload_file_keys(upload_file_form)
+            return await _upload_file_keys(upload_file_form, uid=uid)
         case shared.keys.UploadRemoteForm() as upload_remote_form:
-            return await _upload_remote_keys(upload_remote_form)
+            return await _upload_remote_keys(upload_remote_form, uid=uid)
 
 
 def _construct_keys_url(committee_key: str, *, is_podling: bool) -> str:
@@ -299,7 +300,7 @@
         raise base.ASFQuartException(f"Network error while fetching keys: {e}", errorcode=503)
 
 
-async def _process_keys(keys_text: str, selected_committee: str) -> str:
+async def _process_keys(keys_text: str, selected_committee: str, uid: str) -> str:
     """Process keys text and associate with committee."""
     async with storage.write() as write:
         wacp = write.as_committee_participant(selected_committee)
@@ -315,7 +316,7 @@
 
     await quart.flash(message, "success" if (success_count > 0) else "error")
 
-    return await shared.keys.render_upload_page(results=outcomes, submitted_committees=[selected_committee])
+    return await shared.keys.render_upload_page(results=outcomes, submitted_committees=[selected_committee], uid=uid)
 
 
 async def _update_committee_keys(
@@ -337,29 +338,29 @@
     return await session.redirect(get.keys.keys)
 
 
-async def _upload_file_keys(upload_file_form: shared.keys.UploadFileForm) -> str:
+async def _upload_file_keys(upload_file_form: shared.keys.UploadFileForm, uid: str) -> str:
     """Handle file upload."""
     try:
         if upload_file_form.key is None:
             await quart.flash("No KEYS file uploaded", "error")
-            return await shared.keys.render_upload_page(error=True)
+            return await shared.keys.render_upload_page(error=True, uid=uid)
 
         keys_content = await asyncio.to_thread(upload_file_form.key.read)
         keys_text = keys_content.decode("utf-8", errors="replace")
 
         if not keys_text:
             await quart.flash("No KEYS data found", "error")
-            return await shared.keys.render_upload_page(error=True)
+            return await shared.keys.render_upload_page(error=True, uid=uid)
 
         selected_committee = upload_file_form.selected_committee
-        return await _process_keys(keys_text, selected_committee)
+        return await _process_keys(keys_text, selected_committee, uid=uid)
     except Exception as e:
         log.exception("Error uploading KEYS file:")
         await quart.flash(f"Error processing KEYS file: {e!s}", "error")
-        return await shared.keys.render_upload_page(error=True)
+        return await shared.keys.render_upload_page(error=True, uid=uid)
 
 
-async def _upload_remote_keys(upload_remote_form: shared.keys.UploadRemoteForm) -> str:
+async def _upload_remote_keys(upload_remote_form: shared.keys.UploadRemoteForm, uid: str) -> str:
     """Fetch KEYS file from ASF downloads."""
     try:
         selected_committee = upload_remote_form.committee
@@ -367,7 +368,7 @@
             committee = await data.committee(key=selected_committee).get()
             if not committee:
                 await quart.flash(f"Committee '{selected_committee}' not found", "error")
-                return await shared.keys.render_upload_page(error=True)
+                return await shared.keys.render_upload_page(error=True, uid=uid)
             is_podling = committee.is_podling
 
         keys_url = _construct_keys_url(selected_committee, is_podling=is_podling)
@@ -375,10 +376,10 @@
 
         if not keys_text:
             await quart.flash("No KEYS data found at ASF downloads", "error")
-            return await shared.keys.render_upload_page(error=True)
+            return await shared.keys.render_upload_page(error=True, uid=uid)
 
-        return await _process_keys(keys_text, selected_committee)
+        return await _process_keys(keys_text, selected_committee, uid=uid)
     except Exception as e:
         log.exception("Error fetching KEYS file from ASF:")
         await quart.flash(f"Error fetching KEYS file: {e!s}", "error")
-        return await shared.keys.render_upload_page(error=True)
+        return await shared.keys.render_upload_page(error=True, uid=uid)
diff --git a/atr/post/resolve.py b/atr/post/resolve.py
index 4ab38ff..c772e9d 100644
--- a/atr/post/resolve.py
+++ b/atr/post/resolve.py
@@ -143,7 +143,7 @@
         )
         defaults["vote_result"] = "passed" if details.passed else "failed"
 
-    resolve_form = atr.form.render(
+    resolve_form = await atr.form.render(
         model_cls=shared.resolve.SubmitForm,
         action=util.as_url(selected, project_key=release.project.key, version_key=release.version),
         submit_label="Resolve vote",
diff --git a/atr/server.py b/atr/server.py
index dd33a11..d7b00e5 100644
--- a/atr/server.py
+++ b/atr/server.py
@@ -286,6 +286,10 @@
 
         await asyncio.to_thread(_set_file_permissions_to_read_only)
 
+        # Initialise the form validation cache
+        app.extensions["form_validation_error_cache"] = {}
+        app.extensions["form_validation_error_cache_lock"] = asyncio.Lock()
+
         await _backfill_archive_cache()
 
         await cache.admins_startup_load()
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 2b6fa85..e2b5c29 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -129,6 +129,7 @@
 
 
 async def render_upload_page(
+    uid: str,
     results: storage.outcome.List | None = None,
     submitted_committees: list[str] | None = None,
     error: bool = False,
@@ -159,7 +160,7 @@
     page.h2["Upload a file"]
     page.p["Upload a KEYS file from your computer."]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.UploadFileForm,
         action=util.as_url(post.keys.upload),
@@ -167,12 +168,13 @@
         defaults={"selected_committee": committee_choices},
         border=True,
         wider_widgets=True,
+        uid=uid,
     )
 
     page.h2(".mt-5")["Fetch existing KEYS file"]
     page.p["Fetch the KEYS file from the ASF downloads server for the selected committee."]
 
-    form.render_block(
+    await form.render_block(
         page,
         model_cls=shared.keys.UploadRemoteForm,
         action=util.as_url(post.keys.upload),
@@ -180,6 +182,7 @@
         defaults={"committee": committee_choices},
         border=True,
         wider_widgets=True,
+        uid=uid,
     )
 
     return await template.blank(
diff --git a/atr/shared/vote.py b/atr/shared/vote.py
index e634293..660dd89 100644
--- a/atr/shared/vote.py
+++ b/atr/shared/vote.py
@@ -24,7 +24,7 @@
 
 class CastVoteForm(form.Form):
     decision: Literal["+1", "0", "-1"] = form.label("Your vote", widget=form.Widget.CUSTOM)
-    comment: str = form.label("Comment (optional)", widget=form.Widget.TEXTAREA)
+    comment: str = form.label("Comment (optional)", widget=form.Widget.TEXTAREA, max_length=50_000)
 
 
 async def is_binding(
diff --git a/atr/shared/web.py b/atr/shared/web.py
index 51b0614..81b57f6 100644
--- a/atr/shared/web.py
+++ b/atr/shared/web.py
@@ -82,7 +82,7 @@
         for q in quarantined_failed:
             if q.id is None:
                 continue
-            clear_quarantine_forms[q.id] = form.render(
+            clear_quarantine_forms[q.id] = await form.render(
                 model_cls=draft.ClearQuarantineForm,
                 action=util.as_url(
                     post.draft.quarantine_clear,
@@ -110,7 +110,7 @@
             revision_editor = None
             revision_timestamp = None
 
-    delete_form = form.render(
+    delete_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             get.compose.selected, project_key=release.safe_project_key, version_key=release.safe_version_key
@@ -123,7 +123,7 @@
 
     delete_file_forms: dict[str, htm.Element] = {}
     for path in all_paths:
-        delete_file_forms[str(path)] = form.render(
+        delete_file_forms[str(path)] = await form.render(
             model_cls=draft.DeleteFileForm,
             action=util.as_url(
                 post.draft.delete_file, project_key=release.safe_project_key, version_key=release.safe_version_key
@@ -141,15 +141,17 @@
             ),
         )
 
-    recheck_form = form.render(
+    uid = session.asf_uid if session is not None else None
+    recheck_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             post.draft.recheck, project_key=release.safe_project_key, version_key=release.safe_version_key
         ),
         submit_label="Recheck with fresh cache",
         submit_classes="btn btn-primary",
+        uid=uid,
     )
-    cache_reset_form = form.render(
+    cache_reset_form = await form.render(
         model_cls=form.Empty,
         action=util.as_url(
             post.draft.cache_reset, project_key=release.safe_project_key, version_key=release.safe_version_key
@@ -157,6 +159,7 @@
         submit_label="Recheck with global cache",
         submit_classes="btn btn-primary",
         submit_disabled=release.check_cache_key is None,
+        uid=uid,
     )
 
     vote_task_warnings = _warnings_from_vote_result(vote_task)
diff --git a/atr/templates/macros/flash.html b/atr/templates/macros/flash.html
index f080cd2..18be04b 100644
--- a/atr/templates/macros/flash.html
+++ b/atr/templates/macros/flash.html
@@ -2,12 +2,10 @@
   {% with messages = get_flashed_messages(with_categories=true) %}
     {% if messages %}
       {% for category, message in messages %}
-        {% if category != 'form-error-data' %}
-          <div class="flash-message flash-{{ category }}">
-            {% if category == 'error' %}<strong>Error:</strong>{% endif %}
-            {{ message }}
-          </div>
-        {% endif %}
+        <div class="flash-message flash-{{ category }}">
+          {% if category == 'error' %}<strong>Error:</strong>{% endif %}
+          {{ message }}
+        </div>
       {% endfor %}
     {% endif %}
   {% endwith %}
diff --git a/atr/web.py b/atr/web.py
index 3fa2307..78a47a5 100644
--- a/atr/web.py
+++ b/atr/web.py
@@ -17,7 +17,6 @@
 
 from __future__ import annotations
 
-import json
 import urllib.parse
 from typing import TYPE_CHECKING, Any, Protocol, TypeVar
 
@@ -128,11 +127,11 @@
                 type="atr_error",
             )
         ]
-        flash_data = form.flash_error_data(self.__form_cls, errors, self.__form_data)
-        summary = form.flash_error_summary(errors, flash_data)
+        error_data = form.flash_error_data(self.__form_cls, errors, self.__form_data)
+        summary = form.flash_error_summary(errors, error_data)
 
         await quart.flash(summary, category="error")
-        await quart.flash(json.dumps(flash_data), category="form-error-data")
+        await form.validation_cache_add(self.asf_uid, quart.request.path, error_data)
         return quart.redirect(quart.request.path)
 
     async def form_validate(self, form_cls: type[form.Form], context: dict[str, Any]) -> pydantic.BaseModel: