> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vibely.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Security best practices for Vibely apps

> How to write secure Vibely apps, covering the client bundle, Supabase Edge Functions, row-level security, secrets, authentication, and native builds.

This page covers **how to write secure code for a Vibely app** and avoid the most common mistakes while you build. For scans and findings, see the [Security view](/features/security/project-view) and the [Security center](/features/workspace/security-center).

<Frame>
  <img src="https://cdn.vibely.sh/doc/v1/security-best-practices.webp" alt="Security best practices" width="1200" height="675" />
</Frame>

A Vibely app is not one program. It is three tiers with a trust boundary between
them, and almost every real security mistake is a decision put on the wrong side
of that boundary.

## How a Vibely app is structured

| Tier | Runs where | Trust |
| - | - | - |
| Client bundle | The user's browser or phone | **None.** Public, inspectable, and fully under their control |
| Supabase Edge Functions | Supabase's servers | Trusted. This is the boundary |
| Postgres + row-level security | Supabase's database | Trusted, but only as far as your policies go |

The thing to internalise: **your `SUPABASE_ANON_KEY` ships in the bundle and is
public.** It is prefixed `VITE_` on web and `EXPO_PUBLIC_` on mobile precisely
because it is meant to be there. Anyone can read it out of your JavaScript or
out of your `.ipa`, point a REST client at your database with it, and start
issuing queries.

Row-level security is the only thing stopping a stranger from reading every row.
Not the fact that your UI never shows that table. Not the fact that your app
never issues that query. RLS.

## Never trust the client

Client-side validation is a **user experience feature**. It tells someone their
email is malformed before they wait on a round trip. It is not a security
control, because the person you are defending against is not using your UI.

Anything a user must not be able to do belongs in one of two places:

* **An RLS policy** — for "can this user read or write this row"
* **An Edge Function** — for "is this action allowed at all"

In particular, do not gate on a client-side role flag. `if (user.isAdmin)` hides
a button. It does not stop the request, because the request does not have to come
from your button.

## RLS on by default

New tables get RLS with a deny-all policy, and the agent then adds least-privilege
policies for the access patterns your app actually needs. Two rules make that
hold up:

<Warning>
  RLS and an `auth.uid()`-scoped policy go in the **same migration** as the
  `CREATE TABLE` — never as a later step. A table that exists for one migration
  without a policy is a table that was readable by the world for that window, and
  "I'll add policies at the end" is how it never happens.
</Warning>

Vibely re-checks after every table-creating migration and warns the agent about
tables left with RLS off or with a policy permissive enough to be no policy at
all. Read those warnings.

**Deny-all first, then least privilege.** Start from nothing allowed and add the
narrowest policy that makes a screen work. The reverse — open it up and tighten
later — leaves a window and usually never gets tightened.

**GRANTs are separate and required.** RLS filters rows; `GRANT` decides whether
the role may touch the table at all. Without the grant, every query fails
regardless of how correct your policies are. With the grant but no policy, every
query returns nothing. These fail differently and are worth telling apart.

### The four policy shapes

Almost everything is one of these.

**Owner-only** — the default for anything personal.

```sql theme={"system"}
alter table notes enable row level security;

create policy "owner reads own notes" on notes
  for select using (auth.uid() = user_id);

create policy "owner writes own notes" on notes
  for all using (auth.uid() = user_id)
  with check (auth.uid() = user_id);
```

The `with check` is not optional. `using` decides which rows you may act on;
`with check` decides what a row may look like after you write it. Without it a
user can update their own row and set `user_id` to someone else's.

**Team or organization** — membership lives in its own table, and the policy
asks it.

```sql theme={"system"}
create policy "members read team projects" on projects
  for select using (
    exists (
      select 1 from team_members
      where team_members.team_id = projects.team_id
        and team_members.user_id = auth.uid()
    )
  );
```

**Public read, owner write** — a blog, a product catalogue, anything with a
published state.

```sql theme={"system"}
create policy "anyone reads published posts" on posts
  for select using (published = true);

create policy "author manages own posts" on posts
  for all using (auth.uid() = author_id)
  with check (auth.uid() = author_id);
```

**Admin write** — gate on a role stored in the database, never on a claim the
client sent.

```sql theme={"system"}
create policy "admins write settings" on settings
  for all using (
    exists (
      select 1 from user_roles
      where user_roles.user_id = auth.uid()
        and user_roles.role = 'admin'
    )
  );
```

<Tip>
  Test a policy the way an attacker would: sign in as a second user and try to read
  the first user's rows directly, rather than checking that the UI hides them.
</Tip>

## Edge Functions are the trust boundary

Anything that must be true regardless of who is calling belongs server-side:

* **Authorization decisions** beyond what a row-level policy can express
* **Payments and webhooks** — verifying a Stripe signature, fulfilling an order
* **Third-party API calls that carry a secret key**
* **Anything that reads across users** — aggregates, admin views, exports

Secrets are read there with `Deno.env.get("MY_SECRET")` and never appear in a
file. If you see a literal key in a diff, that is a bug — report it.

## Where secrets go

One rule: **a secret key never goes in your app's source or its `.env` file.**
Store it as a project [secret](/features/backend/secrets). Vibely pushes it to your
Supabase Edge Function secrets, and an Edge Function reads it.

