diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php
index 96cbe8f4..ad278b7a 100644
--- a/public-legacy/auth/login.php
+++ b/public-legacy/auth/login.php
@@ -68,7 +68,7 @@ if($siteIsPrivate) {
 }
 
 while($_SERVER['REQUEST_METHOD'] === 'POST') {
-    if(!CSRF::validateRequest()) {
+    if(!$msz->csrfCtx->verifyLegacy()) {
         $notices[] = 'Was unable to verify the request, please try again!';
         break;
     }
diff --git a/public-legacy/auth/logout.php b/public-legacy/auth/logout.php
index 8df0ad2c..5995bc34 100644
--- a/public-legacy/auth/logout.php
+++ b/public-legacy/auth/logout.php
@@ -7,7 +7,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
 if($msz->authInfo->loggedIn) {
-    if(!CSRF::validateRequest()) {
+    if(!$msz->csrfCtx->verifyLegacy()) {
         Template::render('auth.logout');
         return;
     }
diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php
index 7dc85aa8..2787c640 100644
--- a/public-legacy/auth/password.php
+++ b/public-legacy/auth/password.php
@@ -33,7 +33,7 @@ $remainingAttempts = $msz->authCtx->loginAttempts->countRemainingAttempts($ipAdd
 
 while($canResetPassword) {
     if(!empty($_POST['verification']) && is_scalar($_POST['verification']) && !empty($userInfo)) {
-        if(!CSRF::validateRequest()) {
+        if(!$msz->csrfCtx->verifyLegacy()) {
             $notices[] = 'Was unable to verify the request, please try again!';
             break;
         }
@@ -80,7 +80,7 @@ while($canResetPassword) {
     }
 
     if(!empty($_POST['email']) && is_scalar($_POST['email'])) {
-        if(!CSRF::validateRequest()) {
+        if(!$msz->csrfCtx->verifyLegacy()) {
             $notices[] = 'Was unable to verify the request, please try again!';
             break;
         }
diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php
index 44ce50ee..286ae01b 100644
--- a/public-legacy/auth/register.php
+++ b/public-legacy/auth/register.php
@@ -19,7 +19,7 @@ $countryCode = $_SERVER['COUNTRY_CODE'] ?? 'XX';
 $remainingAttempts = $msz->authCtx->loginAttempts->countRemainingAttempts($ipAddress);
 
 while($_SERVER['REQUEST_METHOD'] === 'POST') {
-    if(!CSRF::validateRequest()) {
+    if(!$msz->csrfCtx->verifyLegacy()) {
         $notices[] = 'Was unable to verify the request, please try again!';
         break;
     }
diff --git a/public-legacy/auth/revert.php b/public-legacy/auth/revert.php
index 6a4f01ec..a1a0f79c 100644
--- a/public-legacy/auth/revert.php
+++ b/public-legacy/auth/revert.php
@@ -6,7 +6,7 @@ use Misuzu\Auth\AuthTokenCookie;
 if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
-if(CSRF::validateRequest()) {
+if($msz->csrfCtx->verifyLegacy()) {
     $tokenInfo = $msz->authInfo->tokenInfo;
 
     if($tokenInfo->hasImpersonatedUserId) {
diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php
index 84083c89..a396e82c 100644
--- a/public-legacy/auth/twofactor.php
+++ b/public-legacy/auth/twofactor.php
@@ -37,7 +37,7 @@ if($totpInfo === null) {
 }
 
 while($_SERVER['REQUEST_METHOD'] === 'POST') {
-    if(!CSRF::validateRequest()) {
+    if(!$msz->csrfCtx->verifyLegacy()) {
         $notices[] = 'Was unable to verify the request, please try again!';
         break;
     }
diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php
index 601c2f0a..5c679b24 100644
--- a/public-legacy/forum/posting.php
+++ b/public-legacy/forum/posting.php
@@ -147,7 +147,7 @@ if(!empty($_POST)) {
     $topicType = isset($_POST['type']) ? $_POST['type'] : null;
     $postSignature = isset($_POST['signature']);
 
-    if(!CSRF::validateRequest()) {
+    if(!$msz->csrfCtx->verifyLegacy()) {
         $notices[] = 'Could not verify request.';
     } else {
         $isEditingTopic = empty($topicInfo) || ($mode === 'edit' && $originalPostInfo->id == $postInfo->id);
diff --git a/public-legacy/manage/changelog/change.php b/public-legacy/manage/changelog/change.php
index 2b5e8313..7d8eb00f 100644
--- a/public-legacy/manage/changelog/change.php
+++ b/public-legacy/manage/changelog/change.php
@@ -34,7 +34,7 @@ else
     }
 
 if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         Template::throwError(403);
 
     $msz->changelog->deleteChange($changeInfo);
@@ -44,7 +44,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 }
 
 // make errors not echos lol
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $action = !empty($_POST['cl_action']) && is_scalar($_POST['cl_action']) ? trim((string)$_POST['cl_action']) : '';
     $summary = !empty($_POST['cl_summary']) && is_scalar($_POST['cl_summary']) ? trim((string)$_POST['cl_summary']) : '';
     $body = !empty($_POST['cl_body']) && is_scalar($_POST['cl_body']) ? trim((string)$_POST['cl_body']) : '';
diff --git a/public-legacy/manage/changelog/tag.php b/public-legacy/manage/changelog/tag.php
index 75c96574..d0699d9d 100644
--- a/public-legacy/manage/changelog/tag.php
+++ b/public-legacy/manage/changelog/tag.php
@@ -23,7 +23,7 @@ else
     }
 
 if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         Template::throwError(403);
 
     $msz->changelog->deleteTag($tagInfo);
@@ -32,7 +32,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
     return;
 }
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $name = !empty($_POST['ct_name']) && is_scalar($_POST['ct_name']) ? trim((string)$_POST['ct_name']) : '';
     $description = !empty($_POST['ct_desc']) && is_scalar($_POST['ct_desc']) ? trim((string)$_POST['ct_desc']) : '';
     $archive = !empty($_POST['ct_archive']);
diff --git a/public-legacy/manage/forum/redirs.php b/public-legacy/manage/forum/redirs.php
index c0d380cd..d55ac1fa 100644
--- a/public-legacy/manage/forum/redirs.php
+++ b/public-legacy/manage/forum/redirs.php
@@ -8,7 +8,7 @@ if(!$msz->authInfo->getPerms('global')->check(Perm::G_FORUM_TOPIC_REDIRS_MANAGE)
     Template::throwError(403);
 
 if($_SERVER['REQUEST_METHOD'] === 'POST') {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         throw new \Exception("Request verification failed.");
 
     $rTopicId = !empty($_POST['topic_redir_id']) && is_scalar($_POST['topic_redir_id']) ? trim((string)$_POST['topic_redir_id']) : '';
@@ -21,7 +21,7 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
 }
 
 if(!empty($_GET['m']) && $_GET['m'] === 'explode') {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         throw new \Exception("Request verification failed.");
 
     $rTopicId = !empty($_GET['t']) && is_scalar($_GET['t']) ? (string)$_GET['t'] : '';
diff --git a/public-legacy/manage/general/emoticon.php b/public-legacy/manage/general/emoticon.php
index 16e8e540..f11bf509 100644
--- a/public-legacy/manage/general/emoticon.php
+++ b/public-legacy/manage/general/emoticon.php
@@ -26,7 +26,7 @@ else
     }
 
 // make errors not echos lol
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $order = !empty($_POST['em_order']) && is_scalar($_POST['em_order']) ? (int)$_POST['em_order'] : '';
     $minRank = !empty($_POST['em_minrank']) && is_scalar($_POST['em_minrank']) ? (int)$_POST['em_minrank'] : '';
     $url = !empty($_POST['em_url']) && is_scalar($_POST['em_url']) ? trim((string)$_POST['em_url']) : '';
diff --git a/public-legacy/manage/general/emoticons.php b/public-legacy/manage/general/emoticons.php
index d670c583..a0824d58 100644
--- a/public-legacy/manage/general/emoticons.php
+++ b/public-legacy/manage/general/emoticons.php
@@ -9,7 +9,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
 if(!$msz->authInfo->getPerms('global')->check(Perm::G_EMOTES_MANAGE))
     Template::throwError(403);
 
-if(CSRF::validateRequest() && !empty($_GET['emote'])) {
+if($msz->csrfCtx->verifyLegacy() && !empty($_GET['emote'])) {
     $emoteId = !empty($_GET['emote']) && is_scalar($_GET['emote']) ? (string)$_GET['emote'] : '';
 
     try {
diff --git a/public-legacy/manage/general/setting-delete.php b/public-legacy/manage/general/setting-delete.php
index 796e046f..4aaafce9 100644
--- a/public-legacy/manage/general/setting-delete.php
+++ b/public-legacy/manage/general/setting-delete.php
@@ -11,7 +11,7 @@ $valueInfo = $msz->config->getValueInfo(!empty($_GET['name']) && is_scalar($_GET
 if($valueInfo === null)
     Template::throwError(404);
 
-if($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+if($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $msz->logsCtx->createAuthedLog('CONFIG_DELETE', [$valueInfo->name]);
     $msz->config->removeValues($valueInfo->name);
     Tools::redirect($msz->urls->format('manage-general-settings'));
diff --git a/public-legacy/manage/general/setting.php b/public-legacy/manage/general/setting.php
index 116c9464..2f797d2e 100644
--- a/public-legacy/manage/general/setting.php
+++ b/public-legacy/manage/general/setting.php
@@ -25,7 +25,7 @@ if(!empty($sName)) {
     }
 }
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     if($isNew) {
         $sName = !empty($_POST['conf_name']) && is_scalar($_POST['conf_name']) ? trim((string)$_POST['conf_name']) : '';
         if(!DbConfig::validateName($sName)) {
diff --git a/public-legacy/manage/news/category.php b/public-legacy/manage/news/category.php
index 4a848eea..fa697df2 100644
--- a/public-legacy/manage/news/category.php
+++ b/public-legacy/manage/news/category.php
@@ -23,7 +23,7 @@ else
     }
 
 if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         Template::throwError(403);
 
     $msz->news->deleteCategory($categoryInfo);
@@ -32,7 +32,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
     return;
 }
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $name = !empty($_POST['nc_name']) && is_scalar($_POST['nc_name']) ? trim((string)$_POST['nc_name']) : '';
     $description = !empty($_POST['nc_desc']) && is_scalar($_POST['nc_desc']) ? trim((string)$_POST['nc_desc']) : '';
     $hidden = !empty($_POST['nc_hidden']);
diff --git a/public-legacy/manage/news/post.php b/public-legacy/manage/news/post.php
index 783ce36f..f0969b8c 100644
--- a/public-legacy/manage/news/post.php
+++ b/public-legacy/manage/news/post.php
@@ -24,7 +24,7 @@ else
     }
 
 if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         Template::throwError(403);
 
     $msz->news->deletePost($postInfo);
@@ -33,7 +33,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
     return;
 }
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $title = !empty($_POST['np_title']) && is_scalar($_POST['np_title']) ? trim((string)$_POST['np_title']) : '';
     $category = !empty($_POST['np_category']) && is_scalar($_POST['np_category']) ? trim((string)$_POST['np_category']) : '';
     $featured = !empty($_POST['np_featured']);
diff --git a/public-legacy/manage/users/ban.php b/public-legacy/manage/users/ban.php
index 5a4e4675..cb322d7f 100644
--- a/public-legacy/manage/users/ban.php
+++ b/public-legacy/manage/users/ban.php
@@ -12,7 +12,7 @@ if(!$msz->authInfo->getPerms('user')->check(Perm::U_BANS_MANAGE))
     Template::throwError(403);
 
 if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         Template::throwError(403);
 
     try {
@@ -35,7 +35,7 @@ try {
 
 $modInfo = $msz->authInfo->userInfo;
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $expires = !empty($_POST['ub_expires']) && is_scalar($_POST['ub_expires']) ? (int)$_POST['ub_expires'] : 0;
     $expiresCustom = !empty($_POST['ub_expires_custom']) && is_scalar($_POST['ub_expires_custom']) ? trim((string)$_POST['ub_expires_custom']) : '';
     $publicReason = !empty($_POST['ub_reason_pub']) && is_scalar($_POST['ub_reason_pub']) ? trim((string)$_POST['ub_reason_pub']) : '';
diff --git a/public-legacy/manage/users/note.php b/public-legacy/manage/users/note.php
index d72d4afd..066cbec2 100644
--- a/public-legacy/manage/users/note.php
+++ b/public-legacy/manage/users/note.php
@@ -35,7 +35,7 @@ if($hasUserId) {
     }
 
     if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
-        if(!CSRF::validateRequest())
+        if(!$msz->csrfCtx->verifyLegacy())
             Template::throwError(403);
 
         $msz->usersCtx->modNotes->deleteNotes($noteInfo);
@@ -48,7 +48,7 @@ if($hasUserId) {
     $authorInfo = $noteInfo->authorId !== null ? $msz->usersCtx->getUserInfo($noteInfo->authorId) : null;
 }
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $title = trim((string)($_POST['mn_title'] ?? ''));
     $body = trim((string)($_POST['mn_body'] ?? ''));
 
diff --git a/public-legacy/manage/users/role.php b/public-legacy/manage/users/role.php
index d0abf2cb..dc2ec42f 100644
--- a/public-legacy/manage/users/role.php
+++ b/public-legacy/manage/users/role.php
@@ -32,7 +32,7 @@ $canEditPerms = $viewerPerms->check(Perm::U_PERMS_MANAGE);
 $permsInfos = $roleInfo === null ? null : $msz->perms->getPermissionInfo(roleInfo: $roleInfo, categoryNames: Perm::INFO_FOR_ROLE);
 $permsLists = Perm::createList(Perm::LISTS_FOR_ROLE);
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $userRank = $msz->usersCtx->users->getUserRank($currentUser);
 
     if(!$isNew && !$currentUser->super && $roleInfo->rank >= $userRank) {
diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php
index a6402cdc..1f0e99f5 100644
--- a/public-legacy/manage/users/user.php
+++ b/public-legacy/manage/users/user.php
@@ -47,7 +47,7 @@ $permsInfos = $msz->perms->getPermissionInfo(userInfo: $userInfo, categoryNames:
 $permsLists = Perm::createList(Perm::LISTS_FOR_USER);
 $permsNeedRecalc = false;
 
-if(CSRF::validateRequest() && $canEdit) {
+if($msz->csrfCtx->verifyLegacy() && $canEdit) {
     if(!empty($_POST['impersonate_user'])) {
         if(!$canImpersonate) {
             $notices[] = 'You must be a super user to do this.';
diff --git a/public-legacy/manage/users/warning.php b/public-legacy/manage/users/warning.php
index 0cfa810e..4d797e02 100644
--- a/public-legacy/manage/users/warning.php
+++ b/public-legacy/manage/users/warning.php
@@ -10,7 +10,7 @@ if(!$msz->authInfo->getPerms('user')->check(Perm::U_WARNINGS_MANAGE))
     Template::throwError(403);
 
 if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
-    if(!CSRF::validateRequest())
+    if(!$msz->csrfCtx->verifyLegacy())
         Template::throwError(403);
 
     try {
@@ -33,7 +33,7 @@ try {
 
 $modInfo = $msz->authInfo->userInfo;
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $body = trim((string)($_POST['uw_body'] ?? ''));
     Template::set('warn_value_body', $body);
 
diff --git a/public-legacy/profile.php b/public-legacy/profile.php
index 25a4ebab..7d0fc2c4 100644
--- a/public-legacy/profile.php
+++ b/public-legacy/profile.php
@@ -110,7 +110,7 @@ if($isEditing) {
     ]);
 
     if(!empty($_POST)) {
-        if(!CSRF::validateRequest()) {
+        if(!$msz->csrfCtx->verifyLegacy()) {
             $notices[] = "Couldn't verify you, please refresh the page and retry.";
         } else {
             if(!$perms->edit_profile) {
diff --git a/public-legacy/settings/account.php b/public-legacy/settings/account.php
index 74a0472a..bb101cd3 100644
--- a/public-legacy/settings/account.php
+++ b/public-legacy/settings/account.php
@@ -16,7 +16,7 @@ $errors = [];
 $userInfo = $msz->authInfo->userInfo;
 $isRestricted = $msz->usersCtx->hasActiveBan($userInfo);
 $hasTotp = $msz->usersCtx->totps->hasUserTotp($userInfo);
-$isVerifiedRequest = CSRF::validateRequest();
+$isVerifiedRequest = $msz->csrfCtx->verifyLegacy();
 
 if(!$isRestricted && $isVerifiedRequest && !empty($_POST['role'])) {
     try {
diff --git a/public-legacy/settings/sessions.php b/public-legacy/settings/sessions.php
index 43a90f26..0adb7a08 100644
--- a/public-legacy/settings/sessions.php
+++ b/public-legacy/settings/sessions.php
@@ -13,7 +13,7 @@ $errors = [];
 $currentUser = $msz->authInfo->userInfo;
 $activeSessionId = $msz->authInfo->sessionId;
 
-while($_SERVER['REQUEST_METHOD'] === 'POST' && CSRF::validateRequest()) {
+while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $sessionId = !empty($_POST['session']) && is_scalar($_POST['session']) ? trim((string)$_POST['session']) : '';
     $activeSessionKilled = false;
 
diff --git a/public/index.php b/public/index.php
index 289fb614..7a2654ba 100644
--- a/public/index.php
+++ b/public/index.php
@@ -6,8 +6,8 @@ use Index\MediaType;
 use Index\Http\Content\MultipartFormContent;
 use Index\Http\Content\Multipart\ValueMultipartFormData;
 use Index\Http\Routing\Router;
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\RouteInfo;
-use Misuzu\Auth\{AuthTokenBuilder,AuthTokenCookie,AuthTokenInfo};
 
 require_once __DIR__ . '/../misuzu.php';
 
@@ -28,90 +28,6 @@ if(is_file($msz->dbCtx->getMigrateLockPath())) {
 
 $request = \Index\Http\HttpRequest::fromRequest();
 
-$tokenPacker = $msz->authCtx->createAuthTokenPacker();
-
-if(!empty($_COOKIE['msz_auth']) && is_string($_COOKIE['msz_auth']))
-    $tokenInfo = $tokenPacker->unpack($_COOKIE['msz_auth']);
-elseif(!empty($_COOKIE['msz_uid']) && !empty($_COOKIE['msz_sid']) && is_string($_COOKIE['msz_uid']) && is_string($_COOKIE['msz_sid'])) {
-    $tokenBuilder = new AuthTokenBuilder;
-    $tokenBuilder->setUserId($_COOKIE['msz_uid']);
-    $tokenBuilder->setSessionToken($_COOKIE['msz_sid']);
-    $tokenInfo = $tokenBuilder->toInfo();
-    $tokenBuilder = null;
-} else
-    $tokenInfo = AuthTokenInfo::empty();
-
-$userInfo = null;
-$sessionInfo = null;
-$userInfoReal = null;
-$remoteAddr = $_SERVER['REMOTE_ADDR'];
-
-if($tokenInfo->hasUserId && $tokenInfo->hasSessionToken) {
-    $tokenBuilder = new AuthTokenBuilder($tokenInfo);
-
-    try {
-        $sessionInfo = $msz->authCtx->sessions->getSession(sessionToken: $tokenInfo->sessionToken);
-
-        if($sessionInfo->expired) {
-            $tokenBuilder->removeUserId();
-            $tokenBuilder->removeSessionToken();
-        } elseif($sessionInfo->userId === $tokenInfo->userId) {
-            $userInfo = $msz->usersCtx->users->getUser($tokenInfo->userId, 'id');
-
-            if($userInfo->deleted) {
-                $tokenBuilder->removeUserId();
-                $tokenBuilder->removeSessionToken();
-            } else {
-                $msz->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $remoteAddr);
-                $msz->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $remoteAddr);
-                if($sessionInfo->shouldBumpExpires)
-                    $tokenBuilder->setEdited();
-
-                if($tokenInfo->hasImpersonatedUserId) {
-                    $allowToImpersonate = $userInfo->super;
-                    $impersonatedUserId = $tokenInfo->impersonatedUserId;
-
-                    if(!$allowToImpersonate) {
-                        $allowImpersonateUsers = $msz->config->getArray(sprintf('impersonate.allow.u%s', $userInfo->id));
-                        $allowToImpersonate = in_array((string)$impersonatedUserId, $allowImpersonateUsers, true);
-                    }
-
-                    if($allowToImpersonate) {
-                        $userInfoReal = $userInfo;
-
-                        try {
-                            $userInfo = $msz->usersCtx->users->getUser($impersonatedUserId, 'id');
-                        } catch(RuntimeException $ex) {
-                            $userInfo = $userInfoReal;
-                            $userInfoReal = null;
-                            $tokenBuilder->removeImpersonatedUserId();
-                        }
-                    } else $tokenBuilder->removeImpersonatedUserId();
-                }
-            }
-        }
-    } catch(RuntimeException $ex) {
-        $tokenBuilder->removeUserId();
-        $tokenBuilder->removeSessionToken();
-        $tokenBuilder->removeImpersonatedUserId();
-        $userInfo = null;
-        $sessionInfo = null;
-        $userInfoReal = null;
-    }
-
-    if($tokenBuilder->isEdited()) {
-        $tokenInfo = $tokenBuilder->toInfo();
-        AuthTokenCookie::apply($tokenPacker->pack($tokenInfo));
-    }
-}
-
-$msz->authInfo->setInfo($tokenInfo, $userInfo, $sessionInfo, $userInfoReal);
-
-CSRF::init(
-    $msz->config->getString('csrf.secret', 'soup'),
-    ($msz->authInfo->loggedIn ? $sessionInfo->token : $remoteAddr)
-);
-
 // order for these two currently matters i think: it shouldn't.
 $router = $msz->createRouting($request);
 $msz->startTemplating();
@@ -125,16 +41,21 @@ if($msz->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
         $mszLegacyPathReal = realpath($mszLegacyPath);
         if($mszLegacyPath === $mszLegacyPathReal || $mszLegacyPath === $mszLegacyPathReal . '/') {
             // this is here so filters can run...
-            $router->router->route(RouteInfo::exact($request->method, $request->requestTarget, function() {}));
+            $router->router->route(RouteInfo::exact(
+                $request->method,
+                $request->requestTarget,
+                #[Before('authz:cookie')]
+                function() use ($msz, $mszRequestPath) {
+                    if(str_starts_with($mszRequestPath, 'manage') && !$msz->hasManageAccess())
+                        return 403;
+                },
+            ));
             $response = $router->router->handle($request);
             if($response->getBody()->getSize() > 0) {
                 Router::output($response);
                 exit;
             }
 
-            if(str_starts_with($mszRequestPath, 'manage') && !$msz->hasManageAccess())
-                Template::throwError(403);
-
             if(is_dir($mszLegacyPath))
                 $mszLegacyPath .= '/index.php';
 
diff --git a/src/Auth/AuthApiRoutes.php b/src/Auth/AuthApiRoutes.php
deleted file mode 100644
index 374fa0ca..00000000
--- a/src/Auth/AuthApiRoutes.php
+++ /dev/null
@@ -1,160 +0,0 @@
-<?php
-namespace Misuzu\Auth;
-
-use RuntimeException;
-use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context};
-use Misuzu\Users\{UsersContext,UserInfo};
-use Index\Config\Config;
-use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
-use Index\Http\Routing\Filters\PrefixFilter;
-
-final class AuthApiRoutes implements RouteHandler {
-    use RouteHandlerCommon;
-
-    public function __construct(
-        private Config $impersonateConfig,
-        private UsersContext $usersCtx,
-        private OAuth2Context $oauth2Ctx,
-        private AuthContext $authCtx,
-        private AuthInfo $authInfo,
-    ) {}
-
-    private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool {
-        if($impersonator->super)
-            return true;
-
-        $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->id));
-        return in_array($targetId, $whitelist, true);
-    }
-
-    /** @return void|array{error: string, error_description?: string} */
-    #[PrefixFilter('/api/v1')]
-    #[PrefixFilter('/oauth2')]
-    #[PrefixFilter('/uploads')]
-    public function handleAuthorization(HttpResponseBuilder $response, HttpRequest $request) {
-        if($this->authInfo->loggedIn)
-            return;
-
-        $authz = explode(' ', $request->getHeaderLine('Authorization'), 2);
-        if(count($authz) < 2)
-            return;
-
-        [$method, $token] = $authz;
-
-        if(strcasecmp('misuzu', $method) === 0) {
-            $tokenInfo = $this->authCtx->createAuthTokenPacker()->unpack($token);
-            if(!$tokenInfo->isEmpty)
-                $token = $tokenInfo->sessionToken;
-
-            try {
-                $sessionInfo = $this->authCtx->sessions->getSession(sessionToken: $token);
-            } catch(RuntimeException $ex) {
-                $response->statusCode = 401;
-                $response->setHeader('WWW-Authenticate', 'Misuzu error="invalid_token", error_description="Misuzu token has expired."');
-                return;
-            }
-
-            if($sessionInfo->expired) {
-                $response->statusCode = 401;
-                $response->setHeader('WWW-Authenticate', 'Misuzu error="invalid_token", error_description="Misuzu token has expired."');
-                $this->authCtx->sessions->deleteSessions(sessionInfos: $sessionInfo);
-                return;
-            }
-
-            $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress);
-
-            $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id');
-            $userInfoReal = null;
-            if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) {
-                $userInfoReal = $userInfo;
-
-                try {
-                    $userInfo = $this->usersCtx->users->getUser($tokenInfo->impersonatedUserId, 'id');
-                } catch(RuntimeException $ex) {
-                    $userInfo = $userInfoReal;
-                }
-            }
-
-            $this->authInfo->setInfo(
-                tokenInfo: $tokenInfo,
-                userInfo: $userInfo,
-                sessionInfo: $sessionInfo,
-                realUserInfo: $userInfoReal,
-            );
-            return;
-        }
-
-        if(strcasecmp('basic', $method) === 0) {
-            $token = base64_decode($token);
-            if(empty($token)) {
-                $response->statusCode = 401;
-                $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."');
-                return;
-            }
-
-            $authz = explode(':', $token, 2);
-            if(count($authz) < 2) {
-                $response->statusCode = 401;
-                $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."');
-                return;
-            }
-
-            [$clientId, $clientSecret] = $authz;
-
-            try {
-                $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
-            } catch(RuntimeException $ex) {
-                $response->statusCode = 401;
-                $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."');
-                return;
-            }
-
-            if($appInfo->confidential) {
-                // TODO: rate limiting
-
-                if(!$appInfo->verifyClientSecret($clientSecret)) {
-                    $response->statusCode = 401;
-                    $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."');
-                    return;
-                }
-            } elseif($clientSecret !== '') {
-                $response->statusCode = 401;
-                $response->setHeader('WWW-Authenticate', 'Basic error="invalid_token", error_description="Basic credentials are invalid."');
-                return;
-            }
-
-            $this->authInfo->setInfo(
-                appInfo: $appInfo,
-            );
-            return;
-        }
-
-        if(strcasecmp('bearer', $method) === 0) {
-            try {
-                $accessInfo = $this->oauth2Ctx->tokens->getAccessInfo($token, OAuth2AccessInfoGetField::Token);
-            } catch(RuntimeException $ex) {
-                $accessInfo = null;
-            }
-
-            if($accessInfo?->expired !== false) {
-                $response->statusCode = 401;
-                $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Access token has expired."');
-                return [
-                    'error' => 'invalid_token',
-                    'error_description' => 'Access token has expired.',
-                ];
-            }
-
-            $userInfo = null;
-            if($accessInfo->userId !== null)
-                $userInfo = $this->usersCtx->users->getUser($accessInfo->userId, 'id');
-
-            $this->authInfo->setInfo(
-                userInfo: $userInfo,
-                accessInfo: $accessInfo,
-            );
-            return;
-        }
-    }
-}
diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php
index 6c1de7c9..b5292413 100644
--- a/src/Auth/AuthInfo.php
+++ b/src/Auth/AuthInfo.php
@@ -46,6 +46,11 @@ class AuthInfo {
         $this->setInfo();
     }
 
+    public bool $hasInfo {
+        get => $this->userInfo !== null
+            || $this->appInfo !== null;
+    }
+
     public bool $loggedIn {
         get => $this->userInfo !== null;
     }
@@ -55,11 +60,13 @@ class AuthInfo {
     }
 
     public bool $loggedInBearer {
-        get => $this->accessInfo !== null && $this->userInfo !== null;
+        get => $this->accessInfo !== null
+            && $this->userInfo !== null;
     }
 
     public bool $loggedInBearerClient {
-        get => $this->accessInfo !== null && $this->userInfo === null;
+        get => $this->accessInfo !== null
+            && $this->userInfo === null;
     }
 
     public ?string $userId {
diff --git a/src/Auth/AuthProcessors.php b/src/Auth/AuthProcessors.php
new file mode 100644
index 00000000..afc778bb
--- /dev/null
+++ b/src/Auth/AuthProcessors.php
@@ -0,0 +1,468 @@
+<?php
+namespace Misuzu\Auth;
+
+use RuntimeException;
+use Carbon\Carbon;
+use Index\Config\Config;
+use Index\Http\{HttpRequest,HttpResponseBuilder};
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Preprocessor;
+use Misuzu\{CsrfContext,Misuzu};
+use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context};
+use Misuzu\Users\{BansData,UsersContext,UserInfo};
+
+final class AuthProcessors implements RouteHandler {
+    use RouteHandlerCommon;
+
+    public function __construct(
+        private Config $impersonateConfig,
+        private UsersContext $usersCtx,
+        private OAuth2Context $oauth2Ctx,
+        private CsrfContext $csrfCtx,
+        private AuthContext $authCtx,
+        private AuthInfo $authInfo,
+    ) {}
+
+    private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool {
+        if($impersonator->super)
+            return true;
+
+        $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->id));
+        return in_array($targetId, $whitelist, true);
+    }
+
+    /** @param array<string, string> $error */
+    private static function applyErrorHeader(HttpResponseBuilder $response, string $method, array $error): void {
+        $parts = [];
+        foreach($error as $name => $value)
+            $parts[] = sprintf('%s="%s"', rawurlencode($name), rawurlencode($value));
+
+        $response->addHeader('WWW-Authenticate', sprintf('%s %s', $method, implode(', ', $parts)));
+    }
+
+    /** @return void|int|array{error: array{name: string, text: string}} */
+    #[Preprocessor('authz:perm')]
+    public function preAuthzPermissionCheck(
+        HandlerContext $context,
+        string $category,
+        int $perm,
+        string $type = '',
+    ) {
+        if($this->authInfo->loggedIn && $this->authInfo->getPerms($category)->check($perm))
+            return;
+
+        if($type === 'json') {
+            $context->response->statusCode = 403;
+            return [
+                'error' => [
+                    'name' => 'permission',
+                    'text' => 'You are not allowed to do this.',
+                ],
+            ];
+        } else return 403;
+    }
+
+    /** @return void|int|array{error: array{name: string, text: string}} */
+    #[Preprocessor('authz:private')]
+    public function preAuthzPrivate(
+        HandlerContext $context,
+        string $type = '',
+        string $argName = 'impersonator',
+        bool $allowDebug = true,
+    ) {
+        if($type === 'arg' && isset($context->args[$argName]))
+            return;
+
+        $result = null;
+        if((!$allowDebug || !Misuzu::debug()) && $this->authInfo->loggedIn && $this->authInfo->impersonating)
+            $result = $this->authInfo->realUserInfo;
+
+        if($type === 'arg')
+            $context->setArgument($argName, $result);
+
+        if($result !== null) {
+            if($type === 'json') {
+                $context->response->statusCode = 403;
+                return [
+                    'error' => [
+                        'name' => 'impersonating',
+                        'text' => 'You are not allowed to do this while impersonating someone.',
+                    ],
+                ];
+            } else return 403;
+        }
+    }
+
+    /** @return void|int|array{error: array{name: string, text: string}} */
+    #[Preprocessor('authz:banned')]
+    public function preAuthzBanned(
+        HandlerContext $context,
+        int $minimumSeverity = BansData::SEVERITY_MIN,
+        string $type = '',
+        string $argName = 'banned',
+    ) {
+        if($type === 'arg' && isset($context->args[$argName]))
+            return;
+
+        $result = null;
+        if($this->authInfo->loggedIn)
+            $result = $this->usersCtx->tryGetActiveBan($this->authInfo->userInfo, $minimumSeverity);
+
+        if($type === 'arg')
+            $context->setArgument($argName, $result);
+
+        if($result !== null) {
+            if($type === 'json') {
+                $context->response->statusCode = 403;
+                return [
+                    'error' => [
+                        'name' => 'ban',
+                        'text' => 'You are banned, check your profile for more information.',
+                    ],
+                ];
+            } else return 403;
+        }
+    }
+
+    /** @return void|int|array{error: array{name: string, text: string}} */
+    #[Preprocessor('authz:cookie')]
+    public function preAuthzCookie(
+        HttpResponseBuilder $response,
+        HttpRequest $request,
+        bool $required = false,
+        string $type = 'html', // should just be replaced with an Accept header read?
+    ) {
+        if($this->authInfo->hasInfo)
+            return;
+
+        $result = (function() use ($response, $request) {
+            $packer = $this->authCtx->createAuthTokenPacker();
+            $builder = $tokenInfo = $userInfo = $sessionInfo = $userInfoReal = null;
+            $mszAuth = $mszUserId = $mszSessionId = null;
+
+            try {
+                $mszAuth = trim($request->getCookie('msz_auth'));
+                if(!empty($mszAuth)) {
+                    $tokenInfo = $packer->unpack($mszAuth);
+                } else {
+                    $mszUserId = trim($request->getCookie('msz_uid'));
+                    $mszSessionId = trim($request->getCookie('msz_sid'));
+
+                    if(!empty($mszUserId) && !empty($mszSessionId))
+                        $tokenInfo = (function($builder) use ($mszUserId, $mszSessionId) {
+                            $builder->setUserId($mszUserId);
+                            $builder->setSessionToken($mszSessionId);
+                            return $builder->toInfo();
+                        })(new AuthTokenBuilder);
+                }
+
+                if(empty($tokenInfo) || $tokenInfo->isEmpty || !$tokenInfo->hasUserId || !$tokenInfo->hasSessionToken)
+                    return false;
+
+                $builder = new AuthTokenBuilder($tokenInfo);
+
+                try {
+                    $sessionInfo = $this->authCtx->sessions->getSession(sessionToken: $tokenInfo->sessionToken);
+                    if($sessionInfo->expired || $sessionInfo->userId !== $tokenInfo->userId) {
+                        $sessionInfo = null;
+                        $builder->removeUserId();
+                        $builder->removeSessionToken();
+                        return false;
+                    }
+
+                    $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id');
+                    if($userInfo->deleted) {
+                        $sessionInfo = $userInfo = null;
+                        $builder->removeUserId();
+                        $builder->removeSessionToken();
+                        return false;
+                    }
+
+                    $this->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $request->remoteAddress);
+                    $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress);
+                    if($sessionInfo->shouldBumpExpires)
+                        $builder->setEdited();
+
+                    if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) {
+                        $userInfoReal = $userInfo;
+
+                        try {
+                            $userInfo = $this->usersCtx->users->getUser($tokenInfo->impersonatedUserId, 'id');
+                        } catch(RuntimeException $ex) {
+                            $userInfo = $userInfoReal;
+                            $userInfoReal = null;
+                            $builder->removeImpersonatedUserId();
+                        }
+                    }
+
+                    return true;
+                } catch(RuntimeException $ex) {
+                    $builder->removeUserId();
+                    $builder->removeSessionToken();
+                    $builder->removeImpersonatedUserId();
+                    $sessionInfo = $userInfo = $userInfoReal = null;
+                    return false;
+                }
+            } finally {
+                $host = $request->getHeaderLine('Host');
+
+                if($mszUserId !== null)
+                    $response->removeCookie('msz_uid', '/', $host, $request->secure, true);
+
+                if($mszSessionId !== null)
+                    $response->removeCookie('msz_sid', '/', $host, $request->secure, true);
+
+                if($builder?->isEdited() === true) {
+                    $tokenInfo = $builder->toInfo();
+                    $response->addCookie(
+                        'msz_auth',
+                        $packer->pack($tokenInfo),
+                        Carbon::now()->addMonths(3),
+                        '/',
+                        $host,
+                        $request->secure,
+                        true,
+                        true,
+                    );
+                }
+
+                $this->authInfo->setInfo(
+                    tokenInfo: $tokenInfo,
+                    userInfo: $userInfo,
+                    sessionInfo: $sessionInfo,
+                    realUserInfo: $userInfoReal,
+                );
+
+                $this->csrfCtx->setIdentity(
+                    $this->authInfo->loggedIn ? $this->authInfo->sessionInfo->token : $request->remoteAddress
+                );
+            }
+        })();
+
+        if($required && !$result) {
+            if($type === 'json') {
+                $response->statusCode = 401;
+                return [
+                    'error' => [
+                        'name' => 'authz',
+                        'text' => 'You must be logged in to do that.',
+                    ],
+                ];
+            }
+
+            return 401;
+        }
+    }
+
+    /** @return void|int|array{error: string, error_description: string} */
+    #[Preprocessor('authz:misuzu')]
+    public function preAuthzMisuzu(
+        HttpResponseBuilder $response,
+        HttpRequest $request,
+        bool $required = true,
+        string $type = 'json',
+    ) {
+        if($this->authInfo->hasInfo)
+            return;
+
+        $result = (function() use ($request) {
+            $authz = explode(' ', $request->getHeaderLine('Authorization'), 2);
+            if(count($authz) < 2 || strcasecmp('misuzu', $authz[0]) !== 0)
+                return false;
+
+            $token = $authz[1];
+            $tokenInfo = $this->authCtx->createAuthTokenPacker()->unpack($token);
+            if(!$tokenInfo->isEmpty)
+                $token = $tokenInfo->sessionToken;
+
+            try {
+                $sessionInfo = $this->authCtx->sessions->getSession(sessionToken: $token);
+            } catch(RuntimeException $ex) {
+                return false;
+            }
+
+            if($sessionInfo->expired) {
+                $this->authCtx->sessions->deleteSessions(sessionInfos: $sessionInfo);
+                return false;
+            }
+
+            $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id');
+            if($userInfo->deleted)
+                return false;
+
+            $this->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $request->remoteAddress);
+            $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress);
+
+            $userInfoReal = null;
+            if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) {
+                $userInfoReal = $userInfo;
+
+                try {
+                    $userInfo = $this->usersCtx->users->getUser($tokenInfo->impersonatedUserId, 'id');
+                } catch(RuntimeException $ex) {
+                    $userInfo = $userInfoReal;
+                }
+            }
+
+            $this->authInfo->setInfo(
+                tokenInfo: $tokenInfo,
+                userInfo: $userInfo,
+                sessionInfo: $sessionInfo,
+                realUserInfo: $userInfoReal,
+            );
+
+            return true;
+        })();
+
+        if($required && !$result) {
+            $info = [
+                'error' => 'invalid_token',
+                'error_description' => 'Misuzu token has expired.',
+            ];
+
+            self::applyErrorHeader($response, 'Misuzu', $info);
+
+            if($type === 'json') {
+                $response->statusCode = 401;
+                return $info;
+            }
+
+            return 401;
+        }
+    }
+
+    /** @return void|int|array{error: string, error_description: string} */
+    #[Preprocessor('authz:basic')]
+    public function preAuthzBasic(
+        HttpResponseBuilder $response,
+        HttpRequest $request,
+        ?FormContent $content = null,
+        bool $required = true,
+        string $type = 'json',
+        bool $bodyId = true,
+        bool $bodySecret = false,
+    ) {
+        if($this->authInfo->hasInfo)
+            return;
+
+        $response->setCacheControl('no-store');
+
+        $result = (function() use ($request, $content, $bodyId, $bodySecret) {
+            if($request->hasHeader('Authorization')) {
+                $token = explode(' ', $request->getHeaderLine('Authorization'), 2);
+                if(count($token) < 2 || strcasecmp('basic', $token[0]) !== 0)
+                    return false;
+
+                $token = base64_decode($token[1]);
+                if(empty($token))
+                    return false;
+
+                $token = explode(':', $token, 2);
+                if(count($token) < 2)
+                    return false;
+
+                [$clientId, $clientSecret] = $token;
+            } elseif($bodyId && $content instanceof FormContent) {
+                $clientId = $content->getParam('client_id');
+                $clientSecret = $bodySecret ? $content->getParam('client_secret') : '';
+            } else return false;
+
+            try {
+                $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
+            } catch(RuntimeException $ex) {
+                return false;
+            }
+
+            if($appInfo->confidential) {
+                // TODO: rate limiting
+
+                if(!$appInfo->verifyClientSecret($clientSecret))
+                    return false;
+            } elseif(trim($clientSecret) !== '') {
+                return false;
+            }
+
+            $this->authInfo->setInfo(
+                appInfo: $appInfo,
+            );
+
+            return true;
+        })();
+
+        if($required && !$result) {
+            $info = [
+                'error' => 'invalid_client',
+                'error_description' => 'Client authentication failed.',
+            ];
+
+            self::applyErrorHeader($response, 'Basic', $info);
+
+            if($type === 'json') {
+                $response->statusCode = 401;
+                return $info;
+            }
+
+            return 401;
+        }
+    }
+
+    /** @return void|int|array{error: string, error_description: string} */
+    #[Preprocessor('authz:bearer')]
+    public function preAuthzBearer(
+        HttpResponseBuilder $response,
+        HttpRequest $request,
+        bool $required = true,
+        string $type = 'json',
+    ) {
+        if($this->authInfo->hasInfo)
+            return;
+
+        $result = (function() use ($request) {
+            $authz = explode(' ', $request->getHeaderLine('Authorization'), 2);
+            if(count($authz) < 2 || strcasecmp('basic', $authz[0]) !== 0)
+                return false;
+
+            try {
+                $accessInfo = $this->oauth2Ctx->tokens->getAccessInfo($authz[1], OAuth2AccessInfoGetField::Token);
+            } catch(RuntimeException $ex) {
+                return false;
+            }
+
+            if($accessInfo->expired)
+                return false;
+
+            $userInfo = null;
+            if($accessInfo->userId !== null) {
+                $userInfo = $this->usersCtx->users->getUser($accessInfo->userId, 'id');
+                if($userInfo->deleted)
+                    return false;
+
+                $this->usersCtx->users->recordUserActivity($userInfo, remoteAddr: $request->remoteAddress);
+            }
+
+            $this->authInfo->setInfo(
+                userInfo: $userInfo,
+                accessInfo: $accessInfo,
+            );
+
+            return true;
+        })();
+
+        if($required && !$result) {
+            $info = [
+                'error' => 'invalid_token',
+                'error_description' => 'Access token has expired.',
+            ];
+
+            self::applyErrorHeader($response, 'Bearer', $info);
+
+            if($type === 'json') {
+                $response->statusCode = 401;
+                return $info;
+            }
+
+            return 401;
+        }
+    }
+}
diff --git a/src/Auth/AuthTokenCookie.php b/src/Auth/AuthTokenCookie.php
index dd2ceba0..e2033d7d 100644
--- a/src/Auth/AuthTokenCookie.php
+++ b/src/Auth/AuthTokenCookie.php
@@ -10,9 +10,6 @@ final class AuthTokenCookie {
         if(empty($url))
             $url = $_SERVER['HTTP_HOST'];
 
-        if(!filter_var($url, FILTER_VALIDATE_IP))
-            $url = '.' . $url;
-
         return $url;
     }
 
diff --git a/src/CSRF.php b/src/CSRF.php
deleted file mode 100644
index 8a7d76d8..00000000
--- a/src/CSRF.php
+++ /dev/null
@@ -1,45 +0,0 @@
-<?php
-namespace Misuzu;
-
-use Index\CsrfToken;
-
-final class CSRF {
-    private static ?CsrfToken $instance = null;
-    private static string $secretKey = '';
-
-    public static function available(): bool {
-        return self::$instance !== null;
-    }
-
-    public static function create(string $identity, ?string $secretKey = null): CsrfToken {
-        if($secretKey === null)
-            $secretKey = self::$secretKey;
-        else
-            self::$secretKey = $secretKey;
-
-        return new CsrfToken($secretKey, $identity);
-    }
-
-    public static function init(string $secretKey, string $identity): void {
-        self::$instance = self::create($identity, $secretKey);
-    }
-
-    public static function validate(string $token, int $tolerance = -1): bool {
-        return self::$instance?->verifyToken($token, $tolerance) ?? false;
-    }
-
-    public static function token(): string {
-        return self::$instance?->createToken() ?? '';
-    }
-
-    public static function validateRequest(int $tolerance = -1): bool {
-        if(self::$instance === null)
-            return false;
-
-        $token = isset($_POST['_csrf']) && is_string($_POST['_csrf']) ? $_POST['_csrf'] : '';
-        if(empty($token))
-            $token = isset($_GET['csrf']) && is_string($_GET['csrf']) ? $_GET['csrf'] : '';
-
-        return self::$instance->verifyToken($token, $tolerance);
-    }
-}
diff --git a/src/Changelog/ChangelogRoutes.php b/src/Changelog/ChangelogRoutes.php
index b5356f1d..8b91a295 100644
--- a/src/Changelog/ChangelogRoutes.php
+++ b/src/Changelog/ChangelogRoutes.php
@@ -5,6 +5,7 @@ use ErrorException;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Syndication\FeedBuilder;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
@@ -24,6 +25,7 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
     ) {}
 
     #[ExactRoute('GET', '/changelog')]
+    #[Before('authz:cookie')]
     #[UrlFormat('changelog-index', '/changelog', ['date' => '<date>', 'user' => '<user>', 'tags' => '<tags>', 'p' => '<page>'])]
     public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string {
         $filterDate = (string)$request->getParam('date');
@@ -98,6 +100,7 @@ final class ChangelogRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('GET', '/changelog/change/([0-9]+)')]
+    #[Before('authz:cookie')]
     #[UrlFormat('changelog-change', '/changelog/change/<change>')]
     #[UrlFormat('changelog-change-comments', '/changelog/change/<change>', fragment: 'comments')]
     public function getChange(HttpResponseBuilder $response, HttpRequest $request, string $changeId): int|string {
diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php
index 791e61cc..18ddd0ca 100644
--- a/src/Comments/CommentsRoutes.php
+++ b/src/Comments/CommentsRoutes.php
@@ -6,11 +6,10 @@ use Index\XArray;
 use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
-use Index\Http\Routing\Filters\PrefixFilter;
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
-use Misuzu\{CSRF,Perm};
+use Misuzu\Perm;
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Perms\{PermissionResult,IPermissionResult};
 use Misuzu\Users\{UserInfo,UsersContext,UsersData};
@@ -189,21 +188,6 @@ class CommentsRoutes implements RouteHandler, UrlSource {
         ];
     }
 
-    /** @return void|array{error: array{name: string, text: string}} */
-    #[PrefixFilter('/comments')]
-    public function checkCsrf(HttpResponseBuilder $response, HttpRequest $request) {
-        if(in_array($request->method, ['DELETE', 'PATCH', 'POST'])) {
-            if(!$this->authInfo->loggedIn)
-                return self::error($response, 401, 'comments:auth', 'You must be logged in to use the comments system.');
-            if(!CSRF::validate($request->getHeaderLine('x-csrf-token')))
-                return self::error($response, 403, 'comments:csrf', 'Request could not be verified. Please try again.');
-            if($this->usersCtx->hasActiveBan($this->authInfo->userInfo))
-                return self::error($response, 403, 'comments:csrf', 'You are banned, check your profile for more information.');
-        }
-
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-    }
-
     /**
      * @return array{
      *  category: array{
@@ -233,6 +217,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * }|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('GET', '/comments/categories/([A-Za-z0-9-]+)')]
+    #[Before('authz:cookie')]
     public function getCategory(HttpResponseBuilder $response, string $categoryName): array {
         try {
             $catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName);
@@ -295,6 +280,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * }|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('POST', '/comments/categories/([A-Za-z0-9-]+)')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[Before('input:urlencoded')]
     public function patchCategory(HttpResponseBuilder $response, FormContent $content, string $categoryName): array {
         try {
@@ -335,6 +323,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
     #[ExactRoute('POST', '/comments/posts')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[Before('input:multipart')]
     public function postPost(HttpResponseBuilder $response, FormContent $content): array {
         $perms = $this->getGlobalPerms();
@@ -397,6 +388,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('GET', '/comments/posts/([0-9]+)')]
+    #[Before('authz:cookie')]
     public function getPost(HttpResponseBuilder $response, string $commentId): array {
         try {
             $postInfo = $this->commentsCtx->posts->getPost($commentId);
@@ -422,6 +414,7 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('GET', '/comments/posts/([0-9]+)/replies')]
+    #[Before('authz:cookie')]
     public function getPostReplies(HttpResponseBuilder $response, string $commentId): array {
         try {
             $postInfo = $this->commentsCtx->posts->getPost($commentId);
@@ -446,6 +439,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * }|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('PATCH', '/comments/posts/([0-9]+)')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[Before('input:multipart')]
     public function patchPost(HttpResponseBuilder $response, FormContent $content, string $commentId): array {
         try {
@@ -515,6 +511,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * @return string|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('DELETE', '/comments/posts/([0-9]+)')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     public function deletePost(HttpResponseBuilder $response, string $commentId): array|string {
         try {
             $postInfo = $this->commentsCtx->posts->getPost($commentId);
@@ -544,6 +543,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('POST', '/comments/posts/([0-9]+)/restore')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     public function postPostRestore(HttpResponseBuilder $response, string $commentId): array {
         if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
             return self::error($response, 403, 'comments:restore-not-allowed', 'You are not allowed to restore comments.');
@@ -569,6 +571,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * @return mixed[]|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('POST' ,'/comments/posts/([0-9]+)/nuke')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     public function postPostNuke(HttpResponseBuilder $response, string $commentId): array {
         if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
             return self::error($response, 403, 'comments:nuke-not-allowed', 'You are not allowed to permanently delete comments.');
@@ -598,6 +603,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * }|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('POST', '/comments/posts/([0-9]+)/vote')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[Before('input:urlencoded')]
     public function postPostVote(HttpResponseBuilder $response, FormContent $content, string $commentId): array {
         $vote = (int)$content->getFilteredParam('vote', FILTER_SANITIZE_NUMBER_INT);
@@ -644,6 +652,9 @@ class CommentsRoutes implements RouteHandler, UrlSource {
      * }|array{error: array{name: string, text: string}}
      */
     #[PatternRoute('DELETE', '/comments/posts/([0-9]+)/vote')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
         if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE))
             return self::error($response, 403, 'comments:vote-not-allowed', 'You are not allowed to vote on comments.');
diff --git a/src/CsrfContext.php b/src/CsrfContext.php
new file mode 100644
index 00000000..cbc1509b
--- /dev/null
+++ b/src/CsrfContext.php
@@ -0,0 +1,129 @@
+<?php
+namespace Misuzu;
+
+use Index\CsrfToken;
+use Index\Config\Config;
+use Index\Http\Content\FormContent;
+use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Preprocessor;
+use Twig\TwigFunction;
+use Twig\Extension\AbstractExtension;
+
+class CsrfContext extends AbstractExtension implements RouteHandler {
+    use RouteHandlerCommon;
+
+    private ?CsrfToken $instance = null;
+
+    public function __construct(
+        private Config $config,
+    ) {}
+
+    public function createInstance(string $identity): CsrfToken {
+        return new CsrfToken(
+            $this->config->getString('secret', 'soup'),
+            $identity,
+        );
+    }
+
+    public function isAvailable(): bool {
+        return $this->instance !== null;
+    }
+
+    public function setIdentity(string $identity): void {
+        $this->instance = $this->createInstance($identity);
+    }
+
+    public function createToken(): string {
+        return $this->instance?->createToken() ?? '';
+    }
+
+    public function verifyToken(string $token, int $tolerance = -1): bool {
+        return $this->instance?->verifyToken($token, $tolerance) ?? false;
+    }
+
+    public function verifyLegacy(int $tolerance = -1): bool {
+        if(!$this->isAvailable())
+            return false;
+
+        $token = isset($_POST['_csrf']) && is_string($_POST['_csrf']) ? $_POST['_csrf'] : '';
+        if(empty($token))
+            $token = isset($_GET['csrf']) && is_string($_GET['csrf']) ? $_GET['csrf'] : '';
+
+        return $this->verifyToken($token, $tolerance);
+    }
+
+    #[\Override]
+    public function getFunctions() {
+        return [
+            new TwigFunction('csrf_available', $this->isAvailable(...)),
+            new TwigFunction('csrf_token', $this->createToken(...)),
+        ];
+    }
+
+    /** @return void|int|array{error: array{name: string, text: string}} */
+    #[Preprocessor('csrf:header')]
+    public function preVerifyHeader(
+        HandlerContext $context,
+        string $type = '',
+        string $argName = 'csrf',
+    ) {
+        if($type === 'arg' && isset($context->args[$argName]) && $context->args[$argName] === true)
+            return;
+
+        $result = $this->isAvailable()
+            && $context->request->hasHeader('X-CSRF-Token')
+            && $this->verifyToken($context->request->getHeaderLine('X-CSRF-Token'));
+
+        if($result) {
+            $context->response->setHeader('X-CSRF-Token', $this->createToken());
+            if($type === 'arg')
+                $context->setArgument($argName, true);
+        } else {
+            if($type === 'arg')
+                $context->setArgument($argName, false);
+            elseif($type === 'json') {
+                $context->response->statusCode = 403;
+                return [
+                    'error' => [
+                        'name' => 'csrf',
+                        'text' => 'Request could not be verified. Please try again.',
+                    ],
+                ];
+            } else return 403;
+        }
+    }
+
+    /** @return void|int|array{error: array{name: string, text: string}} */
+    #[Preprocessor('csrf:form')]
+    public function preVerifyForm(
+        HandlerContext $context,
+        ?FormContent $content,
+        string $formParam = '_csrf',
+        string $type = '',
+        string $argName = 'csrf',
+    ) {
+        if($type === 'arg' && isset($context->args[$argName]) && $context->args[$argName] === true)
+            return;
+
+        $result = $this->isAvailable()
+            && $content?->hasParam($formParam) === true
+            && $this->verifyToken((string)$content->getParam($formParam));
+
+        if($result) {
+            if($type === 'arg')
+                $context->setArgument($argName, true);
+        } else {
+            if($type === 'arg') {
+                $context->setArgument($argName, false);
+            } elseif($type === 'json') {
+                $context->response->statusCode = 403;
+                return [
+                    'error' => [
+                        'name' => 'csrf',
+                        'text' => 'Request could not be verified. Please try again.',
+                    ],
+                ];
+            } else return 403;
+        }
+    }
+}
diff --git a/src/Forum/ForumCategoriesRoutes.php b/src/Forum/ForumCategoriesRoutes.php
index ab7de7c9..a6529546 100644
--- a/src/Forum/ForumCategoriesRoutes.php
+++ b/src/Forum/ForumCategoriesRoutes.php
@@ -5,9 +5,10 @@ use stdClass;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
-use Misuzu\{CSRF,Pagination,Perm,Template};
+use Misuzu\{Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Users\UsersContext;
 
@@ -21,6 +22,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
     ) {}
 
     #[ExactRoute('GET', '/forum')]
+    #[Before('authz:cookie')]
     #[UrlFormat('forum-index', '/forum')]
     #[UrlFormat('forum-category-root', '/forum', fragment: '<forum>')]
     public function getIndex(): string {
@@ -172,6 +174,7 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('GET', '/forum/([0-9]+)')]
+    #[Before('authz:cookie')]
     #[UrlFormat('forum-category', '/forum/<forum>', ['page' => '<page>'])]
     public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $catId): mixed {
         try {
@@ -330,15 +333,10 @@ class ForumCategoriesRoutes implements RouteHandler, UrlSource {
     }
 
     #[ExactRoute('POST', '/forum/mark-as-read')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
     #[UrlFormat('forum-mark-as-read', '/forum/mark-as-read', ['cat' => '<category>', 'rec' => '<recursive>'])]
     public function postMarkAsRead(HttpResponseBuilder $response, HttpRequest $request): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
         $catId = (string)$request->getFilteredParam('cat', FILTER_SANITIZE_NUMBER_INT);
         $recursive = !empty($request->getParam('rec'));
 
diff --git a/src/Forum/ForumPostsRoutes.php b/src/Forum/ForumPostsRoutes.php
index 0080374e..bb436a21 100644
--- a/src/Forum/ForumPostsRoutes.php
+++ b/src/Forum/ForumPostsRoutes.php
@@ -4,12 +4,12 @@ namespace Misuzu\Forum;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\PatternRoute;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
-use Misuzu\{CSRF,Perm};
+use Misuzu\Perm;
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Logs\LogsContext;
-use Misuzu\Users\UsersContext;
 
 class ForumPostsRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
@@ -17,12 +17,12 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
     public function __construct(
         private UrlRegistry $urls,
         private ForumContext $forumCtx,
-        private UsersContext $usersCtx,
         private LogsContext $logsCtx,
         private AuthInfo $authInfo,
     ) {}
 
     #[PatternRoute('GET', '/forum/posts/([0-9]+)')]
+    #[Before('authz:cookie')]
     #[UrlFormat('forum-post', '/forum/posts/<post>')]
     public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
         try {
@@ -56,25 +56,11 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('DELETE', '/forum/posts/([0-9]+)')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-post-delete', '/forum/posts/<post>')]
     public function deletePost(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $post = $this->forumCtx->posts->getPost(
                 postId: $postId,
@@ -185,25 +171,11 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('POST', '/forum/posts/([0-9]+)/nuke')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-post-nuke', '/forum/posts/<post>/nuke')]
     public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $post = $this->forumCtx->posts->getPost(
                 postId: $postId,
@@ -259,25 +231,11 @@ class ForumPostsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('POST', '/forum/posts/([0-9]+)/restore')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-post-restore', '/forum/posts/<post>/restore')]
     public function postPostRestore(HttpResponseBuilder $response, HttpRequest $request, string $postId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $post = $this->forumCtx->posts->getPost(
                 postId: $postId,
diff --git a/src/Forum/ForumTopicsRoutes.php b/src/Forum/ForumTopicsRoutes.php
index f87ffe56..e173500c 100644
--- a/src/Forum/ForumTopicsRoutes.php
+++ b/src/Forum/ForumTopicsRoutes.php
@@ -6,9 +6,10 @@ use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\PatternRoute;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
-use Misuzu\{CSRF,Pagination,Perm,Template};
+use Misuzu\{Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Logs\LogsContext;
 use Misuzu\Users\UsersContext;
@@ -24,6 +25,7 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     ) {}
 
     #[PatternRoute('GET', '/forum/topics/([0-9]+)')]
+    #[Before('authz:cookie')]
     #[UrlFormat('forum-topic', '/forum/topics/<topic>', ['page' => '<page>'], '<topic_fragment>')]
     public function getTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
         $isNuked = $deleted = $canDeleteAny = false;
@@ -155,25 +157,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('DELETE', '/forum/topics/([0-9]+)')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-topic-delete', '/forum/topics/<topic>')]
     public function deleteTopic(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $topic = $this->forumCtx->topics->getTopic(
                 topicId: $topicId,
@@ -269,25 +257,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('POST', '/forum/topics/([0-9]+)/restore')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-topic-restore', '/forum/topics/<topic>/restore')]
     public function postTopicRestore(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $topic = $this->forumCtx->topics->getTopic(
                 topicId: $topicId,
@@ -343,25 +317,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('POST', '/forum/topics/([0-9]+)/nuke')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-topic-nuke', '/forum/topics/<topic>/nuke')]
     public function postTopicNuke(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $topic = $this->forumCtx->topics->getTopic(
                 topicId: $topicId,
@@ -417,25 +377,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('POST', '/forum/topics/([0-9]+)/bump')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-topic-bump', '/forum/topics/<topic>/bump')]
     public function postTopicBump(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $topic = $this->forumCtx->topics->getTopic(
                 topicId: $topicId,
@@ -491,25 +437,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('POST', '/forum/topics/([0-9]+)/lock')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-topic-lock', '/forum/topics/<topic>/lock')]
     public function postTopicLock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $topic = $this->forumCtx->topics->getTopic(
                 topicId: $topicId,
@@ -575,25 +507,11 @@ class ForumTopicsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('POST', '/forum/topics/([0-9]+)/unlock')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('csrf:header', type: 'json')]
+    #[Before('authz:banned', type: 'json')]
     #[UrlFormat('forum-topic-unlock', '/forum/topics/<topic>/unlock')]
     public function postTopicUnlock(HttpResponseBuilder $response, HttpRequest $request, string $topicId): mixed {
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
-            return 403;
-        $response->setHeader('X-CSRF-Token', CSRF::token());
-
-        if($this->usersCtx->hasActiveBan($this->authInfo->userInfo)) {
-            $response->statusCode = 403;
-            return [
-                'error' => [
-                    'name' => 'user:banned',
-                    'text' => "You aren't allowed to do that while banned.",
-                ],
-            ];
-        }
-
         try {
             $topic = $this->forumCtx->topics->getTopic(
                 topicId: $topicId,
diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php
index df374802..96c3d55e 100644
--- a/src/Home/HomeRoutes.php
+++ b/src/Home/HomeRoutes.php
@@ -8,6 +8,7 @@ use Index\Colour\Colour;
 use Index\Db\{DbConnection,DbTools};
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\ExactRoute;
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
@@ -200,6 +201,7 @@ class HomeRoutes implements RouteHandler, UrlSource {
     }
 
     #[ExactRoute('GET', '/')]
+    #[Before('authz:cookie')]
     #[UrlFormat('index', '/')]
     public function getIndex(): string {
         return $this->authInfo->loggedIn ? $this->getHome() : $this->getLanding();
diff --git a/src/Info/InfoRoutes.php b/src/Info/InfoRoutes.php
index 2493d726..d6c108ab 100644
--- a/src/Info/InfoRoutes.php
+++ b/src/Info/InfoRoutes.php
@@ -4,6 +4,7 @@ namespace Misuzu\Info;
 use Index\Index;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{Misuzu,Template};
@@ -22,12 +23,14 @@ class InfoRoutes implements RouteHandler, UrlSource {
     ];
 
     #[ExactRoute('GET', '/info')]
+    #[Before('authz:cookie')]
     #[UrlFormat('info-index', '/info')]
     public function getIndex(): string {
         return Template::renderRaw('info.index');
     }
 
     #[PatternRoute('GET', '/info/([A-Za-z0-9_]+)')]
+    #[Before('authz:cookie')]
     #[UrlFormat('info', '/info/<title>')]
     #[UrlFormat('info-doc', '/info/<title>')]
     public function getDocsPage(HttpResponseBuilder $response, HttpRequest $request, string $name): string {
@@ -75,6 +78,7 @@ class InfoRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('GET', '/info/([A-Za-z0-9_]+)/([A-Za-z0-9_]+)')]
+    #[Before('authz:cookie')]
     #[UrlFormat('info-project-doc', '/info/<project>/<title>')]
     public function getProjectPage(HttpResponseBuilder $response, HttpRequest $request, string $project, string $name): int|string {
         if(!array_key_exists($project, self::PROJECT_PATHS))
diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php
index 6adc5d60..34c36056 100644
--- a/src/Messages/MessagesRoutes.php
+++ b/src/Messages/MessagesRoutes.php
@@ -10,11 +10,10 @@ use Index\Colour\Colour;
 use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
-use Index\Http\Routing\Filters\PrefixFilter;
 use Index\Http\Routing\Processors\{After,Before};
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
-use Misuzu\{CSRF,Misuzu,Pagination,Perm,Template};
+use Misuzu\{Misuzu,Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Parsers\TextFormat;
 use Misuzu\Perms\PermissionsData;
@@ -36,39 +35,13 @@ class MessagesRoutes implements RouteHandler, UrlSource {
         private AuthInfo $authInfo,
         private MessagesContext $msgsCtx,
         private UsersContext $usersCtx,
-        private PermissionsData $perms
+        private PermissionsData $perms,
     ) {}
 
-    private bool $canSendMessages;
-
-    /** @return void|int|array{error: array{name: string, text: string}} */
-    #[PrefixFilter('/messages')]
-    public function checkAccess(HttpResponseBuilder $response, HttpRequest $request) {
-        // should probably be a permission or something too
-        if(!$this->authInfo->loggedIn)
-            return 401;
-
-        // do not allow access to PMs when impersonating in production mode
-        if(!Misuzu::debug() && $this->authInfo->impersonating)
-            return 403;
-
-        $globalPerms = $this->authInfo->getPerms('global');
-        if(!$globalPerms->check(Perm::G_MESSAGES_VIEW))
-            return 403;
-
-        $this->canSendMessages = $globalPerms->check(Perm::G_MESSAGES_SEND)
-            && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo);
-
-        if($request->method === 'POST') {
-            if(!CSRF::validate($request->getHeaderLine('x-csrf-token')))
-                return [
-                    'error' => [
-                        'name' => 'msgs:verify',
-                        'text' => 'Request verification failed! Refresh the page and try again.',
-                    ],
-                ];
-
-            $response->setHeader('X-CSRF-Token', CSRF::token());
+    private bool $canSendMessages {
+        get {
+            return $this->authInfo->getPerms('global')->check(Perm::G_MESSAGES_SEND)
+                && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo);
         }
     }
 
@@ -94,6 +67,9 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     #[ExactRoute('GET', '/messages')]
+    #[Before('authz:cookie', required: true)]
+    #[Before('authz:private')]
+    #[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
     #[UrlFormat('messages-index', '/messages', ['folder' => '<folder>', 'page' => '<page>'])]
     public function getIndex(HttpRequest $request, string $folderName = ''): int|string {
         $folderName = (string)$request->getParam('folder');
@@ -148,6 +124,9 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
     /** @return array{unread: int} */
     #[ExactRoute('GET', '/messages/stats')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
     #[UrlFormat('messages-stats', '/messages/stats')]
     public function getStats(): array {
         $selfInfo = $this->authInfo->userInfo;
@@ -173,6 +152,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
      * }
      */
     #[ExactRoute('POST', '/messages/recipient')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
+    #[Before('csrf:header', type: 'json')]
     #[Before('input:urlencoded')]
     #[UrlFormat('messages-recipient', '/messages/recipient')]
     public function postRecipient(FormContent $content): int|array {
@@ -211,6 +194,9 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     #[ExactRoute('GET', '/messages/compose')]
+    #[Before('authz:cookie', required: true)]
+    #[Before('authz:private')]
+    #[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
     #[UrlFormat('messages-compose', '/messages/compose', ['recipient' => '<recipient>'])]
     public function getEditor(HttpRequest $request): int|string {
         if(!$this->canSendMessages)
@@ -222,6 +208,9 @@ class MessagesRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('GET', '/messages/([A-Za-z0-9]+)')]
+    #[Before('authz:cookie', required: true)]
+    #[Before('authz:private')]
+    #[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
     #[UrlFormat('messages-view', '/messages/<message>')]
     public function getView(string $messageId): int|string {
         if(strlen($messageId) !== 8)
@@ -355,6 +344,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
     /** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */
     #[ExactRoute('POST', '/messages/create')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
+    #[Before('csrf:header', type: 'json')]
     #[Before('input:multipart')]
     #[UrlFormat('messages-create', '/messages/create')]
     public function postCreate(FormContent $content): int|array {
@@ -457,6 +450,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
     /** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */
     #[PatternRoute('PATCH', '/messages/([A-Za-z0-9]+)')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
+    #[Before('csrf:header', type: 'json')]
     #[Before('input:multipart')]
     #[UrlFormat('messages-update', '/messages/<message>')]
     public function patchUpdate(FormContent $content, string $messageId): int|array {
@@ -547,6 +544,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
     #[ExactRoute('POST', '/messages/mark')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
+    #[Before('csrf:header', type: 'json')]
     #[Before('input:urlencoded')]
     #[UrlFormat('messages-mark', '/messages/mark')]
     public function postMark(FormContent $content): int|array {
@@ -576,6 +577,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
     #[ExactRoute('POST', '/messages/delete')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
+    #[Before('csrf:header', type: 'json')]
     #[Before('input:urlencoded')]
     #[UrlFormat('messages-delete', '/messages/delete')]
     public function postDelete(FormContent $content): int|array {
@@ -600,6 +605,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
     #[ExactRoute('POST', '/messages/restore')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
+    #[Before('csrf:header', type: 'json')]
     #[Before('input:urlencoded')]
     #[UrlFormat('messages-restore', '/messages/restore')]
     public function postRestore(FormContent $content) {
@@ -624,6 +633,10 @@ class MessagesRoutes implements RouteHandler, UrlSource {
 
     /** @return int|array{error: array{name: string, text: string}}|scalar[] */
     #[ExactRoute('POST', '/messages/nuke')]
+    #[Before('authz:cookie', type: 'json', required: true)]
+    #[Before('authz:private', type: 'json')]
+    #[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
+    #[Before('csrf:header', type: 'json')]
     #[Before('input:urlencoded')]
     #[UrlFormat('messages-nuke', '/messages/nuke')]
     public function postNuke(FormContent $content) {
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index 6b1e3d58..a6946bed 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -7,11 +7,13 @@ use Index\Config\Db\DbConfig;
 use Index\Db\DbConnection;
 use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo};
 use Index\Http\HttpRequest;
+use Index\Http\Routing\RouteHandler;
 use Index\Snowflake\{BinarySnowflake,RandomSnowflake,SnowflakeGenerator};
 use Index\Templating\TplEnvironment;
 use Index\Urls\UrlRegistry;
 use Misuzu\Routing\RoutingContext;
 use Misuzu\Users\UserInfo;
+use Twig\Extension\ExtensionInterface;
 
 class MisuzuContext {
     public private(set) Dependencies $deps;
@@ -28,6 +30,7 @@ class MisuzuContext {
     public private(set) Counters\CountersData $counters;
     public private(set) News\NewsData $news;
 
+    public private(set) CsrfContext $csrfCtx;
     public private(set) DatabaseContext $dbCtx;
     public private(set) Apps\AppsContext $appsCtx;
     public private(set) Auth\AuthContext $authCtx;
@@ -95,6 +98,10 @@ class MisuzuContext {
         ));
         $this->deps->register($this->coloursCtx = $this->deps->constructLazy(Colours\ColoursContext::class));
         $this->deps->register($this->commentsCtx = $this->deps->constructLazy(Comments\CommentsContext::class));
+        $this->deps->register($this->csrfCtx = $this->deps->constructLazy(
+            CsrfContext::class,
+            config: $this->config->scopeTo('csrf'),
+        ));
         $this->deps->register($this->emotesCtx = $this->deps->constructLazy(Emoticons\EmotesContext::class));
         $this->deps->register($this->forumCtx = $this->deps->constructLazy(
             Forum\ForumContext::class,
@@ -163,6 +170,11 @@ class MisuzuContext {
             cache: $isDebug || !$cache ? null : ['Misuzu', GitInfo::hash(true)],
             debug: $isDebug
         );
+
+        $exts = $this->deps->all(ExtensionInterface::class);
+        foreach($exts as $ext)
+            $this->templating->addExtension($ext);
+
         $this->templating->addExtension($this->deps->construct(TemplatingExtension::class));
         $this->templating->addGlobal('globals', $globals);
 
@@ -175,7 +187,10 @@ class MisuzuContext {
 
         $routingCtx = $this->deps->construct(RoutingContext::class);
         $this->deps->register($this->urls = $routingCtx->urls);
-        $routingCtx->register($this->logsCtx);
+
+        $handlers = $this->deps->all(RouteHandler::class);
+        foreach($handlers as $handler)
+            $routingCtx->register($handler);
 
         if(in_array('main', $roles))
             $this->registerMainRoutes($routingCtx);
@@ -200,7 +215,7 @@ class MisuzuContext {
         $routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class));
 
         $routingCtx->register($this->deps->constructLazy(
-            Auth\AuthApiRoutes::class,
+            Auth\AuthProcessors::class,
             impersonateConfig: $this->config->scopeTo('impersonate')
         ));
         $routingCtx->register($this->deps->constructLazy(Colours\ColoursApiRoutes::class));
diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php
index f4805bee..bf1a4837 100644
--- a/src/News/NewsRoutes.php
+++ b/src/News/NewsRoutes.php
@@ -5,6 +5,7 @@ use RuntimeException;
 use Index\Colour\Colour;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Syndication\FeedBuilder;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
@@ -100,6 +101,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
     }
 
     #[ExactRoute('GET', '/news')]
+    #[Before('authz:cookie')]
     #[UrlFormat('news-index', '/news', ['p' => '<page>'])]
     public function getIndex(HttpResponseBuilder $response, HttpRequest $request): int|string {
         $categories = $this->news->getCategories(hidden: false);
@@ -143,6 +145,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('GET', '/news/post/([0-9]+)')]
+    #[Before('authz:cookie')]
     #[UrlFormat('news-post', '/news/post/<post>')]
     #[UrlFormat('news-post-comments', '/news/post/<post>', fragment: 'comments')]
     public function getPost(HttpResponseBuilder $response, HttpRequest $request, string $postId): int|string {
diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php
index da32e3a6..ecac8aad 100644
--- a/src/OAuth2/OAuth2ApiRoutes.php
+++ b/src/OAuth2/OAuth2ApiRoutes.php
@@ -193,51 +193,17 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
      */
     #[PatternRoute('POST', '/oauth2/request-authori[sz]e')]
     #[Before('input:urlencoded', required: false)]
+    #[Before('authz:basic')]
     #[UrlFormat('oauth2-request-authorise', '/oauth2/request-authorize')]
     public function postRequestAuthorise(HttpResponseBuilder $response, HttpRequest $request, ?FormContent $content): array {
-        $response->setHeader('Cache-Control', 'no-store');
-
         if($content === null)
             return self::filter($response, $request, [
                 'error' => 'invalid_request',
                 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
             ]);
 
-        $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
-        if(strcasecmp($authzHeader[0], 'Basic') === 0) {
-            $authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
-            $clientId = $authzHeader[0];
-            $clientSecret = $authzHeader[1] ?? '';
-        } elseif($authzHeader[0] !== '') {
-            return self::filter($response, $request, [
-                'error' => 'invalid_client',
-                'error_description' => 'You must use the Basic method for Authorization parameters.',
-            ], authzHeader: true);
-        } else {
-            $clientId = (string)$content->getParam('client_id');
-            $clientSecret = '';
-        }
-
-        try {
-            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
-        } catch(RuntimeException $ex) {
-            return self::filter($response, $request, [
-                'error' => 'invalid_client',
-                'error_description' => 'No application has been registered with this client ID.',
-            ], authzHeader: $authzHeader[0] !== '');
-        }
-
-        if($clientSecret !== '') {
-            // TODO: rate limiting
-            if(!$appInfo->verifyClientSecret($clientSecret))
-                return self::filter($response, $request, [
-                    'error' => 'invalid_client',
-                    'error_description' => 'Provided client secret is not correct for this application.',
-                ], authzHeader: true);
-        }
-
         return self::filter($response, $request, $this->oauth2Ctx->createDeviceAuthorisationRequest(
-            $appInfo,
+            $this->authInfo->appInfo,
             $content->hasParam('scope') ? (string)$content->getParam('scope') : null
         ));
     }
@@ -254,81 +220,40 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
     #[AccessControl(allowHeaders: ['Authorization'], exposeHeaders: ['WWW-Authenticate'])]
     #[ExactRoute('POST', '/oauth2/token')]
     #[Before('input:urlencoded', required: false)]
+    #[Before('authz:basic', bodySecret: true)]
     #[UrlFormat('oauth2-token', '/oauth2/token')]
     public function postToken(HttpResponseBuilder $response, HttpRequest $request, ?FormContent $content): array {
-        $response->setHeader('Cache-Control', 'no-store');
-
         if($content === null)
             return self::filter($response, $request, [
                 'error' => 'invalid_request',
                 'error_description' => 'Your request must use content type application/x-www-form-urlencoded.',
             ]);
 
-        // authz header should be the preferred method
-        $authzHeader = explode(' ', (string)$request->getHeaderLine('Authorization'));
-        if(strcasecmp($authzHeader[0], 'Basic') === 0) {
-            $authzHeader = explode(':', base64_decode($authzHeader[1] ?? ''));
-            $clientId = $authzHeader[0];
-            $clientSecret = $authzHeader[1] ?? '';
-        } elseif($authzHeader[0] !== '') {
-            return self::filter($response, $request, [
-                'error' => 'invalid_client',
-                'error_description' => 'You must either use the Basic method for Authorization or use the client_id and client_secret parameters.',
-            ], authzHeader: true);
-        } else {
-            $clientId = (string)$content->getParam('client_id');
-            $clientSecret = (string)$content->getParam('client_secret');
-        }
-
-        try {
-            $appInfo = $this->oauth2Ctx->appsCtx->apps->getAppInfo(clientId: $clientId, deleted: false);
-        } catch(RuntimeException $ex) {
-            return self::filter($response, $request, [
-                'error' => 'invalid_client',
-                'error_description' => 'No application has been registered with this client ID.',
-            ], authzHeader: $authzHeader[0] !== '');
-        }
-
-        $isAuthed = false;
-        if($clientSecret !== '') {
-            // TODO: rate limiting
-            $isAuthed = $appInfo->verifyClientSecret($clientSecret);
-            if(!$isAuthed)
-                return self::filter($response, $request, [
-                    'error' => 'invalid_client',
-                    'error_description' => 'Provided client secret is not correct for this application.',
-                ], authzHeader: $authzHeader[0] !== '');
-        }
-
         $type = (string)$content->getParam('grant_type');
 
         if($type === 'authorization_code')
             return self::filter($response, $request, $this->oauth2Ctx->redeemAuthorisationCode(
-                $appInfo,
-                $isAuthed,
+                $this->authInfo->appInfo,
                 (string)$content->getParam('code'),
                 (string)$content->getParam('code_verifier')
             ));
 
         if($type === 'refresh_token')
             return self::filter($response, $request, $this->oauth2Ctx->redeemRefreshToken(
-                $appInfo,
-                $isAuthed,
+                $this->authInfo->appInfo,
                 (string)$content->getParam('refresh_token'),
                 $content->hasParam('scope') ? (string)$content->getParam('scope') : null
             ));
 
         if($type === 'client_credentials')
             return self::filter($response, $request, $this->oauth2Ctx->redeemClientCredentials(
-                $appInfo,
-                $isAuthed,
+                $this->authInfo->appInfo,
                 $content->hasParam('scope') ? (string)$content->getParam('scope') : null
             ));
 
         if($type === 'urn:ietf:params:oauth:grant-type:device_code' || $type === 'device_code')
             return self::filter($response, $request, $this->oauth2Ctx->redeemDeviceCode(
-                $appInfo,
-                $isAuthed,
+                $this->authInfo->appInfo,
                 (string)$content->getParam('device_code')
             ));
 
@@ -362,17 +287,9 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
      */
     #[AccessControl(allowHeaders: ['Authorization'], exposeHeaders: ['WWW-Authenticate'])]
     #[ExactRoute('GET', '/oauth2/userinfo')]
+    #[Before('authz:bearer')]
     #[UrlFormat('oauth2-openid-userinfo', '/oauth2/userinfo')]
     public function getUserInfo(HttpResponseBuilder $response, HttpRequest $request): array {
-        if(!$this->authInfo->loggedInBearer) {
-            $response->statusCode = 401;
-            $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Bearer authentication must be used."');
-            return [
-                'error' => 'invalid_token',
-                'error_description' => 'Bearer authentication must be used.',
-            ];
-        }
-
         if(!$this->authInfo->hasScope('openid')) {
             $response->statusCode = 403;
             $response->setHeader('WWW-Authenticate', 'Bearer error="insufficient_scope", error_description="openid scope is required for this endpoint."');
diff --git a/src/OAuth2/OAuth2Context.php b/src/OAuth2/OAuth2Context.php
index daedfec5..9441b721 100644
--- a/src/OAuth2/OAuth2Context.php
+++ b/src/OAuth2/OAuth2Context.php
@@ -241,7 +241,7 @@ class OAuth2Context {
      *  refresh_token?: string,
      * }|array{ error: string, error_description: string }
      */
-    public function redeemAuthorisationCode(AppInfo $appInfo, bool $isAuthed, string $code, string $codeVerifier): array {
+    public function redeemAuthorisationCode(AppInfo $appInfo, string $code, string $codeVerifier): array {
         try {
             $authsInfo = $this->authorisations->getAuthorisationInfo(
                 appInfo: $appInfo,
@@ -294,7 +294,7 @@ class OAuth2Context {
      *  refresh_token?: string,
      * }|array{ error: string, error_description: string }
      */
-    public function redeemRefreshToken(AppInfo $appInfo, bool $isAuthed, string $refreshToken, ?string $scope = null): array {
+    public function redeemRefreshToken(AppInfo $appInfo, string $refreshToken, ?string $scope = null): array {
         try {
             $refreshInfo = $this->tokens->getRefreshInfo($refreshToken, OAuth2RefreshInfoGetField::Token);
         } catch(RuntimeException $ex) {
@@ -355,17 +355,12 @@ class OAuth2Context {
      *  refresh_token?: string,
      * }|array{ error: string, error_description: string }
      */
-    public function redeemClientCredentials(AppInfo $appInfo, bool $isAuthed, ?string $scope = null): array {
+    public function redeemClientCredentials(AppInfo $appInfo, ?string $scope = null): array {
         if(!$appInfo->confidential)
             return [
                 'error' => 'unauthorized_client',
                 'error_description' => 'This application is not allowed to use this grant type.',
             ];
-        if(!$isAuthed)
-            return [
-                'error' => 'invalid_client',
-                'error_description' => 'Application must authenticate with client secret in order to use this grant type.',
-            ];
 
         $requestedScope = $scope;
         $scope = $this->checkAndBuildScopeString($appInfo, $requestedScope, true);
@@ -392,7 +387,7 @@ class OAuth2Context {
      *  refresh_token?: string,
      * }|array{ error: string, error_description: string }
      */
-    public function redeemDeviceCode(AppInfo $appInfo, bool $isAuthed, string $deviceCode): array {
+    public function redeemDeviceCode(AppInfo $appInfo, string $deviceCode): array {
         try {
             $deviceInfo = $this->devices->getDeviceInfo(
                 appInfo: $appInfo,
diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php
index 5033b24c..6e944bc9 100644
--- a/src/OAuth2/OAuth2WebRoutes.php
+++ b/src/OAuth2/OAuth2WebRoutes.php
@@ -10,7 +10,7 @@ use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
-use Misuzu\{CSRF,Template};
+use Misuzu\{CsrfContext,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Users\UsersContext;
 
@@ -24,6 +24,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
         private OAuth2Context $oauth2Ctx,
         private UsersContext $usersCtx,
         private UrlRegistry $urls,
+        private CsrfContext $csrfCtx,
         private AuthInfo $authInfo,
     ) {}
 
@@ -48,10 +49,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
     public function postAuthorise(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
         // TODO: RATE LIMITING
 
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+        if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
             return ['error' => 'csrf'];
 
-        $response->setHeader('X-CSRF-Token', CSRF::token());
+        $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
 
         if(!$this->authInfo->loggedIn)
             return ['error' => 'auth'];
@@ -174,10 +175,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
     public function getResolveAuthorise(HttpResponseBuilder $response, HttpRequest $request): array {
         // TODO: RATE LIMITING
 
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+        if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
             return ['error' => 'csrf'];
 
-        $response->setHeader('X-CSRF-Token', CSRF::token());
+        $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
 
         if(!$this->authInfo->loggedIn)
             return ['error' => 'auth'];
@@ -236,7 +237,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
                 'name' => $this->authInfo->realUserInfo->name,
                 'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
                 'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
-                'revert_uri' => $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
+                'revert_uri' => $this->urls->format('auth-revert', ['csrf' => $this->csrfCtx->createToken()]),
                 'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
             ];
 
@@ -263,10 +264,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
     public function postVerify(HttpResponseBuilder $response, HttpRequest $request, FormContent $content): array {
         // TODO: RATE LIMITING
 
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+        if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
             return ['error' => 'csrf'];
 
-        $response->setHeader('X-CSRF-Token', CSRF::token());
+        $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
 
         if(!$this->authInfo->loggedIn)
             return ['error' => 'auth'];
@@ -360,10 +361,10 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
     public function getResolveVerify(HttpResponseBuilder $response, HttpRequest $request) {
         // TODO: RATE LIMITING
 
-        if(!CSRF::validate($request->getHeaderLine('X-CSRF-token')))
+        if(!$this->csrfCtx->verifyToken($request->getHeaderLine('X-CSRF-token')))
             return ['error' => 'csrf'];
 
-        $response->setHeader('X-CSRF-Token', CSRF::token());
+        $response->setHeader('X-CSRF-Token', $this->csrfCtx->createToken());
 
         if(!$this->authInfo->loggedIn)
             return ['error' => 'auth'];
@@ -424,7 +425,7 @@ final class OAuth2WebRoutes implements RouteHandler, UrlSource {
                 'name' => $this->authInfo->realUserInfo->name,
                 'colour' => (string)$this->usersCtx->getUserColour($this->authInfo->realUserInfo),
                 'profile_uri' => $this->urls->format('user-profile', ['user' => $this->authInfo->realUserInfo->id]),
-                'revert_uri' => $this->urls->format('auth-revert', ['csrf' => CSRF::token()]),
+                'revert_uri' => $this->urls->format('auth-revert', ['csrf' => $this->csrfCtx->createToken()]),
                 'avatar_uri' => $this->urls->format('user-avatar', ['user' => $this->authInfo->realUserInfo->id, 'res' => 60]),
             ];
 
diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php
index 128f13ce..7d6de6df 100644
--- a/src/SharpChat/SharpChatRoutes.php
+++ b/src/SharpChat/SharpChatRoutes.php
@@ -64,6 +64,7 @@ final class SharpChatRoutes implements RouteHandler {
     }
 
     #[ExactRoute('GET', '/_sockchat/login')]
+    #[Before('authz:cookie')]
     public function getLogin(HttpResponseBuilder $response, HttpRequest $request): void {
         if(!$this->authInfo->loggedIn) {
             $response->redirect($this->urls->format('auth-login'));
@@ -87,6 +88,7 @@ final class SharpChatRoutes implements RouteHandler {
     /** @return array{ok: false, err: string}|array{ok: true, usr: int, tkn: string} */
     #[AccessControl(credentials: true)]
     #[ExactRoute('GET', '/_sockchat/token')]
+    #[Before('authz:cookie')]
     public function getToken(HttpRequest $request): array {
         $tokenInfo = $this->authInfo->tokenInfo;
         if(!$tokenInfo->hasSessionToken)
diff --git a/src/Storage/Uploads/UploadsLegacyRoutes.php b/src/Storage/Uploads/UploadsLegacyRoutes.php
index 62ddf963..952daedc 100644
--- a/src/Storage/Uploads/UploadsLegacyRoutes.php
+++ b/src/Storage/Uploads/UploadsLegacyRoutes.php
@@ -133,8 +133,11 @@ class UploadsLegacyRoutes implements RouteHandler, UrlSource {
 
     /** @return array<string, ?scalar> */
     #[AccessControl(credentials: true, allowHeaders: ['Authorization'], exposeHeaders: ['X-EEPROM-Max-Size'])]
-    #[Before('input:multipart')]
     #[ExactRoute('POST', '/uploads')]
+    #[Before('authz:cookie', required: false)]
+    #[Before('authz:bearer', required: false)]
+    #[Before('authz:misuzu', required: false)]
+    #[Before('input:multipart')]
     public function postUpload(HttpResponseBuilder $response, HttpRequest $request, MultipartFormContent $content) {
         if(!$this->authInfo->loggedIn) {
             $response->statusCode = 401;
@@ -302,6 +305,9 @@ class UploadsLegacyRoutes implements RouteHandler, UrlSource {
     /** @return int|array{error: string, english: string} */
     #[AccessControl(credentials: true, allowHeaders: ['Authorization'])]
     #[PatternRoute('DELETE', '/uploads/([A-Za-z0-9]+|[A-Za-z0-9\-_]{32})(?:-([a-z0-9]+))?(?:\.([A-Za-z0-9\-_]+))?')]
+    #[Before('authz:cookie', required: false)]
+    #[Before('authz:bearer', required: false)]
+    #[Before('authz:misuzu', required: false)]
     public function deleteUpload(HttpResponseBuilder $response, HttpRequest $request, string $uploadId) {
         if(!$this->authInfo->loggedIn) {
             $response->statusCode = 401;
diff --git a/src/TemplatingExtension.php b/src/TemplatingExtension.php
index c320fcac..ef81f226 100644
--- a/src/TemplatingExtension.php
+++ b/src/TemplatingExtension.php
@@ -26,8 +26,6 @@ final class TemplatingExtension extends AbstractExtension {
         return [
             new TwigFunction('asset', $this->assetInfo->getAssetUrl(...)),
             new TwigFunction('url', $this->ctx->urls->format(...)),
-            new TwigFunction('csrf_available', CSRF::available(...)),
-            new TwigFunction('csrf_token', CSRF::token(...)),
             new TwigFunction('git_commit_hash', GitInfo::hash(...)),
             new TwigFunction('git_tag', GitInfo::tag(...)),
             new TwigFunction('git_branch', GitInfo::branch(...)),
@@ -190,7 +188,7 @@ final class TemplatingExtension extends AbstractExtension {
 
             $menu[] = [
                 'title' => 'Log out',
-                'url' => $this->ctx->urls->format('auth-logout', ['csrf' => CSRF::token()]),
+                'url' => $this->ctx->urls->format('auth-logout', ['csrf' => $this->ctx->csrfCtx->createToken()]),
                 'icon' => 'fas fa-sign-out-alt fa-fw',
             ];
         } else {
diff --git a/src/Users/Assets/AssetsRoutes.php b/src/Users/Assets/AssetsRoutes.php
index b2dc7ff3..393b5c0b 100644
--- a/src/Users/Assets/AssetsRoutes.php
+++ b/src/Users/Assets/AssetsRoutes.php
@@ -5,6 +5,7 @@ use InvalidArgumentException;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Misuzu,Perm};
@@ -33,6 +34,7 @@ class AssetsRoutes implements RouteHandler, UrlSource {
     /** @return void */
     #[ExactRoute('GET', '/assets/avatar')]
     #[PatternRoute('GET', '/assets/avatar/([0-9]+)(?:\.[a-z]+)?')]
+    #[Before('authz:cookie')]
     #[UrlFormat('user-avatar', '/assets/avatar/<user>', ['res' => '<res>'])]
     public function getAvatar(HttpResponseBuilder $response, HttpRequest $request, string $userId = '') {
         $assetInfo = new StaticUserImageAsset(Misuzu::PATH_PUBLIC . '/images/no-avatar.png', Misuzu::PATH_PUBLIC);
@@ -56,6 +58,7 @@ class AssetsRoutes implements RouteHandler, UrlSource {
     /** @return string|void */
     #[ExactRoute('GET', '/assets/profile-background')]
     #[PatternRoute('GET', '/assets/profile-background/([0-9]+)(?:\.[a-z]+)?')]
+    #[Before('authz:cookie')]
     #[UrlFormat('user-background', '/assets/profile-background/<user>')]
     public function getProfileBackground(HttpResponseBuilder $response, HttpRequest $request, string $userId = '') {
         try {
@@ -80,6 +83,7 @@ class AssetsRoutes implements RouteHandler, UrlSource {
 
     /** @return string|void */
     #[ExactRoute('GET', '/user-assets.php')]
+    #[Before('authz:cookie')]
     public function getUserAssets(HttpResponseBuilder $response, HttpRequest $request) {
         $userId = (string)$request->getFilteredParam('u', FILTER_SANITIZE_NUMBER_INT);
         $mode = (string)$request->getParam('m');
diff --git a/src/Users/UsersApiRoutes.php b/src/Users/UsersApiRoutes.php
index c219e48c..70ba2fa0 100644
--- a/src/Users/UsersApiRoutes.php
+++ b/src/Users/UsersApiRoutes.php
@@ -7,6 +7,7 @@ use Index\Colour\{Colour,ColourRgb};
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
+use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\ExactRoute;
 use Index\Urls\UrlRegistry;
 use Misuzu\{FieldTransformer,SiteInfo};
@@ -126,6 +127,9 @@ final class UsersApiRoutes implements RouteHandler {
     /** @return int|mixed[] */
     #[AccessControl]
     #[ExactRoute('GET', '/api/v1/me')]
+    #[Before('authz:cookie', required: false)]
+    #[Before('authz:bearer', required: false)]
+    #[Before('authz:misuzu', required: false)]
     public function getMe(HttpResponseBuilder $response, HttpRequest $request): int|array {
         $response->setHeader('Cache-Control', 'no-store');