diff --git a/app/api/v2/__init__.py b/app/api/v2/__init__.py
index 13faca62b..418217d7f 100644
--- a/app/api/v2/__init__.py
+++ b/app/api/v2/__init__.py
@@ -1,9 +1,43 @@
 # isort: dont-add-imports
 
+from typing import Any
+
 from fastapi import APIRouter
+from fastapi import Depends
+from fastapi import HTTPException
+from fastapi import status
+
+from app.api.v2.common.oauth import OAuth2Scheme
+from app.repositories import access_tokens as access_tokens_repo
+
+oauth2_scheme = OAuth2Scheme(
+    authorizationUrl="/v2/oauth/authorize",
+    tokenUrl="/v2/oauth/token",
+    refreshUrl="/v2/oauth/refresh",
+    scheme_name="OAuth2 for third-party clients.",
+    scopes={
+        "public": "Access endpoints with public data.",
+        "identify": "Access endpoints with user's data.",
+        "admin": "Access admin endpoints.",
+    },
+)
+
+
+async def get_current_client(token: str = Depends(oauth2_scheme)) -> dict[str, Any]:
+    """Look up the token in the Redis-based token store"""
+    access_token = await access_tokens_repo.fetch_one(token)
+    if not access_token:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="Not authenticated",
+            headers={"WWW-Authenticate": "Bearer"},
+        )
+    return access_token
+
 
 from . import clans
 from . import maps
+from . import oauth
 from . import players
 from . import scores
 
@@ -13,3 +47,4 @@
 apiv2_router.include_router(maps.router)
 apiv2_router.include_router(players.router)
 apiv2_router.include_router(scores.router)
+apiv2_router.include_router(oauth.router)
diff --git a/app/api/v2/common/json.py b/app/api/v2/common/json.py
index e56799189..f5c1ed8c7 100644
--- a/app/api/v2/common/json.py
+++ b/app/api/v2/common/json.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from typing import Any
+from uuid import UUID
 
 import orjson
 from fastapi.responses import JSONResponse
@@ -14,6 +15,8 @@ def _default_processor(data: Any) -> Any:
         return {k: _default_processor(v) for k, v in data.items()}
     elif isinstance(data, list):
         return [_default_processor(v) for v in data]
+    elif isinstance(data, UUID):
+        return str(data)
     else:
         return data
 
@@ -22,8 +25,12 @@ def dumps(data: Any) -> bytes:
     return orjson.dumps(data, default=_default_processor)
 
 
+def loads(data: str) -> Any:
+    return orjson.loads(data)
+
+
 class ORJSONResponse(JSONResponse):
-    media_type = "application/json"
+    media_type = "application/json;charset=UTF-8"
 
     def render(self, content: Any) -> bytes:
         return dumps(content)
