The Data API Advisors analyze your database schema and configuration to detect security and performance issues for tables and objects exposed by the Data API feature. They run a set of checks against your database and report issues with severity levels and recommended fixes.

Because the Data API exposes your database schema directly over HTTP, security misconfigurations that would normally be hidden behind an application server become directly exploitable. Missing RLS policies and overly permissive views are especially dangerous in this context. The advisors also check for common performance issues like unindexed foreign keys and table bloat, helping you catch issues before they reach production.

Prerequisites

  • Your compute must be active (not suspended) when running a scan.
  • If the Data API is not yet enabled, the Advisors screen will show a "Data API not enabled" message. Enable the Data API to run scans.

How to access

Neon Console

In the Neon Console, go to Monitoring > Data API Advisors for your branch. The advisor scans your database and displays any issues found, grouped by category.

Data API advisor

Each issue includes a severity level, a description, and a recommended fix. Clicking an issue opens a detail view with an Ask Assistant option that analyzes the risk and suggests a specific fix (such as a SQL query tailored to your schema), along with a Read our docs link for general guidance on that type of issue.

Data API advisor solution

API

You can also retrieve advisor issues via the Neon API:

GET /projects/{project_id}/advisors

This endpoint requires Data API to be enabled for the target project and branch.

Optional query parameters:

  • branch_id: Target branch (defaults to the primary branch)
  • database_name: Target database (required if the branch has multiple databases)
  • category: Filter by SECURITY or PERFORMANCE
  • min_severity: Minimum severity to include: INFO, WARN, or ERROR. For example, WARN returns WARN and ERROR issues but not INFO.
Example response
{
  "issues": [
    {
      "name": "rls_disabled_in_public",
      "title": "RLS Disabled in Public",
      "level": "ERROR",
      "facing": "EXTERNAL",
      "categories": ["SECURITY"],
      "description": "Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to Data-API",
      "detail": "Table `public.users` is public, but RLS has not been enabled.",
      "remediation": "https://neon.com/docs/data-api/database-advisor#rls-disabled-in-public",
      "metadata": {}
    }
  ]
}

Security checks

CheckSeverityDescription
RLS disabled in publicERRORTables exposed via the Data API without row-level security
Policy exists RLS disabledERRORRLS policies exist but RLS is not enabled on the table
Sensitive columns exposedERRORAPI-exposed tables without RLS that contain potentially sensitive columns
Security definer viewERRORViews using SECURITY DEFINER, bypassing the querying user's permissions
Neon Auth users exposedERRORViews exposing neon_auth.user data to API roles
RLS references Neon Auth metadataERRORRLS policies referencing user-editable Neon Auth fields
FK to Neon Auth unique constraintERRORForeign keys referencing Neon Auth unique constraints instead of primary keys
RLS policy always trueWARNRLS policies with always-true expressions that bypass access control
Function search path mutableWARNFunctions without an explicit search_path setting
Extension in publicWARNExtensions installed in the public schema, exposing their objects via the Data API
Extension versions outdatedWARNExtensions not using the recommended default version
Materialized view in APIWARNMaterialized views accessible to API roles, bypassing RLS
Foreign table in APIWARNForeign tables accessible over the Data API, which cannot use RLS
Unsupported reg typesWARNColumns using reg* types that prevent pg_upgrade
RLS enabled no policyINFORLS is enabled but no policies have been created

Performance checks

CheckSeverityDescription
Auth RLS InitPlanWARNRLS policy functions re-evaluated per row instead of once per query
Multiple permissive policiesWARNMultiple permissive RLS policies on the same table for the same role and action
Duplicate indexWARNTwo or more identical indexes on the same table
Unindexed foreign keysINFOForeign key columns without a covering index
Unused indexINFOIndexes that have never been used
No primary keyINFOTables without a primary key
Table bloatINFOTables with excessive bloat that may benefit from maintenance

Security check details

RLS disabled in public

Severity: ERROR

