diff --git a/content/docs/auth/guides/plugins.md b/content/docs/auth/guides/plugins.md index 834cb55389..c5cb49cf0c 100644 --- a/content/docs/auth/guides/plugins.md +++ b/content/docs/auth/guides/plugins.md @@ -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' --- @@ -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: diff --git a/content/docs/auth/guides/plugins/jwt.md b/content/docs/auth/guides/plugins/jwt.md index 3cabab21e0..1c9d4c94c5 100644 --- a/content/docs/auth/guides/plugins/jwt.md +++ b/content/docs/auth/guides/plugins/jwt.md @@ -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' --- @@ -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", @@ -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": "", + "o": { + "id": "", + "slug": "acme-corp", + "role": "owner" + }, + "sub": "", + "exp": 1766321585, + "iss": "", + "aud": "" +} +``` + +`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). @@ -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 diff --git a/content/docs/auth/guides/plugins/organization.md b/content/docs/auth/guides/plugins/organization.md index 286f585f42..405b7444f6 100644 --- a/content/docs/auth/guides/plugins/organization.md +++ b/content/docs/auth/guides/plugins/organization.md @@ -6,7 +6,7 @@ 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' --- @@ -14,7 +14,7 @@ updatedOn: '2026-04-09T23:15:21.000Z' 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. -The Organization plugin is currently in **Beta**. Support for JWT token claims is under development. +The Organization plugin is currently in **Beta**. ## Why use this plugin? @@ -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: +// { ..., 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 +``` + + +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). + + +### 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. diff --git a/content/docs/data-api/access-control.md b/content/docs/data-api/access-control.md index b6ca51f3bb..951f95e5b8 100644 --- a/content/docs/data-api/access-control.md +++ b/content/docs/data-api/access-control.md @@ -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' --- @@ -17,6 +17,7 @@ updatedOn: '2026-04-18T12:27:58.000Z' Getting started with Data API Custom authentication providers Secure your app with RLS + Neon Auth Organizations @@ -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()) + ); +``` diff --git a/content/docs/extensions/pg_session_jwt.md b/content/docs/extensions/pg_session_jwt.md index e006b5c179..1838eb8920 100644 --- a/content/docs/extensions/pg_session_jwt.md +++ b/content/docs/extensions/pg_session_jwt.md @@ -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' --- @@ -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()