diff --git a/app/api/v2/common/oauth.py b/app/api/v2/common/oauth.py
new file mode 100644
index 000000000..0e4455442
--- /dev/null
+++ b/app/api/v2/common/oauth.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+import base64
+
+from fastapi import Request
+from fastapi import status
+from fastapi.exceptions import HTTPException
+from fastapi.openapi.models import OAuthFlowAuthorizationCode
+from fastapi.openapi.models import OAuthFlowClientCredentials
+from fastapi.openapi.models import OAuthFlows
+from fastapi.security import OAuth2
+from fastapi.security.utils import get_authorization_scheme_param
+
+
+class OAuth2Scheme(OAuth2):
+    def __init__(
+        self,
+        authorizationUrl: str,
+        tokenUrl: str,
+        refreshUrl: str | None = None,
+        scheme_name: str | None = None,
+        scopes: dict[str, str] | None = None,
+        description: str | None = None,
+        auto_error: bool = True,
+    ):
+        if not scopes:
+            scopes = {}
+        flows = OAuthFlows(
+            authorizationCode=OAuthFlowAuthorizationCode(
+                authorizationUrl=authorizationUrl,
+                tokenUrl=tokenUrl,
+                scopes=scopes,
+                refreshUrl=refreshUrl,
+            ),
+            clientCredentials=OAuthFlowClientCredentials(
+                tokenUrl=tokenUrl,
+                scopes=scopes,
+                refreshUrl=refreshUrl,
+            ),
+        )
+        super().__init__(
+            flows=flows,
+            scheme_name=scheme_name,
+            description=description,
+            auto_error=auto_error,
+        )
+
+    async def __call__(self, request: Request) -> str | None:
+        authorization = request.headers.get("Authorization")
+        scheme, param = get_authorization_scheme_param(authorization)
+        if not authorization or scheme.lower() != "bearer":
+            if self.auto_error:
+                raise HTTPException(
+                    status_code=status.HTTP_401_UNAUTHORIZED,
+                    detail="Not authenticated",
+                    headers={"WWW-Authenticate": "Bearer"},
+                )
+            else:
+                return None
+        return param
+
+
+# https://developer.zendesk.com/api-reference/sales-crm/authentication/requests/#client-authentication
+def get_credentials_from_basic_auth(
+    request: Request,
+) -> dict[str, str | int] | None:
+    authorization = request.headers.get("Authorization")
+    scheme, param = get_authorization_scheme_param(authorization)
+    if not authorization or scheme.lower() != "basic":
+        return None
+
+    data = base64.b64decode(param).decode("utf-8")
+    if ":" not in data:
+        return None
+
+    split = data.split(":")
+    if len(split) != 2:
+        return None
+    if not split[0].isdecimal():
+        return None
+
+    return {
+        "client_id": int(split[0]),
+        "client_secret": split[1],
+    }
diff --git a/app/api/v2/models/oauth.py b/app/api/v2/models/oauth.py
new file mode 100644
index 000000000..381525c85
--- /dev/null
+++ b/app/api/v2/models/oauth.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from datetime import datetime
+from enum import StrEnum
+from typing import Literal
+
+from . import BaseModel
+
+# input models
+
+
+# output models
+
+
+class GrantType(StrEnum):
+    AUTHORIZATION_CODE = "authorization_code"
+    CLIENT_CREDENTIALS = "client_credentials"
+
+    # TODO: Add support for other grant types
+
+
+class Token(BaseModel):
+    access_token: str
+    refresh_token: str | None
+    token_type: Literal["Bearer"]
+    expires_in: int
+    expires_at: datetime
+    scope: str
diff --git a/app/api/v2/oauth.py b/app/api/v2/oauth.py
new file mode 100644
index 000000000..3e5821752
--- /dev/null
+++ b/app/api/v2/oauth.py
@@ -0,0 +1,213 @@
+""" bancho.py's v2 apis for interacting with clans """
+
+from __future__ import annotations
+
+import uuid
+from typing import Any
+
+from fastapi import APIRouter
+from fastapi import Depends
+from fastapi import Response
+from fastapi import status
+from fastapi.param_functions import Form
+from fastapi.param_functions import Query
+
+from app.api.v2 import get_current_client
+from app.api.v2.common.oauth import get_credentials_from_basic_auth
+from app.api.v2.models.oauth import GrantType
+from app.api.v2.models.oauth import Token
+from app.repositories import access_tokens as access_tokens_repo
+from app.repositories import authorization_codes as authorization_codes_repo
+from app.repositories import ouath_clients as clients_repo
+from app.repositories import refresh_tokens as refresh_tokens_repo
+
+router = APIRouter()
+
+
+def oauth_failure_response(reason: str) -> dict[str, Any]:
+    return {"error": reason}
+
+
+@router.get("/oauth/authorize", status_code=status.HTTP_302_FOUND)
+async def authorize(
+    client_id: int = Query(),
+    redirect_uri: str = Query(),
+    response_type: str = Query(regex="code"),
+    player_id: int = Query(),
+    scope: str = Query(default="", regex=r"\b\w+\b(?:,\s*\b\w+\b)*"),
+    state: str | None = Query(default=None),
+):
+    """Authorize a client to access the API on behalf of a user."""
+    # NOTE: We should have to implement the frontend part to request the user to authorize the client
+    # and then redirect to the redirect_uri with the code.
+    # For now, we just return the code and the state if it's provided.
+    client = await clients_repo.fetch_one(client_id)
+    if client is None:
+        return oauth_failure_response("invalid_client")
+
+    if client["redirect_uri"] != redirect_uri:
+        return oauth_failure_response("invalid_client")
+
+    if response_type != "code":
+        return oauth_failure_response("unsupported_response_type")
+
+    code = uuid.uuid4()
+    await authorization_codes_repo.create(code, client_id, scope, player_id)
+
+    if state is None:
+        redirect_uri = f"{redirect_uri}?code={code}"
+    else:
+        redirect_uri = f"{redirect_uri}?code={code}&state={state}"
+
+    # return RedirectResponse(redirect_uri, status_code=status.HTTP_302_FOUND)
+    return redirect_uri
+
+
+@router.post("/oauth/token", status_code=status.HTTP_200_OK)
+async def token(
+    response: Response,
+    grant_type: GrantType = Form(),
+    client_id: int | None = Form(default=None),
+    client_secret: str | None = Form(default=None),
+    auth_credentials: dict[str, Any] | None = Depends(
+        get_credentials_from_basic_auth,
+    ),
+    code: str | None = Form(default=None),
+    scope: str = Form(default="", regex=r"\b\w+\b(?:,\s*\b\w+\b)*"),
+):
+    """Get an access token for the API."""
+    # https://www.rfc-editor.org/rfc/rfc6749#section-5.1
+    response.headers["Content-Type"] = "application/json; charset=utf-8"
+    response.headers["Cache-Control"] = "no-store, private"
+    response.headers["Pragma"] = "no-cache"
+
+    if (client_id is None or client_secret is None) and auth_credentials is None:
+        return oauth_failure_response("invalid_request")
+
+    if client_id is None and client_secret is None:
+        if auth_credentials is None:
+            return oauth_failure_response("invalid_request")
+        else:
+            client_id = auth_credentials["client_id"]
+            client_secret = auth_credentials["client_secret"]
+
+    client = await clients_repo.fetch_one(client_id)
+    if client is None:
+        return oauth_failure_response("invalid_client")
+
+    if client["secret"] != client_secret:
+        return oauth_failure_response("invalid_client")
+
+    if grant_type is GrantType.AUTHORIZATION_CODE:
+        if code is None:
+            return oauth_failure_response("invalid_request")
+
+        authorization_code = await authorization_codes_repo.fetch_one(code)
+        if not authorization_code:
+            return oauth_failure_response("invalid_grant")
+
+        if client_id is None or authorization_code["client_id"] != client_id:
+            return oauth_failure_response("invalid_client")
+
+        if authorization_code["scopes"] != scope:
+            return oauth_failure_response("invalid_scope")
+        await authorization_codes_repo.delete(code)
+
+        refresh_token = uuid.uuid4()
+        raw_access_token = uuid.uuid4()
+
+        access_token = await access_tokens_repo.create(
+            raw_access_token,
+            client_id,
+            grant_type,
+            scope,
+            refresh_token,
+            authorization_code["player_id"],
+        )
+        await refresh_tokens_repo.create(
+            refresh_token,
+            raw_access_token,
+            client_id,
+            scope,
+        )
+
+        return Token(
+            access_token=str(raw_access_token),
+            refresh_token=str(refresh_token),
+            token_type="Bearer",
+            expires_in=3600,
+            expires_at=access_token["expires_at"],
+            scope=scope,
+        )
+    elif grant_type is GrantType.CLIENT_CREDENTIALS:
+        if client_id is None:
+            return oauth_failure_response("invalid_client")
+
+        client = await clients_repo.fetch_one(client_id)
+        if client is None:
+            return oauth_failure_response("invalid_client")
+
+        if client["secret"] != client_secret:
+            return oauth_failure_response("invalid_client")
+
+        raw_access_token = uuid.uuid4()
+        access_token = await access_tokens_repo.create(
+            raw_access_token,
+            client_id,
+            grant_type,
+            scope,
+            expires_in=86400,
+        )
+
+        return Token(
+            access_token=str(raw_access_token),
+            refresh_token=None,
+            token_type="Bearer",
+            expires_in=86400,
+            expires_at=access_token["expires_at"],
+            scope=scope,
+        )
+    else:
+        return oauth_failure_response("unsupported_grant_type")
+
+
+@router.post("/oauth/refresh", status_code=status.HTTP_200_OK)
+async def refresh(
+    response: Response,
+    client: dict[str, Any] = Depends(get_current_client),
+    grant_type: str = Form(),
+    refresh_token: str = Form(),
+):
+    """Refresh an access token."""
+    # https://www.rfc-editor.org/rfc/rfc6749#section-5.1
+    response.headers["Content-Type"] = "application/json; charset=utf-8"
+    response.headers["Cache-Control"] = "no-store, private"
+    response.headers["Pragma"] = "no-cache"
+
+    if grant_type != "refresh_token":
+        return oauth_failure_response("unsupported_grant_type")
+
+    if client["grant_type"] != "authorization_code":
+        return oauth_failure_response("invalid_grant")
+
+    if client["refresh_token"] != refresh_token:
+        return oauth_failure_response("invalid_grant")
+
+    raw_access_token = uuid.uuid4()
+    access_token = await access_tokens_repo.create(
+        raw_access_token,
+        client["client_id"],
+        client["grant_type"],
+        client["scope"],
+        refresh_token,
+        client["player_id"],
+    )
+
+    return Token(
+        access_token=str(raw_access_token),
+        refresh_token=refresh_token,
+        token_type="Bearer",
+        expires_in=3600,
+        expires_at=access_token["expires_at"],
+        scope=access_token["scope"],
+    )
diff --git a/app/repositories/access_tokens.py b/app/repositories/access_tokens.py
new file mode 100644
index 000000000..47a23491e
--- /dev/null
+++ b/app/repositories/access_tokens.py
@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from datetime import datetime
+from datetime import timedelta
+from typing import Any
+from typing import Literal
+from typing import TypedDict
+from uuid import UUID
+
+import app.state.services
+from app.api.v2.common import json
+
+ACCESS_TOKEN_TTL = timedelta(hours=1)
+
+
+class AccessToken(TypedDict):
+    refresh_token: UUID | None
+    client_id: int
+    grant_type: str
+    scope: str
+    player_id: int | None
+    created_at: datetime
+    expires_at: datetime
+
+
+def create_access_token_key(code: UUID | Literal["*"]) -> str:
+    return f"bancho:access_tokens:{code}"
+
+
+async def create(
+    access_token_id: UUID,
+    client_id: int,
+    grant_type: str,
+    scope: str,
+    refresh_token: UUID | None = None,
+    player_id: int | None = None,
+) -> AccessToken:
+    now = datetime.now()
+    expires_at = now + ACCESS_TOKEN_TTL
+    access_token: AccessToken = {
+        "refresh_token": refresh_token,
+        "client_id": client_id,
+        "grant_type": grant_type,
+        "scope": scope,
+        "player_id": player_id,
+        "created_at": now,
+        "expires_at": expires_at,
+    }
+    await app.state.services.redis.set(
+        create_access_token_key(access_token_id),
+        json.dumps(access_token),
+        exat=expires_at,
+    )
+    return access_token
+
+
+async def fetch_one(access_token_id: UUID) -> AccessToken | None:
+    raw_access_token = await app.state.services.redis.get(
+        create_access_token_key(access_token_id),
+    )
+    if raw_access_token is None:
+        return None
+    return json.loads(raw_access_token)
+
+
+async def fetch_all(
+    client_id: int | None = None,
+    scope: str | None = None,
+    grant_type: str | None = None,
+    player_id: int | None = None,
+    page: int = 1,
+    page_size: int = 10,
+) -> list[AccessToken]:
+    access_token_key = create_access_token_key("*")
+
+    if page > 1:
+        cursor, keys = await app.state.services.redis.scan(
+            cursor=0,
+            match=access_token_key,
+            count=(page - 1) * page_size,
+        )
+    else:
+        cursor = None
+
+    access_tokens = []
+    while cursor != 0:
+        cursor, keys = await app.state.services.redis.scan(
+            cursor=cursor or 0,
+            match=access_token_key,
+            count=page_size,
+        )
+
+        raw_access_token = await app.state.services.redis.mget(keys)
+        for raw_access_token in raw_access_token:
+            access_token = json.loads(raw_access_token)
+
+            if client_id is not None and access_token["client_id"] != client_id:
+                continue
+
+            if scope is not None and access_token["scopes"] != scope:
+                continue
+
+            if grant_type is not None and access_token["grant_type"] != grant_type:
+                continue
+
+            if player_id is not None and access_token["player_id"] != player_id:
+                continue
+
+            access_tokens.append(access_token)
+
+    return access_tokens
+
+
+async def delete(access_token_id: UUID) -> AccessToken | None:
+    access_token_key = create_access_token_key(access_token_id)
+
+    raw_access_token = await app.state.services.redis.get(access_token_key)
+    if raw_access_token is None:
+        return None
+
+    await app.state.services.redis.delete(access_token_key)
+
+    return json.loads(raw_access_token)
diff --git a/app/repositories/authorization_codes.py b/app/repositories/authorization_codes.py
new file mode 100644
index 000000000..f8fdc6294
--- /dev/null
+++ b/app/repositories/authorization_codes.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from datetime import datetime
+from datetime import timedelta
+from typing import Literal
+from typing import TypedDict
+from uuid import UUID
+
+import app.state.services
+from app.api.v2.common import json
+
+AUTHORIZATION_CODE_TTL = timedelta(minutes=3)
+
+
+class AuthorizationCode(TypedDict):
+    client_id: int
+    scope: str
+    player_id: int
+    created_at: datetime
+    expires_at: datetime
+
+
+def create_authorization_code_key(code: UUID | Literal["*"]) -> str:
+    return f"bancho:authorization_codes:{code}"
+
+
+async def create(
+    code: UUID,
+    client_id: int,
+    scope: str,
+    player_id: int,
+) -> AuthorizationCode:
+    now = datetime.now()
+    expires_at = now + AUTHORIZATION_CODE_TTL
+    authorization_code: AuthorizationCode = {
+        "client_id": client_id,
+        "scope": scope,
+        "player_id": player_id,
+        "created_at": now,
+        "expires_at": expires_at,
+    }
+    await app.state.services.redis.set(
+        create_authorization_code_key(code),
+        json.dumps(authorization_code),
+        exat=expires_at,
+    )
+    return authorization_code
+
+
+async def fetch_one(code: UUID) -> AuthorizationCode | None:
+    raw_authorization_code = await app.state.services.redis.get(
+        create_authorization_code_key(code),
+    )
+    if raw_authorization_code is None:
+        return None
+
+    return json.loads(raw_authorization_code)
+
+
+async def fetch_all(
+    client_id: int | None = None,
+    scope: str | None = None,
+    page: int = 1,
+    page_size: int = 10,
+) -> list[AuthorizationCode]:
+    authorization_code_key = create_authorization_code_key("*")
+
+    if page > 1:
+        cursor, keys = await app.state.services.redis.scan(
+            cursor=0,
+            match=authorization_code_key,
+            count=(page - 1) * page_size,
+        )
+    else:
+        cursor = None
+
+    authorization_codes = []
+    while cursor != 0:
+        cursor, keys = await app.state.services.redis.scan(
+            cursor=cursor or 0,
+            match=authorization_code_key,
+            count=page_size,
+        )
+
+        raw_authorization_code = await app.state.services.redis.mget(keys)
+        for raw_authorization_code in raw_authorization_code:
+            authorization_code = json.loads(raw_authorization_code)
+
+            if client_id is not None and authorization_code["client_id"] != client_id:
+                continue
+
+            if scope is not None and authorization_code["scope"] != scope:
+                continue
+
+            authorization_codes.append(authorization_code)
+
+    return authorization_codes
+
+
+async def delete(code: UUID) -> AuthorizationCode | None:
+    authorization_code_key = create_authorization_code_key(code)
+
+    raw_authorization_code = await app.state.services.redis.get(authorization_code_key)
+    if raw_authorization_code is None:
+        return None
+
+    await app.state.services.redis.delete(authorization_code_key)
+
+    return json.loads(raw_authorization_code)
diff --git a/app/repositories/ouath_clients.py b/app/repositories/ouath_clients.py
new file mode 100644
index 000000000..d4626c731
--- /dev/null
+++ b/app/repositories/ouath_clients.py
@@ -0,0 +1,154 @@
+from __future__ import annotations
+
+import textwrap
+from typing import Any
+from typing import Optional
+
+import app.state.services
+
+# +--------------+-------------+------+-----+---------+----------------+
+# | Field        | Type        | Null | Key | Default | Extra          |
+# +--------------+-------------+------+-----+---------+----------------+
+# | id           | int         | NO   | PRI | NULL    | auto_increment |
+# | name         | varchar(16) | YES  |     | NULL    |                |
+# | secret       | varchar(32) | NO   |     | NULL    |                |
+# | owner        | int         | NO   |     | NULL    |                |
+# | redirect_uri | text        | YES  |     | NULL    |                |
+# +--------------+-------------+------+-----+---------+----------------+
+
+READ_PARAMS = textwrap.dedent(
+    """\
+        id, name, secret, owner, redirect_uri
+    """,
+)
+
+
+async def create(
+    secret: str,
+    owner: int,
+    name: str | None = None,
+    redirect_uri: str | None = None,
+) -> dict[str, Any]:
+    """Create a new client in the database."""
+    query = """\
+        INSERT INTO oauth_clients (secret, owner, name, redirect_uri)
+             VALUES (:secret, :owner, :name, :redirect_uri)
+    """
+    params = {
+        "secret": secret,
+        "owner": owner,
+        "name": name,
+        "redirect_uri": redirect_uri,
+    }
+    rec_id = await app.state.services.database.execute(query, params)
+
+    query = f"""\
+        SELECT {READ_PARAMS}
+          FROM oauth_clients
+         WHERE id = :id
+    """
+    params = {
+        "id": rec_id,
+    }
+
+    rec = await app.state.services.database.fetch_one(query, params)
+    assert rec is not None
+    return dict(rec)
+
+
+async def fetch_one(
+    id: int | None = None,
+    owner: int | None = None,
+    secret: str | None = None,
+    name: str | None = None,
+) -> dict[str, Any] | None:
+    """Fetch a signle client from the database."""
+    if id is None and owner is None and secret is None:
+        raise ValueError("Must provide at least one parameter.")
+
+    query = f"""\
+        SELECT {READ_PARAMS}
+          FROM oauth_clients
+         WHERE id = COALESCE(:id, id)
+            AND owner = COALESCE(:owner, owner)
+            AND secret = COALESCE(:secret, secret)
+            AND name = COALESCE(:name, name)
+    """
+    params = {
+        "id": id,
+        "owner": owner,
+        "secret": secret,
+        "name": name,
+    }
+    rec = await app.state.services.database.fetch_one(query, params)
+    return dict(rec) if rec is not None else None
+
+
+async def fetch_many(
+    id: int | None = None,
+    owner: int | None = None,
+    secret: str | None = None,
+    page: int | None = None,
+    page_size: int | None = None,
+) -> list[dict[str, Any]] | None:
+    """Fetch all clients from the database."""
+    query = f"""\
+        SELECT {READ_PARAMS}
+          FROM oauth_clients
+         WHERE id = COALESCE(:id, id)
+            AND owner = COALESCE(:owner, owner)
+            AND secret = COALESCE(:secret, secret)
+    """
+    params = {
+        "id": id,
+        "owner": owner,
+        "secret": secret,
+    }
+
+    if page is not None and page_size is not None:
+        query += """\
+            LIMIT :limit
+           OFFSET :offset
+        """
+        params["limit"] = page_size
+        params["offset"] = (page - 1) * page_size
+
+    rec = await app.state.services.database.fetch_one(query, params)
+    return dict(rec) if rec is not None else None
+
+
+async def update(
+    id: int,
+    secret: str | None = None,
+    owner: int | None = None,
+    name: str | None = None,
+    redirect_uri: str | None = None,
+) -> dict[str, Any] | None:
+    """Update an existing client in the database."""
+    query = """\
+        UPDATE oauth_clients
+           SET secret = COALESCE(:secret, secret),
+               owner = COALESCE(:owner, owner),
+               redirect_uri = COALESCE(:redirect_uri, redirect_uri)
+               name = COALESCE(:name, name)
+         WHERE id = :id
+    """
+    params = {
+        "id": id,
+        "secret": secret,
+        "owner": owner,
+        "name": name,
+        "redirect_uri": redirect_uri,
+    }
+    await app.state.services.database.execute(query, params)
+
+    query = f"""\
+        SELECT {READ_PARAMS}
+          FROM oauth_clients
+         WHERE id = :id
+    """
+    params = {
+        "id": id,
+    }
+    rec = await app.state.services.database.fetch_one(query, params)
+    return dict(rec) if rec is not None else None
diff --git a/app/repositories/refresh_tokens.py b/app/repositories/refresh_tokens.py
new file mode 100644
index 000000000..b1856a15c
--- /dev/null
+++ b/app/repositories/refresh_tokens.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+from datetime import datetime
+from datetime import timedelta
+from typing import Literal
+from typing import TypedDict
+from uuid import UUID
+
+import app.state.services
+from app.api.v2.common import json
+
+
+class RefreshToken(TypedDict):
+    client_id: int
+    scope: str
+    refresh_token_id: UUID
+    access_token_id: UUID
+    created_at: datetime
+    expires_at: datetime
+
+
+def create_refresh_token_key(code: UUID | Literal["*"]) -> str:
+    return f"bancho:refresh_tokens:{code}"
+
+
+async def create(
+    refresh_token_id: UUID,
+    access_token_id: UUID,
+    client_id: int,
+    scope: str,
+) -> RefreshToken:
+    now = datetime.now()
+    expires_at = now + timedelta(days=30)
+    refresh_token: RefreshToken = {
+        "client_id": client_id,
+        "scope": scope,
+        "refresh_token_id": refresh_token_id,
+        "access_token_id": access_token_id,
+        "created_at": now,
+        "expires_at": expires_at,
+    }
+    await app.state.services.redis.set(
+        create_refresh_token_key(refresh_token_id),
+        json.dumps(refresh_token),
+        exat=expires_at,
+    )
+    return refresh_token
+
+
+async def fetch_one(refresh_token_id: UUID) -> RefreshToken | None:
+    raw_refresh_token = await app.state.services.redis.get(
+        create_refresh_token_key(refresh_token_id),
+    )
+    if raw_refresh_token is None:
+        return None
+
+    return json.loads(raw_refresh_token)
+
+
+async def fetch_all(
+    client_id: int | None = None,
+    scope: str | None = None,
+    page: int = 1,
+    page_size: int = 10,
+) -> list[RefreshToken]:
+    refresh_token_key = create_refresh_token_key("*")
+
+    if page > 1:
+        cursor, keys = await app.state.services.redis.scan(
+            cursor=0,
+            match=refresh_token_key,
+            count=(page - 1) * page_size,
+        )
+    else:
+        cursor = None
+
+    refresh_tokens = []
+    while cursor != 0:
+        cursor, keys = await app.state.services.redis.scan(
+            cursor=cursor or 0,
+            match=refresh_token_key,
+            count=page_size,
+        )
+
+        raw_refresh_token = await app.state.services.redis.mget(keys)
+        for raw_refresh_token in raw_refresh_token:
+            refresh_token = json.loads(raw_refresh_token)
+
+            if client_id is not None and refresh_token["client_id"] != client_id:
+                continue
+
+            if scope is not None and refresh_token["scope"] != scope:
+                continue
+
+            refresh_tokens.append(refresh_token)
+
+    return refresh_tokens
+
+
+async def delete(refresh_token_id: UUID) -> RefreshToken | None:
+    refresh_token_key = create_refresh_token_key(refresh_token_id)
+
+    raw_refresh_token = await app.state.services.redis.get(refresh_token_key)
+    if raw_refresh_token is None:
+        return None
+
+    await app.state.services.redis.delete(refresh_token_key)
+
+    return json.loads(raw_refresh_token)
diff --git a/app/state/services.py b/app/state/services.py
index 0365a552d..3c729c8be 100644
--- a/app/state/services.py
+++ b/app/state/services.py
@@ -40,7 +40,7 @@
 
 http_client = httpx.AsyncClient()
 database = databases.Database(app.settings.DB_DSN)
-redis: aioredis.Redis = aioredis.from_url(app.settings.REDIS_DSN)
+redis: aioredis.Redis = aioredis.from_url(app.settings.REDIS_DSN, decode_responses=True)
 
 datadog: datadog_client.ThreadStats | None = None
 if str(app.settings.DATADOG_API_KEY) and str(app.settings.DATADOG_APP_KEY):
diff --git a/migrations/migrations.sql b/migrations/migrations.sql
index e524aace7..4f57865aa 100644
--- a/migrations/migrations.sql
+++ b/migrations/migrations.sql
@@ -409,3 +409,13 @@ alter table maps drop primary key;
 alter table maps add primary key (id);
 alter table maps modify column server enum('osu!', 'private') not null default 'osu!' after id;
 unlock tables;
+
+# v4.7.3
+CREATE TABLE oauth_clients (
+	id INT(10) NOT NULL AUTO_INCREMENT,
+	name VARCHAR(16) NULL DEFAULT NULL,
+	secret VARCHAR(32) NOT NULL,
+	owner INT(10) NOT NULL,
+	redirect_uri TEXT NULL DEFAULT NULL,
+	PRIMARY KEY (`id`) USING BTREE
+)