diff --git a/VERSION b/VERSION
index ab374766..b584ac26 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-20250324.1
+20250324.2
diff --git a/src/Auth/AuthApiRoutes.php b/src/Auth/AuthApiRoutes.php
new file mode 100644
index 00000000..b5d9a11a
--- /dev/null
+++ b/src/Auth/AuthApiRoutes.php
@@ -0,0 +1,158 @@
+<?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\{HttpMiddleware,RouteHandler,RouteHandlerCommon};
+
+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|int */
+    #[HttpMiddleware('/api/v1')]
+    #[HttpMiddleware('/oauth2')]
+    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 f97f240b..3d0602fa 100644
--- a/src/Auth/AuthInfo.php
+++ b/src/Auth/AuthInfo.php
@@ -1,10 +1,11 @@
 <?php
 namespace Misuzu\Auth;
 
+use Misuzu\Apps\AppInfo;
 use Misuzu\Auth\SessionInfo;
 use Misuzu\Forum\ForumCategoryInfo;
-use Misuzu\Perms\IPermissionResult;
-use Misuzu\Perms\{PermissionsData,PermissionResult};
+use Misuzu\OAuth2\OAuth2AccessInfo;
+use Misuzu\Perms\{IPermissionResult,PermissionsData,PermissionResult};
 use Misuzu\Users\UserInfo;
 
 class AuthInfo {
@@ -12,6 +13,8 @@ class AuthInfo {
     public private(set) ?UserInfo $userInfo;
     public private(set) ?SessionInfo $sessionInfo;
     public private(set) ?UserInfo $realUserInfo;
+    public private(set) ?AppInfo $appInfo;
+    public private(set) ?OAuth2AccessInfo $accessInfo;
 
     /** @var array<string, PermissionResult> */
     public private(set) array $perms;
@@ -23,26 +26,42 @@ class AuthInfo {
     }
 
     public function setInfo(
-        AuthTokenInfo $tokenInfo,
+        ?AuthTokenInfo $tokenInfo = null,
         ?UserInfo $userInfo = null,
         ?SessionInfo $sessionInfo = null,
-        ?UserInfo $realUserInfo = null
+        ?UserInfo $realUserInfo = null,
+        ?AppInfo $appInfo = null,
+        ?OAuth2AccessInfo $accessInfo = null,
     ): void {
-        $this->tokenInfo = $tokenInfo;
+        $this->tokenInfo = $tokenInfo ?? AuthTokenInfo::empty();
         $this->userInfo = $userInfo;
         $this->sessionInfo = $sessionInfo;
         $this->realUserInfo = $realUserInfo;
+        $this->appInfo = $appInfo;
+        $this->accessInfo = $accessInfo;
         $this->perms = [];
     }
 
     public function removeInfo(): void {
-        $this->setInfo(AuthTokenInfo::empty());
+        $this->setInfo();
     }
 
     public bool $loggedIn {
         get => $this->userInfo !== null;
     }
 
+    public bool $loggedInBasic {
+        get => $this->appInfo !== null;
+    }
+
+    public bool $loggedInBearer {
+        get => $this->accessInfo !== null && $this->userInfo !== null;
+    }
+
+    public bool $loggedInBearerClient {
+        get => $this->accessInfo !== null && $this->userInfo === null;
+    }
+
     public ?string $userId {
         get => $this->userInfo?->id;
     }
@@ -59,6 +78,38 @@ class AuthInfo {
         get => $this->realUserInfo?->id;
     }
 
+    public ?string $accessId {
+        get => $this->accessInfo?->id;
+    }
+
+    public ?string $appId {
+        get => $this->appInfo?->id;
+    }
+
+    public ?array $scopes {
+        get {
+            if($this->appInfo !== null)
+                return ['oauth'];
+            if($this->accessInfo !== null)
+                return $this->accessInfo->scopes;
+            if($this->sessionInfo !== null)
+                return null;
+
+            return [];
+        }
+    }
+
+    public function hasScope(string $scope): bool {
+        if($this->appInfo !== null)
+            return $scope === 'oauth';
+        if($this->accessInfo !== null)
+            return in_array($scope, $this->accessInfo->scopes);
+        if($this->sessionInfo !== null)
+            return true;
+
+        return false;
+    }
+
     public function getPerms(
         string $category,
         ForumCategoryInfo|string|null $forumCategoryInfo = null
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index cb6a3d5f..3d5e6a79 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -176,7 +176,12 @@ class MisuzuContext {
         ));
         $routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class));
 
+        $routingCtx->register($this->deps->constructLazy(
+            Auth\AuthApiRoutes::class,
+            impersonateConfig: $this->config->scopeTo('impersonate')
+        ));
         $routingCtx->register($this->deps->constructLazy(Emoticons\EmotesApiRoutes::class));