Tables in schemas exposed through the Data API are accessible to anyone with your project's API URL if RLS is not enabled. Without RLS, all rows are fully readable and writable via the API.

See Data API access control and RLS tutorial.

Show resolution

Enable RLS on the affected table:

ALTER TABLE <schema>.<table> ENABLE ROW LEVEL SECURITY;

After enabling RLS, no data is accessible via the Data API until you create policies to define access rules.

If the table should not be accessible via the API at all, remove its schema from the exposed schemas in your Data API settings.


Policy exists RLS disabled

Severity: ERROR

RLS policies have been created on a table, but RLS itself is not enabled. The policies have no effect until RLS is turned on, which may give a false sense of security.

Show resolution

Enable RLS on the table:

ALTER TABLE <schema>.<table> ENABLE ROW LEVEL SECURITY;

Sensitive columns exposed

Severity: ERROR

Tables exposed via the Data API that contain columns with potentially sensitive data (credentials, personal identifiers, financial info) are at risk if RLS is not enabled. Without RLS, any API user can read all rows.

Show resolution

This check matches exact column names (case-insensitive, hyphens treated as underscores) against known sensitive patterns, including:

  • Credentials: password, secret, api_key, token, jwt, access_token, refresh_token, otp, 2fa_secret, and others
  • Personal identifiers: ssn, passport_number, tax_id, driver_license, national_id
  • Financial data: credit_card, cvv, bank_account, iban, routing_number
  • Other: health_record, ssh_key, pgp_key, mac_address, facial_recognition

A column named user_password_hash would not be flagged because matching is on the full column name, not substrings.

Enable RLS on the table and create appropriate policies:

ALTER TABLE <schema>.<table> ENABLE ROW LEVEL SECURITY;

CREATE POLICY restrict_access ON <schema>.<table>
  FOR SELECT
  USING ((SELECT auth.uid()) = user_id);

Alternatively, move sensitive columns to a separate table with its own RLS policies, or remove the schema from the Data API's exposed schemas if API access is not needed.


Security definer view

Severity: ERROR

Views default to SECURITY DEFINER in PostgreSQL, meaning they execute with the permissions of the view creator rather than the querying user. This bypasses RLS policies on the underlying tables and can expose more data than intended through the Data API.

See PostgreSQL views for background.

Show resolution

Set the view to use SECURITY INVOKER mode so it respects the querying user's permissions and RLS policies:

CREATE OR REPLACE VIEW <schema>.<view_name>
  WITH (security_invoker=on)
AS
SELECT ...
FROM ...;

Note: The security_invoker option requires PostgreSQL 15 or later. This check only runs on PostgreSQL 15+.


Neon Auth users exposed

Severity: ERROR

Views or materialized views that reference neon_auth.user can expose sensitive user data to API roles. Views in PostgreSQL default to SECURITY DEFINER mode, which means they bypass row-level security policies on the underlying tables.

See Neon Auth overview and Data API access control for background.

Show resolution

Option 1: Use a profiles table with a trigger

Create a public.profiles table with only the user fields your application needs, and populate it via a trigger on neon_auth.user:

CREATE TABLE public.profiles (
  id uuid NOT NULL REFERENCES neon_auth.user ON DELETE CASCADE,
  display_name text,
  PRIMARY KEY (id)
);

CREATE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
  INSERT INTO public.profiles (id)
  VALUES (new.id);
  RETURN new;
END;
$$;

CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON neon_auth.user
  FOR EACH ROW EXECUTE PROCEDURE public.handle_new_user();

Then enable RLS on public.profiles and create policies to control access.

Option 2: Use a security invoker view

Create a view with security_invoker=on so it respects the querying user's permissions:

CREATE VIEW public.members
  WITH (security_invoker=on)
AS
SELECT id, created_at
FROM neon_auth.user;

Note: For this approach to work, RLS must also be enabled on the neon_auth.user table with appropriate policies. Otherwise, the security invoker view still exposes all rows.


