# Profiles

> A profile row per user, created on sign-up from auth metadata, with a unique username, an email mirror and columns users can't change.

Source: https://bettersupabase.com/docs/blocks/profiles

The `profiles` [SQL module](/docs/blocks/sql) gives every user a profile
row. A trigger on `auth.users` creates it on sign-up from the user's
metadata, allocates a unique username and keeps the email in step with
`auth.users`. Users update their own name and avatar through the Data API,
while the email, the active organization and `disabled_at` stay with the
server.

```bash
pnpm better-supabase sql add profiles
```

## What sign-up fills in [#what-sign-up-fills-in]

`sync_profile(user_id)` runs after each insert into `auth.users`. When the
user has no profile yet, it inserts one:

| Column       | From                                                                                  |
| ------------ | ------------------------------------------------------------------------------------- |
| `full_name`  | metadata `full_name`, then `name`, then the first and last name joined                |
| `first_name` | metadata `first_name`, then `given_name`, then the first word of the full name        |
| `last_name`  | metadata `last_name`, then `family_name`, then the rest of the full name              |
| `avatar_url` | metadata `avatar_url`, then `picture`                                                 |
| `email`      | `auth.users.email`, and again after every change of address                           |
| `username`   | metadata `user_name`, `preferred_username` or `username`, else the email's local part |

`avatar_path` is never filled from metadata, and a `metadata` option that
names it is refused. It holds the Storage object path of an avatar the user
uploads (`<user id>/avatar.webp` in a public bucket), so a provider picture
in `avatar_url` never overwrites it. Users update it like their name, and
`update_my_profile` writes it. The Next.js example uploads to an `avatars`
bucket, saves the object path in `avatar_path` and shows `avatar_url` when
the user has no upload.

Usernames are lowercased, stripped to `a-z`, `0-9` and `_` (plus the `.`
or `-` of a `usernameFrom` separator), start with a letter, and get a number suffix while the name is reserved or another
profile has it (`ada`, then `ada1`). `allocate_username(base)` returns a
free one for a "pick a username" form. On a managed table, a check
constraint applies the same rules to names users pick themselves: the
length limits, the characters and `reservedUsernames` (`admin`, `api`,
`support`, `www` and similar by default).