+        $routingCtx->register($this->deps->constructLazy(Users\UsersApiRoutes::class));
 
         $routingCtx->register($this->deps->constructLazy(Home\HomeRoutes::class));
         $routingCtx->register($this->deps->constructLazy(Users\Assets\AssetsRoutes::class));
diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php
index cbe9a442..b47628fb 100644
--- a/src/OAuth2/OAuth2ApiRoutes.php
+++ b/src/OAuth2/OAuth2ApiRoutes.php
@@ -9,6 +9,7 @@ use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler,RouteHandlerCo
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\SiteInfo;
 use Misuzu\Apps\AppsContext;
+use Misuzu\Auth\AuthInfo;
 use Misuzu\Profile\ProfileContext;
 use Misuzu\Users\UsersContext;
 
@@ -22,6 +23,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
         private ProfileContext $profileCtx,
         private UrlRegistry $urls,
         private SiteInfo $siteInfo,
+        private AuthInfo $authInfo,
     ) {}
 
     /**
@@ -181,9 +183,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
 
     /** @return int|array{keys?: array<string, string>} */
     #[HttpOptions('/oauth2/jwks.json')]
-    #[HttpOptions('/openid/jwks.json')]
     #[HttpGet('/oauth2/jwks.json')]
-    #[HttpGet('/openid/jwks.json')]
     #[UrlFormat('oauth2-jwks', '/oauth2/jwks.json')]
     public function getJwks(HttpResponseBuilder $response, HttpRequest $request): int|array {
         $response->setHeader('Access-Control-Allow-Origin', '*');
@@ -395,9 +395,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
      * }|array{ error: string, error_description: string }
      */
     #[HttpOptions('/oauth2/userinfo')]
-    #[HttpOptions('/openid/userinfo')]
     #[HttpGet('/oauth2/userinfo')]
-    #[HttpGet('/openid/userinfo')]
     #[UrlFormat('oauth2-openid-userinfo', '/oauth2/userinfo')]
     public function getUserInfo(HttpResponseBuilder $response, HttpRequest $request): int|array {
         $response->setHeader('Access-Control-Allow-Origin', '*');
@@ -405,8 +403,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
         if($request->method === 'OPTIONS')
             return 204;
 
-        $header = explode(' ', (string)$request->getHeaderLine('Authorization'), 2);
-        if(strcasecmp($header[0], 'bearer') !== 0 || empty($header[1])) {
+        if(!$this->authInfo->loggedInBearer) {
             $response->statusCode = 401;
             $response->setHeader('WWW-Authenticate', 'Bearer error="invalid_token", error_description="Bearer authentication must be used."');
             return [
@@ -415,22 +412,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
             ];
         }
 
-        try {
-            $tokenInfo = $this->oauth2Ctx->tokens->getAccessInfo(trim($header[1]), OAuth2AccessInfoGetField::Token);
-        } catch(RuntimeException $ex) {
-            $tokenInfo = null;
-        }
-        if($tokenInfo === null || $tokenInfo->userId === null || $tokenInfo->expired) {
-            $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.',
-            ];
-        }
-
-        $scopes = $tokenInfo->scopes;
-        if(!in_array('openid', $scopes)) {
+        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."');
             return [
@@ -439,23 +421,9 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
             ];
         }
 
-        try {
-            $userInfo = $this->usersCtx->getUserInfo($tokenInfo->userId);
-        } catch(RuntimeException $ex) {
-            $userInfo = null;
-        }
-        if($userInfo === null || $userInfo->deleted) {
-            $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.',
-            ];
-        }
-
         $result = ['sub' => $userInfo->id];
 
-        if(in_array('profile', $scopes)) {
+        if($this->authInfo->hasScope('profile')) {
             $result['name'] = $userInfo->name;
             $result['nickname'] = $userInfo->name;
             $result['preferred_username'] = $userInfo->name;
@@ -502,7 +470,7 @@ final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
             }
         }
 
