diff --git a/docs/docs.json b/docs/docs.json
index 363d6ac082..705dc8fbaa 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -465,6 +465,13 @@
"user-guide/providers/stackit/authentication"
]
},
+ {
+ "group": "Supabase",
+ "pages": [
+ "user-guide/providers/supabase/getting-started-supabase",
+ "user-guide/providers/supabase/authentication"
+ ]
+ },
{
"group": "Vercel",
"pages": [
diff --git a/docs/introduction.mdx b/docs/introduction.mdx
index ea0a9c48b1..af7f3e401e 100644
--- a/docs/introduction.mdx
+++ b/docs/introduction.mdx
@@ -72,6 +72,7 @@ Prowler supports a wide range of providers organized by category:
| [M365](/user-guide/providers/microsoft365/getting-started-m365) | Official | Tenants | UI, API, CLI |
| [MongoDB Atlas](/user-guide/providers/mongodbatlas/getting-started-mongodbatlas) | Official | Organizations | UI, API, CLI |
| [Okta](/user-guide/providers/okta/getting-started-okta) | Official | Organizations | CLI |
+| [Supabase](/user-guide/providers/supabase/getting-started-supabase) | PoC | Organizations / Members | CLI |
| [Vercel](/user-guide/providers/vercel/getting-started-vercel) | Official | Teams / Projects | UI, API, CLI |
### Kubernetes
diff --git a/docs/user-guide/providers/supabase/authentication.mdx b/docs/user-guide/providers/supabase/authentication.mdx
new file mode 100644
index 0000000000..b7769cffe4
--- /dev/null
+++ b/docs/user-guide/providers/supabase/authentication.mdx
@@ -0,0 +1,42 @@
+---
+title: "Supabase Authentication in Prowler"
+---
+
+Prowler for Supabase authenticates to the hosted Supabase Management API using a **Personal Access Token** (PAT). Prowler reads the token exclusively from `SUPABASE_ACCESS_TOKEN`; there is no credential CLI argument, and Prowler does not read Supabase CLI state files.
+
+## Personal Access Token Security
+
+Supabase PATs inherit the privileges of the user account that created them. Use a dedicated least-privilege or read-only organization account where available, set a suitable token expiry, store the token in a secret manager, and rotate it regularly.
+
+
+A PAT is not independently scoped to read-only Management API access. Compromise of the token grants the same Management API privileges as its issuing user account.
+
+
+## Required Access
+
+The issuing account must be able to call:
+
+- [`GET /v1/organizations`](https://supabase.com/docs/reference/api/v1-list-all-organizations)
+- [`GET /v1/organizations/{slug}/members`](https://supabase.com/docs/reference/api/v1-list-organization-members)
+
+An invalid token returns `401`, insufficient organization permissions return `403`, and rate limiting returns `429`. Prowler treats all three as scan errors rather than compliant results. Supabase applies a standard limit of approximately 120 Management API requests per minute for each user and scope; Prowler uses the response rate-limit reset headers before retrying.
+
+## Configure Authentication
+
+Create a PAT from [Supabase Account Tokens](https://supabase.com/dashboard/account/tokens), then export it:
+
+```bash
+export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
+prowler supabase
+```
+
+For CI/CD, configure `SUPABASE_ACCESS_TOKEN` as a masked secret environment variable. Do not place it in command arguments, repository files, or job logs.
+
+## Out of Scope
+
+This proof of concept does not support:
+
+- OAuth 2.0 Authorization Code with Proof Key for Code Exchange (PKCE)
+- Direct PostgreSQL authentication
+- Self-hosted Supabase deployments
+- Supabase CLI credential or state files
diff --git a/docs/user-guide/providers/supabase/getting-started-supabase.mdx b/docs/user-guide/providers/supabase/getting-started-supabase.mdx
new file mode 100644
index 0000000000..7b2188751f
--- /dev/null
+++ b/docs/user-guide/providers/supabase/getting-started-supabase.mdx
@@ -0,0 +1,40 @@
+---
+title: "Getting Started With Supabase on Prowler"
+---
+
+Prowler for Supabase scans hosted Supabase Cloud organizations through the Management API. This proof of concept checks whether each organization member has multi-factor authentication (MFA) enabled.
+
+
+This proof of concept supports Prowler SDK and Prowler CLI only. It does not support onboarding through Prowler Cloud, Prowler Private Cloud, or Prowler Local Server; self-hosted Supabase; direct PostgreSQL access; or OAuth.
+
+
+## Prerequisites
+
+1. A hosted Supabase Cloud account with access to at least one organization.
+2. A Supabase Personal Access Token (PAT) for an account that can list organizations and organization members.
+3. A dedicated least-privilege or read-only organization account where your Supabase plan and organization roles allow one.
+
+See [Supabase Authentication in Prowler](./authentication) for PAT setup and security guidance.
+
+## Run a Scan
+
+Set the PAT through the environment and run Prowler:
+
+```bash
+export SUPABASE_ACCESS_TOKEN="your-personal-access-token"
+prowler supabase
+```
+
+To run only the organization member MFA check:
+
+```bash
+prowler supabase --check organizations_member_mfa_enabled
+```
+
+## Checks Shipped
+
+| Check ID | Severity | Description |
+|---|---|---|
+| `organizations_member_mfa_enabled` | High | Creates one finding per organization member and fails members whose Supabase account does not have MFA enabled. |
+
+The finding resource UID is the Management API `user_id`. Prowler does not include member email addresses in findings or normal logs.
diff --git a/prowler/changelog.d/supabase-provider.added.md b/prowler/changelog.d/supabase-provider.added.md
new file mode 100644
index 0000000000..36be668051
--- /dev/null
+++ b/prowler/changelog.d/supabase-provider.added.md
@@ -0,0 +1 @@
+Supabase provider with the `organizations_member_mfa_enabled` check
diff --git a/prowler/providers/supabase/services/organizations/organizations_client.py b/prowler/providers/supabase/services/organizations/organizations_client.py
new file mode 100644
index 0000000000..e49d4c4a0c
--- /dev/null
+++ b/prowler/providers/supabase/services/organizations/organizations_client.py
@@ -0,0 +1,6 @@
+from prowler.providers.common.provider import Provider
+from prowler.providers.supabase.services.organizations.organizations_service import (
+ Organizations,
+)
+
+organizations_client = Organizations(Provider.get_global_provider())
diff --git a/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/__init__.py b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.metadata.json b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.metadata.json
new file mode 100644
index 0000000000..e5c10eaab9
--- /dev/null
+++ b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.metadata.json
@@ -0,0 +1,37 @@
+{
+ "Provider": "supabase",
+ "CheckID": "organizations_member_mfa_enabled",
+ "CheckTitle": "Supabase organization members have multi-factor authentication enabled",
+ "CheckType": [],
+ "ServiceName": "organizations",
+ "SubServiceName": "",
+ "ResourceIdTemplate": "",
+ "Severity": "high",
+ "ResourceType": "NotDefined",
+ "ResourceGroup": "IAM",
+ "Description": "This check verifies whether each Supabase organization member has multi-factor authentication enabled for their Supabase account.",
+ "Risk": "Without multi-factor authentication, compromised credentials can allow unauthorized access to Supabase organizations, projects, and management operations available to the member.",
+ "RelatedUrl": "",
+ "AdditionalURLs": [
+ "https://supabase.com/docs/reference/api/v1-list-organization-members",
+ "https://supabase.com/docs/guides/platform/multi-factor-authentication"
+ ],
+ "Remediation": {
+ "Code": {
+ "CLI": "",
+ "NativeIaC": "",
+ "Other": "Ask the organization member to open Supabase Dashboard > Account Settings > Security and enroll a TOTP factor. Organization owners on eligible plans can also enforce MFA for all members.",
+ "Terraform": ""
+ },
+ "Recommendation": {
+ "Text": "Require every organization member to enroll multi-factor authentication and periodically review membership for accounts that do not meet this requirement.",
+ "Url": "https://hub.prowler.com/check/organizations_member_mfa_enabled"
+ }
+ },
+ "Categories": [
+ "identity-access"
+ ],
+ "DependsOn": [],
+ "RelatedTo": [],
+ "Notes": ""
+}
diff --git a/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.py b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.py
new file mode 100644
index 0000000000..d0121be889
--- /dev/null
+++ b/prowler/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled.py
@@ -0,0 +1,28 @@
+from prowler.lib.check.models import Check, CheckReportSupabase
+from prowler.providers.supabase.services.organizations.organizations_client import (
+ organizations_client,
+)
+
+
+class organizations_member_mfa_enabled(Check):
+ """Check whether each Supabase organization member has MFA enabled."""
+
+ def execute(self) -> list[CheckReportSupabase]:
+ """Return one MFA finding per organization member."""
+ findings = []
+ for member in organizations_client.members.values():
+ report = CheckReportSupabase(metadata=self.metadata(), resource=member)
+ if member.mfa_enabled:
+ report.status = "PASS"
+ report.status_extended = (
+ f"Supabase organization {member.organization_slug} member "
+ f"{member.id} has MFA enabled."
+ )
+ else:
+ report.status = "FAIL"
+ report.status_extended = (
+ f"Supabase organization {member.organization_slug} member "
+ f"{member.id} does not have MFA enabled."
+ )
+ findings.append(report)
+ return findings
diff --git a/tests/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled_test.py b/tests/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled_test.py
new file mode 100644
index 0000000000..2c51a4c45b
--- /dev/null
+++ b/tests/providers/supabase/services/organizations/organizations_member_mfa_enabled/organizations_member_mfa_enabled_test.py
@@ -0,0 +1,101 @@
+from unittest import mock
+
+from prowler.providers.supabase.services.organizations.organizations_service import (
+ SupabaseOrganizationMember,
+)
+from tests.providers.supabase.supabase_fixtures import (
+ ORGANIZATION_NAME,
+ ORGANIZATION_SLUG,
+ USER_ID,
+ set_mocked_supabase_provider,
+)
+
+
+class Test_organizations_member_mfa_enabled:
+ def _execute(self, members):
+ organizations_client = mock.MagicMock()
+ organizations_client.members = members
+
+ with (
+ mock.patch(
+ "prowler.providers.common.provider.Provider.get_global_provider",
+ return_value=set_mocked_supabase_provider(),
+ ),
+ mock.patch(
+ "prowler.providers.supabase.services.organizations.organizations_member_mfa_enabled.organizations_member_mfa_enabled.organizations_client",
+ new=organizations_client,
+ ),
+ ):
+ from prowler.providers.supabase.services.organizations.organizations_member_mfa_enabled.organizations_member_mfa_enabled import (
+ organizations_member_mfa_enabled,
+ )
+
+ return organizations_member_mfa_enabled().execute()
+
+ def test_no_members(self):
+ assert self._execute({}) == []
+
+ def test_member_with_mfa_passes_without_email(self):
+ member = SupabaseOrganizationMember(
+ id=USER_ID,
+ name=f"member {USER_ID}",
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ mfa_enabled=True,
+ )
+
+ result = self._execute({f"{ORGANIZATION_SLUG}:{USER_ID}": member})
+
+ assert len(result) == 1
+ assert result[0].status == "PASS"
+ assert result[0].resource_id == USER_ID
+ assert result[0].organization_slug == ORGANIZATION_SLUG
+ assert result[0].status_extended == (
+ f"Supabase organization {ORGANIZATION_SLUG} member {USER_ID} has MFA enabled."
+ )
+
+ def test_member_without_mfa_fails(self):
+ member = SupabaseOrganizationMember(
+ id=USER_ID,
+ name=f"member {USER_ID}",
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ mfa_enabled=False,
+ )
+
+ result = self._execute({f"{ORGANIZATION_SLUG}:{USER_ID}": member})
+
+ assert len(result) == 1
+ assert result[0].status == "FAIL"
+ assert result[0].resource_id == USER_ID
+ assert result[0].status_extended == (
+ f"Supabase organization {ORGANIZATION_SLUG} member {USER_ID} does not have MFA enabled."
+ )
+
+ def test_multiple_members_return_independent_pass_and_fail_findings(self):
+ enabled_member = SupabaseOrganizationMember(
+ id="enabled-user",
+ name="member enabled-user",
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ mfa_enabled=True,
+ )
+ disabled_member = SupabaseOrganizationMember(
+ id="disabled-user",
+ name="member disabled-user",
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ mfa_enabled=False,
+ )
+
+ result = self._execute(
+ {
+ f"{ORGANIZATION_SLUG}:enabled-user": enabled_member,
+ f"{ORGANIZATION_SLUG}:disabled-user": disabled_member,
+ }
+ )
+
+ assert [(finding.resource_id, finding.status) for finding in result] == [
+ ("enabled-user", "PASS"),
+ ("disabled-user", "FAIL"),
+ ]
diff --git a/tests/providers/supabase/supabase_provider_test.py b/tests/providers/supabase/supabase_provider_test.py
index 30bcf7a3ea..36d1cad896 100644
--- a/tests/providers/supabase/supabase_provider_test.py
+++ b/tests/providers/supabase/supabase_provider_test.py
@@ -1,11 +1,14 @@
import os
from argparse import Namespace
+from pathlib import Path
from unittest import mock
import pytest
from prowler.config.config import Provider as ProviderName
+from prowler.lib.check.models import CheckReportSupabase
from prowler.lib.cli.parser import ProwlerArgumentParser
+from prowler.lib.outputs.finding import Finding
from prowler.lib.outputs.html.html import HTML
from prowler.providers.common.provider import Provider
from prowler.providers.supabase.exceptions.exceptions import (
@@ -15,12 +18,16 @@ from prowler.providers.supabase.exceptions.exceptions import (
SupabaseRateLimitError,
)
from prowler.providers.supabase.models import SupabaseOrganization, SupabaseSession
+from prowler.providers.supabase.services.organizations.organizations_service import (
+ SupabaseOrganizationMember,
+)
from prowler.providers.supabase.supabase_provider import SupabaseProvider
from tests.providers.supabase.supabase_fixtures import (
ACCESS_TOKEN,
ORGANIZATION_ID,
ORGANIZATION_NAME,
ORGANIZATION_SLUG,
+ USER_ID,
)
@@ -149,6 +156,37 @@ class TestSupabaseProviderOutputHooks:
"region": "global",
}
+ def test_finding_output_pipeline_uses_supabase_fields(self):
+ provider = SupabaseProvider.__new__(SupabaseProvider)
+ provider._identity = mock.MagicMock(organizations=[])
+ member = SupabaseOrganizationMember(
+ id=USER_ID,
+ name=f"member {USER_ID}",
+ organization_slug=ORGANIZATION_SLUG,
+ organization_name=ORGANIZATION_NAME,
+ mfa_enabled=False,
+ )
+ metadata = Path(
+ "prowler/providers/supabase/services/organizations/"
+ "organizations_member_mfa_enabled/"
+ "organizations_member_mfa_enabled.metadata.json"
+ ).read_text()
+ check_output = CheckReportSupabase(metadata=metadata, resource=member)
+ check_output.status = "FAIL"
+ check_output.status_extended = "Member does not have MFA enabled."
+
+ finding = Finding.generate_output(
+ provider, check_output, Namespace(unix_timestamp=False)
+ )
+
+ assert finding.provider == "supabase"
+ assert finding.account_uid == ORGANIZATION_SLUG
+ assert finding.account_name == ORGANIZATION_NAME
+ assert finding.resource_name == f"member {USER_ID}"
+ assert finding.resource_uid == USER_ID
+ assert finding.region == "global"
+ assert finding.auth_method == "personal_access_token"
+
@pytest.mark.parametrize(
("output_filename", "expected"),
[