After the module creates a profile, it calls your `after_profile_sync(user_id)`
[SQL hook](/docs/extending/events#sql-hooks) when it exists, to create rows
that hang off the profile. It doesn't call the hook for users who already
had a profile.

A failure while creating the profile, in the insert or in the hook, never
blocks the sign-up. The user gets an account without a profile, Postgres
logs a warning with the error, and `select better_supabase.backfill_profiles()`
as the service role creates the missing profiles once the cause is fixed.
Run it once after installing on a project that already has users.

## Who can change what [#who-can-change-what]

| Rule              | Default                                                                                                                                             |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| Read              | your own profile; with `readPolicy: 'members'`, the public columns of everyone who shares an organization with you                                  |
| Update            | your own row, only the columns in `updatable`                                                                                                       |
| Service columns   | `email`, `disabled_at`, the active organization and team, `created_at`; a trigger refuses changes from the API roles with `PROFILE_COLUMN_READONLY` |
| Insert and delete | the service role and `auth.users` (rows are deleted with their user)                                                                                |

With `readPolicy: 'members'`, `authenticated` gets `select` on the public
columns only, so select them by name instead of `*`. `email`, the active
organization and team and `onboarding` stay private: read your own through
`better_supabase.my_profile()`, which returns your full row as jsonb.
`update_my_profile(attrs)` updates the `updatable` columns present in
`attrs`. `createProfiles` from `better-supabase/blocks/profiles` calls both:

```ts
import { createProfiles } from "better-supabase/blocks/profiles";

const profiles = createProfiles({ transport });
const row = await profiles.mine().orThrow();
await profiles.updateMine({ full_name: "Ada Lovelace" }).orThrow();
```

Pass a Standard Schema as `fields` to type your own columns (from
`extraColumns`, or any column of an adopted table): `mine()` parses the row
with it and `updateMine` validates the columns it sets. `hooks` refuses,
rewrites or observes `mine` and `updateMine`. In SQL,
`before_profile_update(attrs jsonb, user_id uuid)` runs before every update
and can raise to refuse it, and `after_profile_update(user_id uuid)` runs
after a change. See [Extending blocks](/docs/extending/blocks).

```ts
const profiles = createProfiles({
  transport,
  fields: z.object({ locale: z.enum(["en", "nl"]) }),
});
const profile = await profiles.mine().orThrow();
profile?.locale; // "en" | "nl"
```

On a managed table the guard also sets `updated_at`. An adopted table keeps
its own `updated_at` trigger: the guard neither checks nor writes the column.

Security definer functions, such as `switch_organization` from the
[organizations](/docs/blocks/organizations) module and the email mirror, write the
service columns. To disable users through the profile, point the access
contract at its column:

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    access: { disabled: { user: "better_supabase.profiles.disabled_at" } },
    profiles: {},
  },
},
```

## Options [#options]

`sql.modules.profiles.options`:

| Option              | Default                                               | What it does                                                                                                     |
| ------------------- | ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `metadata`          | the keys in the table above                           | metadata key to column name; replaces the defaults                                                               |
| `splitName`         | `true`                                                | fills first and last name from a full name                                                                       |
| `username`          | `true`                                                | allocates a username on sign-up                                                                                  |
| `usernameFrom`      | `["user_name", "preferred_username", "username"]`     | metadata keys tried before the email, or `{ names, separator }` and `{ columns, separator }` entries (see below) |
| `usernameMinLength` | `3`                                                   | shorter names become `user`                                                                                      |
| `usernameMaxLength` | `32`                                                  | the longest name, suffix included                                                                                |
| `reservedUsernames` | `admin`, `api`, `support`, `www` and more             | names nobody gets; allocation adds a suffix                                                                      |
| `extraColumns`      | none                                                  | column name to SQL type on a managed table, such as `{ locale: "text not null default 'en'" }`                   |
| `updatable`         | name, avatar, username, onboarding and `extraColumns` | the columns users can update                                                                                     |
| `columnGrants`      | `true` when managed                                   | revokes `update` and grants it on `updatable` only                                                               |
| `serviceColumns`    | see above                                             | the columns only the service changes; `[]` drops the guard. `updated_at` is never guarded                        |
| `readPolicy`        | `self`                                                | `members` also shows profiles of people in the same organizations (needs `tenant`); see below                    |
| `syncTrigger`       | `true`                                                | `false` leaves sign-up to your own trigger, which calls `sync_profile`                                           |
| `mirrorEmail`       | `true`                                                | keeps `email` equal to `auth.users.email`                                                                        |

An entry of `usernameFrom` can join several metadata keys when all of them
are set, so a "first\_last" convention is a setting:

```ts
profiles: {
  options: {
    usernameFrom: [
      { names: ["first_name", "last_name"], separator: "_" },
      "user_name",
    ],
  },
},
```

Ada Lovelace becomes `ada_lovelace`; a user without a last name falls back to
`user_name`, then the email. `allocate_username` still lowercases, strips and
suffixes the result, and keeps a `.` or `-` separator, which the username
check then also accepts.

`{ columns, separator }` joins the values the profile's own columns get
instead, after `metadata` and `splitName`. A sign-up that only sends
`full_name: "Grace Hopper"` becomes `grace.hopper`:

```ts
profiles: {
  options: {
    usernameFrom: [{ columns: ["first_name", "last_name"], separator: "." }],
  },
},
```

`readPolicy` also takes `{ members, platform }`. `platform` is a permission
key that `is_platform()` checks, so platform staff read every profile, which
admin consoles need. It writes its own policy, `bs_profiles_platform_read`,
in managed and adopt mode, and pulls in the `access` module. Column grants
still apply, so the private columns stay behind them.

```ts
profiles: { options: { readPolicy: { members: true, platform: "platform.user.read" } } },
```

## Existing profiles [#existing-profiles]

With `mode: 'adopt'` the module writes its functions and triggers over your
table and leaves its columns, policies and grants alone. Map the key and
the columns you have, and set the others to `null`:

```ts title="better-supabase.config.ts"
sql: {
  modules: {
    profiles: {
      mode: "adopt",
      tables: { profiles: "public.profiles" },
      columns: {
        profiles: {
          key: "user_id",
          fullName: null,
          avatar: null,
          avatarPath: "avatar_path",
          activeTenant: "active_organization_id",
          onboarding: null,
        },
      },
      hooks: {
        schema: "public",
        functions: { after_profile_sync: "create_contact_profile" },
      },
      options: { syncTrigger: false, usernameMaxLength: 30 },
    },
  },
},
```

If a trigger of yours already creates profiles (`handle_new_user`),
installing warns about it. Keep it and set `syncTrigger: false`, calling
`better_supabase.sync_profile(new.id)` from it, or drop it and move its
extra work into `after_profile_sync`. With `mode: 'custom'` the module
writes nothing and you provide `sync_profile` and `backfill_profiles`.

An adopted table has no `avatarPath` column until you map one, so a table
without it keeps working.

Avatar and logo uploads use the [`avatarBucket` and `organizationLogoBucket`
presets](/docs/platform/storage#avatars-and-organization-logos).