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.

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.

API
You can also retrieve advisor issues via the Neon API:
GET /projects/{project_id}/advisorsThis 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 bySECURITYorPERFORMANCEmin_severity: Minimum severity to include:INFO,WARN, orERROR. For example,WARNreturns 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
| Check | Severity | Description |
|---|---|---|
| RLS disabled in public | ERROR | Tables exposed via the Data API without row-level security |
| Policy exists RLS disabled | ERROR | RLS policies exist but RLS is not enabled on the table |
| Sensitive columns exposed | ERROR | API-exposed tables without RLS that contain potentially sensitive columns |
| Security definer view | ERROR | Views using SECURITY DEFINER, bypassing the querying user's permissions |
| Neon Auth users exposed | ERROR | Views exposing neon_auth.user data to API roles |
| RLS references Neon Auth metadata | ERROR | RLS policies referencing user-editable Neon Auth fields |
| FK to Neon Auth unique constraint | ERROR | Foreign keys referencing Neon Auth unique constraints instead of primary keys |
| RLS policy always true | WARN | RLS policies with always-true expressions that bypass access control |
| Function search path mutable | WARN | Functions without an explicit search_path setting |
| Extension in public | WARN | Extensions installed in the public schema, exposing their objects via the Data API |
| Extension versions outdated | WARN | Extensions not using the recommended default version |
| Materialized view in API | WARN | Materialized views accessible to API roles, bypassing RLS |
| Foreign table in API | WARN | Foreign tables accessible over the Data API, which cannot use RLS |
| Unsupported reg types | WARN | Columns using reg* types that prevent pg_upgrade |
| RLS enabled no policy | INFO | RLS is enabled but no policies have been created |
Performance checks
| Check | Severity | Description |
|---|---|---|
| Auth RLS InitPlan | WARN | RLS policy functions re-evaluated per row instead of once per query |
| Multiple permissive policies | WARN | Multiple permissive RLS policies on the same table for the same role and action |
| Duplicate index | WARN | Two or more identical indexes on the same table |
| Unindexed foreign keys | INFO | Foreign key columns without a covering index |
| Unused index | INFO | Indexes that have never been used |
| No primary key | INFO | Tables without a primary key |
| Table bloat | INFO | Tables 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 roleneon_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: falseForeign 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.