RLS references Neon Auth metadata

Severity: ERROR

RLS policies that reference user-editable fields from Neon Auth (such as user.role or organization.metadata) are insecure. Users can modify these fields, allowing them to bypass security checks.

See Neon Auth overview.

Show resolution

Remove references to user-editable Neon Auth fields from your RLS policies. The specific fields detected by this check are:

  • neon_auth.user.role -- users can change their own role
  • neon_auth.organization.metadata -- organization metadata can be modified

Instead, base security decisions on stable, non-editable fields like the user's id.

For example, replace:

-- Insecure: references neon_auth.user.role which users can modify
CREATE POLICY role_based_access ON public.documents
  FOR SELECT
  USING (
    (SELECT role FROM neon_auth.user WHERE id = (SELECT auth.uid())) = 'admin'
  );

With a policy based on a stable identifier:

CREATE POLICY owner_access ON public.documents
  FOR SELECT
  USING ((SELECT auth.uid()) = user_id);

FK to Neon Auth unique constraint

Severity: ERROR

Foreign keys referencing unique constraints (rather than primary keys) in the neon_auth schema are not supported. These unique constraints may change in future updates, which would break your foreign key references and block migrations.

Show resolution

Drop the foreign key constraint referencing the unique constraint and recreate it referencing the primary key instead:

ALTER TABLE <schema>.<table> DROP CONSTRAINT <foreign_key_name>;

ALTER TABLE <schema>.<table>
  ADD CONSTRAINT <foreign_key_name>
  FOREIGN KEY (<column>) REFERENCES neon_auth.user(id);

RLS policy always true

Severity: WARN

RLS policies that use always-true expressions like USING (true) or WITH CHECK (true) for write operations (INSERT, UPDATE, DELETE) effectively bypass row-level security. While the table appears to have RLS enabled, these policies grant unrestricted access.

Note: USING (true) on SELECT-only policies is excluded from this check, as it is a common pattern for intentional public read access.

Show resolution

Replace the always-true policy with one that enforces actual access control:

DROP POLICY <policy_name> ON <schema>.<table>;

CREATE POLICY <new_policy_name> ON <schema>.<table>
  FOR <action>
  USING ((SELECT auth.uid()) = user_id);

If you need broad read access combined with restricted writes, use a restrictive policy:

CREATE POLICY restrict_writes ON <schema>.<table>
  AS RESTRICTIVE
  FOR UPDATE
  USING ((SELECT auth.uid()) = user_id);

Function search path mutable

Severity: WARN

Functions without an explicit search_path inherit the calling user's session search_path. This can lead to inconsistent behavior and potential security issues if a user manipulates their search_path to resolve unqualified names to unexpected objects.

See PostgreSQL schemas and search_path for background.

Show resolution

Set search_path to an empty string in the function definition, and fully qualify all object references:

CREATE OR REPLACE FUNCTION <schema>.<function_name>()
  RETURNS void
  LANGUAGE sql
  SET search_path = ''
AS $$
  -- Use fully qualified names, e.g. public.my_table
$$;

Extension in public

Severity: WARN

Extensions installed in the public schema make their functions, tables, and views appear as part of your API surface. This is usually unintentional and can expose internal functionality.

See Neon extensions for more on managing extensions.

Show resolution

Move the extension to a non-exposed schema:

ALTER EXTENSION <extension_name> SET SCHEMA <non_exposed_schema>;

For example, to move the ltree extension out of public:

ALTER EXTENSION ltree SET SCHEMA extensions;

Extension versions outdated

Severity: WARN

Running an outdated extension version may expose your database to known security vulnerabilities and prevent you from benefiting from bug fixes and performance improvements.

See managing extensions for background.

Show resolution

Check the current and available versions:

SELECT name, installed_version, default_version
FROM pg_catalog.pg_available_extensions
WHERE name = '<extension_name>';

