Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions content/docs/auth/guides/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ summary: >-
and management through the Neon SDK without direct installation or
configuration by users.
enableTableOfContents: true
updatedOn: '2026-05-12T23:02:23.681Z'
updatedOn: '2026-05-27T14:28:53.887Z'
---

<FeatureBetaProps feature_name="Neon Auth with Better Auth" />
Expand All @@ -23,15 +23,15 @@ The following Better Auth plugins are currently supported in Neon Auth:

## Supported plugins

| Plugin | Status |
| ------------------------------------------------------ | ----------------------------------------------- |
| [Admin](/docs/auth/guides/plugins/admin) | ✅ Supported |
| [Email OTP](/docs/auth/guides/plugins/email-otp) | ✅ Supported |
| [JWT](/docs/auth/guides/plugins/jwt) | ✅ Supported |
| [Magic Link](/docs/auth/guides/plugins/magic-link) | ✅ Supported |
| [Organization](/docs/auth/guides/plugins/organization) | ⚠️ Partial (JWT token claims under development) |
| [Open API](/docs/auth/guides/plugins/openapi) | ✅ Supported |
| [Phone Number](/docs/auth/guides/plugins/phone-number) | ✅ Supported |
| Plugin | Status |
| ------------------------------------------------------ | ------------ |
| [Admin](/docs/auth/guides/plugins/admin) | ✅ Supported |
| [Email OTP](/docs/auth/guides/plugins/email-otp) | ✅ Supported |
| [JWT](/docs/auth/guides/plugins/jwt) | ✅ Supported |
| [Magic Link](/docs/auth/guides/plugins/magic-link) | ✅ Supported |
| [Organization](/docs/auth/guides/plugins/organization) | ✅ Supported |
| [Open API](/docs/auth/guides/plugins/openapi) | ✅ Supported |
| [Phone Number](/docs/auth/guides/plugins/phone-number) | ✅ Supported |

For more runnable Neon Auth samples, see the [neondatabase/neon-js](https://github.com/neondatabase/neon-js/tree/main/examples) examples repository:

Expand Down
34 changes: 31 additions & 3 deletions content/docs/auth/guides/plugins/jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ summary: >-
domains, while emphasizing that it does not replace session management for
standard web applications.
enableTableOfContents: true
updatedOn: '2026-02-15T20:51:54.038Z'
updatedOn: '2026-05-27T14:28:53.887Z'
---

<FeatureBetaProps feature_name="Neon Auth with Better Auth" />
Expand Down Expand Up @@ -76,7 +76,6 @@ A typical decoded JWT payload looks like this:
"name": "User Name",
"email": "user@email.com",
"emailVerified": false,
"image": null,
"createdAt": "2025-12-20T11:04:41.437Z",
"updatedAt": "2025-12-20T11:04:41.437Z",
"role": "authenticated",
Expand All @@ -91,6 +90,35 @@ A typical decoded JWT payload looks like this:
}
```

If the session has an active org (via [`setActive()`](/docs/auth/guides/plugins/organization#set-active-organization)), the payload also includes an `o` claim:

```json
{
"iat": 1766320685,
"name": "User Name",
"email": "user@email.com",
"emailVerified": false,
"createdAt": "2025-12-20T11:04:41.437Z",
"updatedAt": "2025-12-20T11:04:41.437Z",
"role": "authenticated",
"banned": false,
"banReason": null,
"banExpires": null,
"id": "<USER_ID>",
"o": {
"id": "<ORG_ID>",
"slug": "acme-corp",
"role": "owner"
},
"sub": "<USER_ID>",
"exp": 1766321585,
"iss": "<YOUR_NEON_AUTH_URL_ORIGIN>",
"aud": "<YOUR_NEON_AUTH_URL_ORIGIN>"
}
```

`o.role` is the member's role at token issuance time (`owner`, `admin`, or `member`). The claim is absent when no active organization is set. See [Organization context in JWTs](/docs/auth/guides/plugins/organization#organization-context-in-jwts) for the full flow.

## Verify a token

To verify the authenticity of a JWT, you need to validate its signature using the public keys provided by JWKS (JSON Web Key Set).
Expand Down Expand Up @@ -314,7 +342,7 @@ Because Neon Auth is a managed service, certain server-side configurations avail

- **Signing algorithm:** Neon Auth uses **EdDSA (Ed25519)** by default for high security and performance. Ensure your verification libraries support this algorithm.
- **Expiration:** Tokens expire in **15 minutes** (access tokens). You should implement logic to refresh the token using `authClient.token()` when it expires.
- **Custom claims:** Currently, the JWT payload contains the default user information. Custom claims are not supported at this time.
- **Custom claims:** Not supported. Neon Auth adds an `o` claim automatically when the session has an active org (see [Organization context in JWTs](/docs/auth/guides/plugins/organization#organization-context-in-jwts)).

## Troubleshooting

Expand Down
66 changes: 64 additions & 2 deletions content/docs/auth/guides/plugins/organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ summary: >-
including creating organizations, inviting members, and managing permissions
through the Organization plugin APIs.
enableTableOfContents: true
updatedOn: '2026-04-09T23:15:21.000Z'
updatedOn: '2026-05-27T23:29:49.973Z'
---

<FeatureBetaProps feature_name="Neon Auth with Better Auth" />

Neon Auth is built on [Better Auth](https://www.better-auth.com/) and comes with a pre-configured Organization plugin, so your app can support multi-tenancy without additional setup.

<Admonition type="note" title="Preview Feature">
The Organization plugin is currently in **Beta**. Support for JWT token claims is under development.
The Organization plugin is currently in **Beta**.
</Admonition>

## Why use this plugin?
Expand Down Expand Up @@ -239,6 +239,68 @@ const { data, error } = await authClient.organization.setActive({
});
```