```text wrap theme={"system"}
Add my Resend API key securely and send the welcome email from an Edge Function.
```

The `VITE_`, `EXPO_PUBLIC_` and `NEXT_PUBLIC_` prefixes mean "this value is
compiled into the bundle and is public". Some keys are designed for that — a
Supabase anon key, a Stripe publishable `pk_…`, RevenueCat's per-store SDK keys.
Secret keys are refused that prefix. If you find yourself wanting to add it to
something called `..._SECRET_KEY`, that is the signal you need an Edge Function,
not a prefix.

See [Secrets](/features/backend/secrets) for the full breakdown. If a secret key
ever lands in your code or your chat, treat it as leaked: revoke it at the
provider, issue a new one, and store the new one as a secret.

## Workspace and project protection

Project visibility controls who can open the project **in the editor**. It is a
separate thing from who can load your published URL, which you set when you
[publish](/features/deploy/publish).

| Project access | Who can open the project in the editor |
| - | - |
| **Workspace** | Every member of the workspace |
| **Restricted** | You, and people you explicitly invite. Business plan |

Set a workspace-wide default in **Settings → Privacy & security** so new projects
inherit the right value instead of depending on whoever creates them. See
[Privacy & security](/features/workspace/privacy-security).

**Connectors are workspace-scoped, not project-scoped.** Connect Notion once and
every project in that workspace can use it — including projects you did not have
in mind when you authorized it. Scope the OAuth grant accordingly, and use
separate workspaces where you want separate blast radii. See
[Connectors](/integrations/connectors/overview).

## Native app security

Everything above applies to a mobile build too. These do not have a web analogue.

### The binary is readable

An `.ipa` or `.apk` is an archive. Anyone can download it from the store, unzip
it, and read your JavaScript bundle, your assets and your `EXPO_PUBLIC_*` values.
There is no obfuscation step that changes this and no "it's compiled" defence.

**Nothing secret belongs in an app binary.** Not a Stripe secret key, not a
service-role key, not a third-party API key with billing attached, not an
internal endpoint you were hoping nobody would find. Route all of it through an
Edge Function and let the app call that.

### Keep cleartext off

Both platforms block plaintext HTTP by default, and both let you turn it off
globally. Do not.

* **iOS** — no `NSAllowsArbitraryLoads` in your App Transport Security config
* **Android** — no `android:usesCleartextTraffic="true"`

If a single legacy host genuinely needs it, exempt that one domain rather than
disabling the policy for every request the app makes.

### Permissions and usage strings

Request the **minimum** permission set your app actually uses. A permission you
requested "just in case" is one more thing review will ask about and one more
thing a user sees at install.

Every iOS permission needs an honest `NS*UsageDescription` — a specific sentence
about what your app does with it, not a placeholder. A vague or missing string is
a common rejection; see [Ship to stores](/features/mobile-apps/ship).

### Do not change what review approved

An over-the-air EAS Update is for JavaScript and assets. It must never be used to
add a permission, change native config, or change what the store reviewed. That
needs a new build and a new review — see
[Ship to stores](/features/mobile-apps/ship). Shipping a
behaviour change over the top of an approved binary is both a guideline violation
and a good way to lose a developer account.

## Before you publish

<Steps>
  <Step title="Run a scan and clear the errors">
    Ask for a security scan in chat, or run a Deep scan from the Security view.
    Publishing starts a Basic scan in the background, but it does not block that
    publish. See [Security view](/features/security/project-view).
  </Step>

  <Step title="Confirm every table has RLS and a real policy">
    Enabled, and not permissive enough to be no policy. Check as a second user,
    not through your own UI.
  </Step>

  <Step title="Confirm no secret key sits outside Edge Function secrets">
    Search your code for `sk_`, `service_role`, and anything called `SECRET`.
    See [Secrets](/features/backend/secrets).
  </Step>

  <Step title="Confirm auth checks are server-side">
    Every rule you care about is in a policy or a function, not in a component.
  </Step>

  <Step title="Confirm project visibility is what you intended">
    And that it matches your published URL's access setting, which is separate.
    See [Publish](/features/deploy/publish).
  </Step>

  <Step title="Native only: review permissions and usage strings">
    Minimum set, honest descriptions, no cleartext exemption. See
    [Ship to stores](/features/mobile-apps/ship).
  </Step>
</Steps>

## Related

<CardGroup cols={2}>
  <Card title="Security view" icon="shield-check" href="/features/security/project-view">
    Scan a project and fix findings.
  </Card>

  <Card title="Secrets" icon="key" href="/features/backend/secrets">
    Where API keys belong.
  </Card>

  <Card title="Authentication" icon="lock" href="/features/backend/auth">
    Sign-in, sessions, and redirect URLs.
  </Card>

  <Card title="Supabase" icon="database" href="/integrations/supabase">
    The database and Edge Functions behind your app.
  </Card>
</CardGroup>


## Related topics

- [Project security view](/features/security/project-view.md)
- [Workspace security center](/features/workspace/security-center.md)
- [Publish your app as an MCP server](/features/grow/agent-integrations.md)
- [Privacy & security settings](/features/workspace/privacy-security.md)
- [FAQ](/faq.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.