Then update to the default version:

ALTER EXTENSION <extension_name> UPDATE;

Note: Some extension updates may include breaking changes. Test the update in a development branch before applying to production, and review the extension's changelog if available.


Materialized view in API

Severity: WARN

Materialized views cannot be protected with RLS. If a materialized view is accessible to API roles (anonymous or authenticated), all rows are visible to any API user.

See PostgreSQL materialized views.

Show resolution

Revoke SELECT access from API roles:

REVOKE SELECT ON <schema>.<materialized_view> FROM public, anonymous, authenticated;

You can verify the change with:

SELECT pg_catalog.has_table_privilege('anonymous', '<schema>.<materialized_view>'::regclass::oid, 'select');
-- Should return: false

Foreign table in API

Severity: WARN

Foreign tables do not support RLS. If a foreign table is accessible over the Data API, all rows are visible to any API user.

Show resolution

Revoke SELECT access from API roles:

REVOKE SELECT ON <schema>.<foreign_table> FROM public, anonymous, authenticated;

If you need to access data from the foreign table over the API, move it to a non-exposed schema, grant API roles access in the new schema, and create a function that implements security rules:

CREATE SCHEMA private;
ALTER FOREIGN TABLE public.<foreign_table> SET SCHEMA private;

-- Ensure API roles can still read from the foreign table
GRANT SELECT ON private.<foreign_table> TO anonymous, authenticated;

-- Create a function with security rules, accessible via RPC
CREATE OR REPLACE FUNCTION public.secure_foreign_read()
  RETURNS TABLE (id integer, data text, author_id uuid)
  LANGUAGE sql
  SET search_path = ''
AS $$
  SELECT id, data, author_id
  FROM private.<foreign_table>
  WHERE author_id = (SELECT auth.uid());
$$;

Unsupported reg types

Severity: WARN

Columns using certain reg* types that reference PostgreSQL internals are not supported by pg_upgrade, the standard tool for upgrading between PostgreSQL versions. Using these types can block future database upgrades. The flagged types are: regcollation, regconfig, regdictionary, regnamespace, regoper, regoperator, regproc, and regprocedure.

Show resolution

Replace the reg* type column with a text column storing the object's name:

-- Instead of:
CREATE TABLE public.bad_table (
  id int PRIMARY KEY,
  my_collation regcollation
);

-- Use:
CREATE TABLE public.good_table (
  id int PRIMARY KEY,
  my_collation_name text
);

RLS enabled no policy

Severity: INFO

RLS is enabled on a table but no policies have been created. This means no data is accessible via the Data API for this table, which may or may not be intentional.

Show resolution

If the table should be accessible, create an appropriate RLS policy:

CREATE POLICY select_own_rows ON <schema>.<table>
  FOR SELECT
  USING ((SELECT auth.uid()) = user_id);

If blocking all access is intentional, make it explicit with a denial policy:

CREATE POLICY deny_all ON <schema>.<table>
  FOR SELECT
  USING (false);

See Secure your app with RLS for a step-by-step guide.


Performance check details

Auth RLS InitPlan

Severity: WARN

When RLS policies call functions like auth.uid() or current_setting() directly, PostgreSQL may re-evaluate them for every row the query touches. At scale, this adds significant overhead. Wrapping the call in a subquery caches the result for the duration of the query.

See RLS performance with subqueries for more context.

Show resolution

Wrap the function call in a SELECT subquery:

-- Before (re-evaluated per row):
CREATE POLICY access_policy ON documents
  USING (auth.uid() = user_id);

-- After (evaluated once per query):
CREATE POLICY access_policy ON documents
  USING ((SELECT auth.uid()) = user_id);

This applies to auth.uid(), auth.role(), auth.jwt(), auth.email(), and current_setting() calls in RLS policies.


Multiple permissive policies

Severity: WARN

When multiple permissive RLS policies apply to the same table for the same role and action, PostgreSQL must evaluate all of them (they combine with OR logic). This increases query overhead and can also lead to unintentionally broad access.