### Organization context in JWTs

With an active organization set, the JWT includes an `o` claim containing the org ID, slug, and the member's role. Downstream services can authorize requests without an extra API call.

```ts
// 1. Set the active organization for the session
await authClient.organization.setActive({ organizationId: 'org_12345678' });

// 2. Fetch a JWT — it will now include the o claim
const { data } = await authClient.token();
// data.token is a JWT containing:
// { ...<user fields>, o: { id: "org_12345678", slug: "acme-corp", role: "owner" } }
```

**The `o` claim**

| Field | Type | Description |
| :----- | :----- | :------------------------------------------------------------- |
| `id` | string | Organization ID |
| `slug` | string | URL-friendly org identifier |
| `role` | string | Member's role at token issuance: `owner`, `admin`, or `member` |

**Clearing org context**

Pass `organizationId: null` to remove the active org from the session. The next `token()` call returns a JWT without the `o` claim.

```ts
await authClient.organization.setActive({ organizationId: null });
const { data } = await authClient.token();
// data.token JWT has no o claim
```

<Admonition type="warning" title="Role staleness">
The `o.role` value is fixed at issuance. If the member's role changes, the JWT reflects the old role until expiry. For sensitive operations, verify the current role server-side with [`organization.getActiveMember()`](#get-active-member).
</Admonition>

### Using the org claim for RLS

When you use the [Neon Data API](/docs/data-api/overview), the `o` claim is available inside `auth.jwt()`. Create a helper function to extract it for use in RLS policies:

```sql
CREATE OR REPLACE FUNCTION public.jwt_organization()
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT auth.jwt() -> 'o';
$$;
```

Use the helper in policies to scope rows to the active org:

```sql
CREATE POLICY "Org members can view team data" ON public.items
FOR SELECT TO authenticated
USING (organization_id = public.jwt_organization() ->> 'id');
```

