The Supabase Anon Key Vulnerability Explained

A critical security oversight in Supabase's Row Level Security (RLS) policies means that the widely used `anon` key can potentially expose an entire users or profiles table. This vulnerability arises from misconfigured RLS policies that fail to adequately restrict access, allowing unauthenticated users to read sensitive information.

The `anon` key is Supabase's default client-side key, intended for read-only access to public data and for enabling basic authentication flows. It is typically embedded directly in client applications, making it accessible to anyone who inspects the application's code. The expectation is that this key should only grant access to data explicitly marked as public or to perform actions like signing up. However, the recent discovery highlights how easily this expectation can be undermined by overly permissive RLS policies.

Consider a typical Supabase setup where a profiles table stores user information such as names, timezones, and availability. A common RLS policy might look like this:

create policy "profiles are viewable" on public.profiles for select using (
  id = auth.uid() or not public.blocked_with(id)
);

This policy, at first glance, seems reasonable. It allows a user to view their own profile (`id = auth.uid()`) or if they are not blocked by a custom function `public.blocked_with(id)`. The function `public.blocked_with(id)` is a user-defined function that presumably checks if the requesting user (implicitly via `auth.uid()`) has blocked the target profile.

Diagram illustrating Supabase RLS policy evaluation flow with anon key

The flaw lies in the interpretation of `auth.uid()` when accessed by an unauthenticated user (i.e., using the `anon` key). When an unauthenticated user makes a request, `auth.uid()` returns null. Consequently, the condition `id = auth.uid()` evaluates to false for all rows, as no user ID can equal null. This is the intended behavior for unauthenticated users regarding their own profile.

The Bypass Mechanism

The critical issue emerges with the second part of the `OR` condition: `not public.blocked_with(id)`. If the `public.blocked_with(id)` function is not designed to handle null input gracefully, or if it defaults to returning false for null inputs, then the entire condition `not public.blocked_with(id)` will evaluate to true for unauthenticated requests. This means any unauthenticated user, armed only with the `anon` key, can read all rows from the profiles table where the `blocked_with` function returns false for null input.

This bypass is not specific to the `profiles` table. Any table with a similar RLS policy structure that relies on `auth.uid()` and a custom function that doesn't properly handle null can be vulnerable. For instance, a `users` table containing email addresses, registration dates, or other personal details would be equally exposed if protected by such a policy.

The implications are significant. Developers often store sensitive user metadata in tables accessible via the `anon` key for convenience. This includes personal identifiers, preferences, and other information that should not be publicly accessible. The `anon` key is meant to be used for frontend applications where direct database access is limited by design and RLS. When RLS fails to enforce these boundaries, the security model collapses.

Mitigation Strategies

The primary solution is to ensure RLS policies are robust and consider the implications of `auth.uid()` returning null for unauthenticated requests. Developers must explicitly handle unauthenticated access within their policies. For the example policy above, a corrected version would explicitly check for authentication before evaluating the `blocked_with` condition:

create policy "profiles are viewable" on public.profiles for select using (
  auth.role() = 'authenticated' and id = auth.uid() or 
  auth.role() != 'authenticated' and false -- explicitly deny unauthenticated access to all rows
);

Alternatively, if some level of public access is desired, the policy must be more granular:

create policy "public profiles are viewable" on public.profiles for select using (
  is_public = true -- a column indicating if the profile is public
);

create policy "own profile is viewable" on public.profiles for select using (
  id = auth.uid()
);

This approach separates public data from authenticated-only data. The `public.blocked_with` function itself should also be audited. It should ideally return true (indicating a block) or throw an error if called with a null user ID, rather than implicitly allowing access.

Supabase acknowledges this issue and is working on providing clearer guidance and potentially more robust defaults. However, the onus remains on developers to correctly implement RLS. This vulnerability serves as a stark reminder that security is not just about enabling features but about meticulously configuring them, especially when dealing with sensitive user data.

Broader Implications for Supabase Users

This discovery impacts a wide range of Supabase users, particularly those who have not deeply audited their RLS policies. The ease with which this bypass can be achieved suggests that many projects, especially smaller or rapidly developed ones, might be inadvertently exposing user data. The `anon` key's ubiquity in client-side code makes it a prime target for attackers looking for a quick way to enumerate user information. This could lead to privacy violations, data breaches, and a loss of user trust. Developers must treat RLS as a critical security layer, not an optional configuration. Every policy needs to be scrutinized for edge cases, particularly those involving unauthenticated access and custom functions.