See row-level security for background on policy types.

Show resolution

Consolidate multiple permissive policies into a single policy that combines the conditions:

-- Instead of two separate policies:
DROP POLICY policy_a ON <table>;
DROP POLICY policy_b ON <table>;

-- Combine into one:
CREATE POLICY consolidated_policy ON <table>
  FOR SELECT
  USING (
    <condition_a> OR <condition_b>
  );

This reduces the number of policy evaluations per row and gives the query planner better optimization opportunities.

Consolidation is a best practice, not a hard rule. If combining conditions leads to unreadable SQL, multiple policies may be acceptable for maintainability.


Duplicate index

Severity: WARN

Two or more identical indexes on the same table add write overhead with no performance benefit. Each duplicate must be updated on every insert, update, and delete operation.

Show resolution

Drop all but one instance of the duplicate index:

DROP INDEX <schema_name>.<duplicate_index_name>;

Unindexed foreign keys

Severity: INFO

Foreign key columns without a covering index can lead to slow joins and increased resource usage, especially as tables grow. Adding an index on foreign key columns improves join performance.

See PostgreSQL foreign keys and index types for background.

Show resolution

Create an index on the foreign key column:

CREATE INDEX ON <table>(<foreign_key_column>);

For example, if order_item.customer_id references customer.id:

CREATE INDEX ix_order_item_customer_id ON order_item(customer_id);

Choose an index type appropriate for the column's data type and query patterns. The default B-tree index works well for most foreign key columns.


Unused index

Severity: INFO

Indexes that have never been used by any query still add overhead on every write operation (insert, update, delete) and consume storage. Removing unused indexes can improve write performance. This check only flags non-unique, non-primary-key indexes.

Important: PostgreSQL tracks index usage in pg_stat_user_indexes, but these statistics are local to the compute and do not persist across compute restarts (including suspend/resume cycles and failovers). An index showing zero scans may simply not have been used since the last compute start, not since it was created. Allow enough time for representative traffic before treating an index as truly unused.

See PostgreSQL indexes for background.

Show resolution

Before removing an index, confirm it is not needed for upcoming features or seasonal workloads. Then drop it:

DROP INDEX <schema_name>.<index_name>;

Consider testing in a development branch first to verify no performance regression.


No primary key

Severity: INFO

Tables without a primary key lack a guaranteed unique row identifier. This can degrade query performance and prevents establishing foreign key relationships with other tables.

Show resolution

Add a primary key to the table:

ALTER TABLE <schema>.<table> ADD PRIMARY KEY (<column>);

If no single column is unique, use a composite key:

ALTER TABLE <schema>.<table> ADD PRIMARY KEY (<column1>, <column2>);

Use simple, fixed-size types like int, bigint, or uuid for primary keys when possible.


Table bloat

Severity: INFO

Tables with excessive bloat contain unused space from deleted or updated rows. This increases storage usage, slows down sequential scans, and increases I/O overhead. PostgreSQL's autovacuum normally reclaims this space, but it may not keep up with high-churn tables.

See pg_repack and pgstattuple for tools to analyze and resolve bloat.

Show resolution

Start with a standard VACUUM which runs without locking the table:

VACUUM <schema>.<table>;

If that is not sufficient, VACUUM FULL reclaims more space but locks the table for the duration. Duration scales with table size, so plan for potential downtime on large tables.

VACUUM FULL <schema>.<table>;

For large or heavily used tables where downtime is not acceptable, consider using pg_repack as a less disruptive alternative.

You can check table size before and after:

SELECT pg_size_pretty(pg_table_size('<schema>.<table>'));

If a table is repeatedly flagged for bloat, review your autovacuum settings to ensure vacuuming runs frequently enough. Contact Neon support for help tuning autovacuum for high-churn tables.

Database lints originally based on open-source lints from the Supabase project, Apache 2.0.