For the complete multi-tenant RLS pattern (including personal rows alongside org-scoped rows), see [Multi-tenant access with organizations](/docs/data-api/access-control#multi-tenant-access-with-organizations).

### Get active organization

Retrieves full details of the currently active organization.
Expand Down
76 changes: 75 additions & 1 deletion content/docs/data-api/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ summary: >-
uses PostgreSQL's security model to enforce role privileges and Row-Level
Security for database access control.
enableTableOfContents: true
updatedOn: '2026-04-18T12:27:58.000Z'
updatedOn: '2026-05-27T23:29:49.973Z'
---

<FeatureBetaProps feature_name="Neon Data API" />
Expand All @@ -17,6 +17,7 @@ updatedOn: '2026-04-18T12:27:58.000Z'
<a href="/docs/data-api/get-started">Getting started with Data API</a>
<a href="/docs/data-api/custom-authentication-providers">Custom authentication providers</a>
<a href="/docs/guides/rls-tutorial">Secure your app with RLS</a>
<a href="/docs/auth/guides/plugins/organization">Neon Auth Organizations</a>
</DocsList>
</InfoBlock>

Expand Down Expand Up @@ -153,3 +154,76 @@ CREATE TABLE posts (
```

Now, even though the `authenticated` role has `SELECT` permission on the table, the database will only return rows where the `user_id` column matches the ID in the user's token.

## Multi-tenant access with organizations

When using [Neon Auth Organizations](/docs/auth/guides/plugins/organization), the JWT includes an `o` claim containing the active org ID, slug, and member role. You can use this claim in RLS policies to scope rows to an organization.

The active org is set client-side with [`authClient.organization.setActive()`](/docs/auth/guides/plugins/organization#set-active-organization). After `setActive()`, the next JWT carries the `o` claim. The Data API enforces org scope via RLS.

### Table schema

Add an `organization_id` column to tables that need org-scoped access:

```sql
CREATE TABLE public.items (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id text NOT NULL,
organization_id text, -- NULL = personal row; org ID = team row
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);

ALTER TABLE public.items ENABLE ROW LEVEL SECURITY;
```

### Helper function

Create a helper that extracts the `o` claim from the current JWT:

```sql
CREATE OR REPLACE FUNCTION public.jwt_organization()
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT auth.jwt() -> 'o';
$$;
```

### RLS policies

These policies allow two kinds of rows: org-scoped team data (matched by `o.id`) and personal rows (`organization_id IS NULL`, owned by the user):

```sql
-- jwt_organization() returns NULL when no org is active;
-- the IS NOT NULL guard prevents false matches against real org IDs
CREATE POLICY "Authenticated users can view items" ON public.items
FOR SELECT TO authenticated USING (
(organization_id IS NOT NULL AND organization_id = public.jwt_organization() ->> 'id')
OR (organization_id IS NULL AND user_id = auth.user_id())
);

CREATE POLICY "Authenticated users can create items" ON public.items
FOR INSERT TO authenticated WITH CHECK (
user_id = auth.user_id()
AND (
(organization_id IS NOT NULL AND organization_id = public.jwt_organization() ->> 'id')
OR organization_id IS NULL
)
);

CREATE POLICY "Authenticated users can update items" ON public.items
FOR UPDATE TO authenticated USING (
(organization_id IS NOT NULL AND organization_id = public.jwt_organization() ->> 'id')
OR (organization_id IS NULL AND user_id = auth.user_id())
);

CREATE POLICY "Authenticated users can delete items" ON public.items
FOR DELETE TO authenticated USING (
(organization_id IS NOT NULL AND organization_id = public.jwt_organization() ->> 'id')
OR (organization_id IS NULL AND user_id = auth.user_id())
);
```
14 changes: 12 additions & 2 deletions content/docs/extensions/pg_session_jwt.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ summary: >-
authenticated sessions using JSON Web Tokens (JWTs), including JWK validation
and PostgREST compatibility for secure user identity handling.
enableTableOfContents: true
updatedOn: '2026-05-15T10:22:57.192Z'
updatedOn: '2026-05-27T23:29:49.973Z'
---

<InfoBlock>
Expand Down Expand Up @@ -81,7 +81,17 @@ SELECT auth.session();

### auth.jwt()

Alias for `auth.session()`.
Alias for `auth.session()`. When using [Neon Auth Organizations](/docs/auth/guides/plugins/organization), the payload includes an `o` claim for the active org. Extract it directly in RLS:

```sql
-- Full org object (jsonb): {"id": "org_123", "slug": "acme", "role": "owner"} or NULL
SELECT auth.jwt() -> 'o';

-- Org ID as text, for policy comparisons
SELECT auth.jwt() -> 'o' ->> 'id';
```

See [Multi-tenant access with organizations](/docs/data-api/access-control#multi-tenant-access-with-organizations) for the complete RLS pattern.

### auth.uid()

Expand Down