-        if(in_array('email', $scopes)) {
+        if($this->authInfo->hasScope('email')) {
             $result['email'] = $userInfo->emailAddress;
             $result['email_verified'] = true;
         }
diff --git a/src/Routing/RoutingErrorHandler.php b/src/Routing/RoutingErrorHandler.php
index 167d5c22..ecd45513 100644
--- a/src/Routing/RoutingErrorHandler.php
+++ b/src/Routing/RoutingErrorHandler.php
@@ -12,6 +12,15 @@ class RoutingErrorHandler extends HtmlHttpErrorHandler {
             return;
         }
 
+        if(str_starts_with($request->path, '/api')) {
+            $response->setTypeJson();
+            $response->content = json_encode([
+                'error' => sprintf('http:%s', $code),
+                'message' => $message,
+            ], JSON_UNESCAPED_SLASHES);
+            return;
+        }
+
         $path = sprintf('%s/error-%03d.html', Misuzu::PATH_PUBLIC, $code);
         if(is_file($path)) {
             $response->setTypeHTML();
diff --git a/src/Users/UsersApiRoutes.php b/src/Users/UsersApiRoutes.php
new file mode 100644
index 00000000..ab2e2cc5
--- /dev/null
+++ b/src/Users/UsersApiRoutes.php
@@ -0,0 +1,109 @@
+<?php
+namespace Misuzu\Users;
+
+use RuntimeException;
+use Misuzu\SiteInfo;
+use Misuzu\Auth\AuthInfo;
+use Misuzu\Users\Assets\UserAvatarAsset;
+use Index\XArray;
+use Index\Colour\{Colour,ColourRgb};
+use Index\Http\{HttpRequest,HttpResponseBuilder};
+use Index\Http\Routing\{HttpGet,HttpOptions,RouteHandler,RouteHandlerCommon};
+use Index\Urls\UrlRegistry;
+
+final class UsersApiRoutes implements RouteHandler {
+    use RouteHandlerCommon;
+
+    public function __construct(
+        private SiteInfo $siteInfo,
+        private UrlRegistry $urls,
+        private UsersContext $usersCtx,
+        private AuthInfo $authInfo,
+    ) {}
+
+    /** @return int|mixed[] */
+    #[HttpOptions('/api/v1/me')]
+    #[HttpGet('/api/v1/me')]
+    public function getEmotes(HttpResponseBuilder $response, HttpRequest $request): int|array {
+        $response->setHeader('Access-Control-Allow-Origin', '*');
+        $response->setHeader('Access-Control-Allow-Methods', 'GET');
+        $response->addHeader('Access-Control-Allow-Headers', 'Cache-Control');
+        $response->setHeader('Cache-Control', 'public, max-age=3600');
+
+        if($request->method === 'OPTIONS')
+            return 204;
+
+        $openid = $this->authInfo->hasScope('openid');
+        if(!$openid
+            && !$this->authInfo->hasScope('identify')
+            && !$this->authInfo->hasScope('beans'))
+            return 403;
+
+        $includeProfile = !$openid || $this->authInfo->hasScope('profile');
+        $includeEMail = $openid
+            ? $this->authInfo->hasScope('email')
+            : $this->authInfo->hasScope('identify:email');
+
+        try {
+            $userInfo = $this->usersCtx->getUserInfo($this->authInfo->userId, UsersData::GET_USER_ID);
+        } catch(RuntimeException) {
+            return 404;
+        }
+
+        // TODO: there should be some kinda privacy controls for users
+
+        $output = ['id' => $userInfo->id];
+
+        if($includeProfile) {
+            $output['name'] = $userInfo->name;
+
+            $colour = $this->usersCtx->getUserColour($userInfo);
+            if($colour->inherits) {
+                $colourRaw = null;
+                $colourCSS = (string)$colour;
+            } else {
+                $colourRaw = Colour::toRawRgb($colour);
+                $colourCSS = (string)ColourRgb::convert($colour);
+            }
+
+            $output['colour_raw'] = $colourRaw;
+            $output['colour_css'] = $colourCSS;
+            $output['country_code'] = $userInfo->countryCode;
+
+            if($this->usersCtx->hasActiveBan($userInfo)) {
+                $output['rank'] = 0;
+                $output['roles'] = ['x-banned'];
+            } else {
+                $roles = XArray::select(
+                    $this->usersCtx->roles->getRoles(userInfo: $userInfo, hasString: true, orderByRank: true),
+                    fn($roleInfo) => $roleInfo->string,
+                );
+
+                $output['rank'] = $this->usersCtx->getUserRank($userInfo);
+                if(!empty($roles))
+                    $output['roles'] = $roles;
+                if($userInfo->super)
+                    $output['is_super'] = true;
+            }
+
+            if(!empty($userInfo->title))
+                $output['title'] = $userInfo->title;
+
+            $output['created_at'] = $userInfo->createdAt->toIso8601ZuluString();
+            if($userInfo->lastActiveTime !== null)
+                $output['last_active_at'] = $userInfo->lastActiveAt->toIso8601ZuluString();
+
+            $baseUrl = $this->siteInfo->url;
+            $output['profile_url'] = $baseUrl . $this->urls->format('user-profile', ['user' => $userInfo->id]);
+            $output['avatar_url'] = $baseUrl . $this->urls->format('user-avatar', ['user' => $userInfo->id]);
+
+            if($userInfo->deleted)
+                $output['is_deleted'] = true;
+        }
+
+        if($includeEMail)
+            $output['email'] = $userInfo->emailAddress;
+
+        return $output;
+    }
+}