Two different questions, answered by two different parts of the SDK.
**Authentication** is `identity` — who is this, and is their session still
valid. **Authorization** is `access` and `entitlements` — what that person
may do on your application, and what their workspace is paying for. Every
gate in this SDK, server or client, is one of those two questions.

<Callout type="info" title="Already wired up?">
  [Getting started](/docs/getting-started) mounts `@cosmoops/connect-sdk/next`
  and you get the whole round trip below for free — `/signin`, `/callback`,
  the session cookie, `getConnectSession()`. Read this page when you need to
  see what's happening underneath: rolling the flow by hand on `/server`,
  adding `proxy.ts`, or building your own role/entitlement checks.
</Callout>

## Authentication: the OAuth2 round trip

`identity` is five calls: `authorizeUrl` to send the user out,
`exchangeCode` when they come back, `verifyIdToken` once, `getUser` on every
later request, `logout` at the end.

<Tabs items={["Next.js plug-and-play", "Any other framework"]}>
<Tab>

`createConnectHandlers` runs all five for you once `session.write` is
configured — which is exactly what happens when you export the
zero-argument `GET`/`POST`. `/api/connect/signin` calls `authorizeUrl`,
`/api/connect/callback` calls `exchangeCode` and `verifyIdToken`, and
`getConnectSession()` calls `getUser` against the cookie the callback wrote.

```ts title="app/api/connect/[...connect]/route.ts"
export { GET, POST } from "@cosmoops/connect-sdk/next";
```

```tsx title="Anywhere in your app"
import { requireConnectSession } from "@cosmoops/connect-sdk/next";

const session = await requireConnectSession(); // UserResponse — redirects if signed out
```

Send someone through it: link or redirect to
`/api/connect/signin?next=/dashboard` (registration instead of sign-in:
`?prompt=create`). Sign them out: `POST /api/connect/logout`, or
`<ConnectSignOutButton />` / `useConnect().signOut()` from `/client`.

</Tab>
<Tab>

<Steps>

<Step>

### 1. Send the user out

```ts
import { createState } from "@cosmoops/connect-sdk/server";

const state = createState();
await saveToSession({ state });

redirect(connect.identity.authorizeUrl({
  redirectUri: "https://you.example/callback",
  state,
  // prompt: "create"  ← opens registration instead of sign-in
}));
```

`state` has to be fresh and random per request, and has to round-trip
through Connect unchanged — check it matches on the way back before you
trust anything else in the callback.

</Step>

<Step>

### 2. Handle the callback

```ts
if (query.state !== savedState) throw new Error("state mismatch");

if (query.error === "login_required") return retrySignIn(); // normal — not an error
if (query.error) return showCancelled();

const session = await connect.identity.exchangeCode({
  code: query.code,
  redirectUri: "https://you.example/callback", // byte-identical to step 1
});
```

`redirectUri` must match step 1 exactly — Connect checks it, the same way
any OAuth2 authorization server does.

</Step>

<Step>

### 3. Verify the ID token, once

```ts
await connect.identity.verifyIdToken(session.idToken);

await saveToSession({ sessionToken: session.sessionToken });
```

HS256 over `header.payload`, keyed with your API key — there's no JWKS
fetch, because Connect stores only your key's hash, so this checks the
signature *and* `exp`/`aud` in one call. Verify it once here; everything
after this is `sessionToken`, an opaque string your session has to hold —
never the ID token itself.

</Step>

<Step>

### 4. On every later request

```ts
const { user, role, permissions, sessionExpiresAt } = await connect.identity.getUser(sessionToken);
```

Live, not cached from step 3 — a role change or a lapsed subscription shows
up here without another sign-in. `sessionExpiresAt` is ISO 8601; refresh
proactively off it rather than waiting for a 401. Any `ConnectError` with
`isSessionExpired` means send them back through step 1.

</Step>

<Step>

### 5. Sign out

```ts
await connect.identity.logout(sessionToken);
```

Always succeeds — an unknown or already-revoked token is an indistinguishable
no-op. `scope` defaults to `"device"` (this sign-in, every other application on
the same browser, Connect's own session on that device); `"all"` is an
explicit "sign out everywhere". Discard your own copy of `sessionToken` too —
`logout` doesn't do that for you.

</Step>

</Steps>

</Tab>
</Tabs>

### One Tap: recognise a visitor without a redirect

A visitor already signed into Connect shouldn't have to press "Sign in" again,
and shouldn't have to watch a page navigate away to find out. Mount the prompt
and you're done:

```tsx
<ConnectProvider>
  <App />
  <ConnectOneTap />
</ConnectProvider>
```

No `clientId` in the browser, no configuration. The bridge already knows the
client and tells the page where to ask on the `/session` read it was making
anyway, so discovering One Tap costs no extra request.

They see a small card naming the account they'd be signing in as. One tap and
they're in — **no navigation at any point**. A visitor Connect doesn't
recognise sees nothing at all, ever, which is why this is safe to mount
unconditionally.

<Callout type="info" title="Your app must be on a cosmoops.com subdomain">
  `SameSite` is judged on the registrable domain, so the visitor's Connect
  session cookie rides along on a request from `crm.cosmoops.com` to
  `connect.cosmoops.com` — and from nowhere else.

  This is the whole reason One Tap works in **every** browser: no third-party
  cookie is involved at any point, so there is nothing for Safari's ITP or
  Firefox's ETP to block, and no need for FedCM (which is Chromium-only).
  Deploy off-site and the prompt simply never appears — nothing breaks, and
  `ConnectLoginButton` behaves exactly as before.
</Callout>

#### What the visitor is shown, and when

| They are | The prompt says | The tap does |
|---|---|---|
| Not signed into Connect | *nothing at all* | — |
| Signed in, first time here | "You're signed in to CosmoOps" | Connect's consent screen, once |
| Signed in and already authorised you | "Continue as Priya Nair", with email, workspace and photo | Signs them in, in place |

**Connect releases no identifying detail until consent exists.** Not a name,
not an email. So the first prompt your application ever shows a given visitor
is necessarily the impersonal one, and the tap that follows leads to the real
consent screen. Every visit after that is the personal one.

#### Dismissal is remembered

Closing the prompt buys 2 hours of quiet, closing it again a day, and again a
week. Signing out of your application buys a day without spending a dismissal —
signing out isn't a complaint about the prompt. All of it lives in your own
origin's `localStorage` and none of it leaves the browser.

#### Drawing it yourself

`useConnect()` exposes the two calls the component is built from:

```tsx
const { readOneTapHint, oneTapSignIn } = useConnect();
```

`readOneTapHint()` resolves to `null` when there's nothing to offer — including
when Connect can't be reached, because an unreachable Connect says nothing
about whether anyone is signed into it. `oneTapSignIn()` reports expected
outcomes as values rather than throwing; `consent_required` and
`interaction_required` both mean "hand off to the interactive screen", which is
a hand-off, not an error to show anybody.

<Callout type="info" title="The code that crosses the browser is PKCE-bound">
  Connect hands the authorization code to the *page*, so unlike the redirect
  flow the code alone must never be redeemable. The bridge mints the PKCE pair
  and keeps the verifier in an `HttpOnly` cookie on your own origin; the page
  only ever holds the code, which is worthless without it. A code lifted out of
  the browser, or replayed cross-site, buys nothing.
</Callout>

### Silent sign-in: the redirect-based predecessor

<Callout type="warning" title="Prefer One Tap">
  `silentSignIn` answers the same question and still works, unchanged. But it
  spends a full-page redirect on the first load of **every** signed-out visit,
  shows the visitor nothing while it happens, and gives them no way to decline.
  Reach for it only where One Tap can't run — an application deployed off
  `cosmoops.com`.
</Callout>

### Sign returning visitors in with no click

A visitor already signed into Connect shouldn't have to press "Sign in"
again — OIDC calls this silent authentication. On `/next` it's one prop:

```tsx
<ConnectProvider silentSignIn>
```

On the first load of a signed-out visit, the browser makes one round trip to
Connect with `prompt=none`. Signed in **and** already authorised your app →
they come back signed in, having seen nothing. Either not true → they come
back signed out, no error, your normal signed-out UI a beat later. Rolling
it by hand is the same flow with `prompt: "none"` on `authorizeUrl`.

<Callout type="warning" title="Three ways to get this wrong">
  - **`prompt=none` never shows a login screen — that's its contract.** Use
    it to *resume* a session, never to start one. Sending a genuinely
    signed-out visitor through it in a loop is the classic redirect loop.
  - **A `login_required` error from a silent attempt isn't the one in step
    2.** Retrying interactively is right for a real sign-in and wrong here —
    it escalates a quiet "nobody home" into a forced login. Track which kind
    of attempt is in flight.
  - **Attempt it at most once per tab.** A visitor with no Connect session
    answers the same way on every load; without a marker, every navigation
    pays a full redirect. `/next` tracks this in `sessionStorage` for you.
</Callout>

First contact still shows Connect's consent screen once — that grant is what
makes every later visit silent, and it's per user, per client, so
`consent_required` means "they've never authorised you", not "something
broke". Treat `login_required`, `consent_required` and `interaction_required`
alike: carry on signed out.

It costs a full-page redirect on that first load, which is why it's off by
default — worth it for an app shell, less so for a marketing page.

## Protecting routes before they render: `proxy.ts`

Next 16 renamed Middleware to **Proxy** — a single `proxy.ts` at the project
root (or in `src/`, beside `app/`). Nothing in this SDK writes one for you,
and nothing needs to.

<Steps>

<Step>

### 1. Decide whether you need one

Only if you want an unauthenticated visitor bounced *before* a page starts
rendering — a `/dashboard` that should never even begin streaming for a
signed-out visitor. If redirecting from inside the page is fine,
`getConnectSession()` above already does that and there's nothing to add.

</Step>

<Step>

### 2. Add the cookie check

Keep it to a **presence** check — nothing more:

```ts title="proxy.ts"
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const COOKIE = process.env.CONNECT_SESSION_COOKIE ?? "connect_session";

export function proxy(request: NextRequest) {
  if (request.cookies.has(COOKIE)) return NextResponse.next();

  const signin = new URL("/api/connect/signin", request.url);
  signin.searchParams.set("next", request.nextUrl.pathname);
  return NextResponse.redirect(signin);
}
```

`COOKIE` has to match what the bridge actually writes — the default
`connect_session`, or whatever you set `CONNECT_SESSION_COOKIE` to (or
passed as a custom `session` store to `createConnectHandlers`). Nothing
checks the two agree; get this wrong and every visitor bounces even when
signed in. `/api/connect/signin` is also wherever you mounted the bridge —
change it to match a custom `basePath` (see `createConnectHandlers`'
[reference](/docs/reference/next)).

</Step>

<Step>

### 3. Scope the matcher to the routes that need it

```ts title="proxy.ts"
// Must not match /api/connect/* — the sign-in route has no session cookie
// yet, so matching it would redirect the sign-in to itself.
export const config = { matcher: ["/dashboard/:path*", "/settings/:path*"] };
```

List the paths that actually require sign-in, not a catch-all. A broad
matcher (`"/:path*"`, or anything wider than it needs to be) will eventually
catch the bridge's own routes — the sign-in redirect matching itself is an
immediate redirect loop, and it also means the loading/marketing pages ahead
of sign-in pay a cookie check they don't need.

</Step>

<Step>

### 4. Confirm the redirect actually happens

Signed out, hitting a matched route → a 307 to
`/api/connect/signin?next=<the path you were on>` → sign in → land back
where you started. Signed in → the same route passes straight through, no
extra request. An **expired or revoked** session cookie also passes
through unchanged — that's expected, not a bug still to fix; see below for
why.

</Step>

</Steps>

<Callout type="warning" title="A cookie is not a session, and this check is not authorization">
  The cookie holds an opaque `sessionToken`; deciding whether it's still
  valid means asking Connect, and proxy runs on *every* matched request — so
  a real check here puts a network round trip in front of each one. An
  expired or revoked token still arrives as a cookie and still passes this
  check. That's also [Next's own
  guidance](https://nextjs.org/docs/app/guides/authentication#optimistic-checks-with-proxy-optional):
  proxy is for optimistic checks, "not … a full session management or
  authorization solution."
</Callout>

Treat `proxy.ts` purely as a way to skip rendering a page the visitor
plainly can't use. The authoritative check is downstream — `getConnectSession()`
in the Server Component, or `identity.getUser()`/`entitlements.*` on your
server — which is exactly what the rest of this page covers.

## Authorization: what a signed-in member may do

Two independent axes, both re-resolved live on every check — never cached
from sign-in:

- **Roles** — what *this member* may do on *your* application. Scoped to your
  `client_id`, not their role in the rest of their workspace.
- **Entitlements** — what *the workspace* is paying for. A subscription,
  KYC/KYB, a chargeable payment method, seat headroom, plan features.

### Roles & permissions

`identity.getUser()` already returns `role` and `permissions` — for most
checks, that's the only call you need:

```ts
const { user, role, permissions } = await connect.identity.getUser(sessionToken);

if (!permissions.includes("invoice:write")) return forbidden();
```

`role` is the resolved role key, `null` when their granted permissions match
no single defined role — including when they have no access at all. If you already have a `userId` and only need the role axis
re-checked (no full session read), [`access.getUserRoles`](/docs/reference/server#interface-accessresource)
does the same resolution directly. [`access.listRoles`](/docs/reference/server#interface-accessresource)
returns your application's own permission catalog and role definitions — the
catalog `getUserRoles` resolves against, useful for building your own
role-picker UI in an admin screen.

### Connect platform staff

Connect's own operators — `platform_admin`, `platform_manager`, `developer` —
can sign into your application, so an operator console can live inside your
product and be authenticated by Connect rather than by a second login you
maintain. `staff` is how you tell:

```ts
const { user, role, permissions, staff } = await connect.identity.getUser(sessionToken);

if (staff) {
  if (role !== "platform_admin") return forbidden();
  return renderOperatorConsole();
}
```

Read `staff` **before** `role` or `permissions`. It is what decides whose
vocabulary those two are speaking: your own Permission Catalog's ids for a
member, Connect's `platform:*` ids for an operator.

**A staff session is not a customer session, and is shaped differently.**
These accounts belong to no workspace — Connect enforces that, they can
neither own nor join one — so:

| Field | On a staff session |
| --- | --- |
| `user.workspaceId` / `workspaceName` / `workspaceType` | `null` |
| `subscription` | `null` |
| `staff` | `true` |
| `role` | the highest platform role they hold **that you accept** |
| `permissions` | Connect `platform:*` ids, not yours |

Do not read workspace data on a staff session — there is no tenant to read,
and the workspace-scoped bridge routes (`/entitlements`, `/integrations`,
`/data/*`, …) answer `403 no_workspace` for exactly that reason.

`role` is drawn from the intersection of what the account holds and what your
application accepts — never Connect's full list for that person.

Staff access is **off by default and per-application**. Ask a Connect platform
admin to enable it on your catalogue entry, naming the roles you accept; until
then `staff` is always `false`. Re-read it live: a revoked role, or staff
access switched off, comes back `staff: false` from `identity.getUser()` *and*
Connect revokes the session on that read, so a console trusting a login-time
copy keeps serving someone already ejected.

On the browser side, `<ConnectPlatformGate>` renders a subtree only for a
staff session and `ConnectNavItem.audience` (`"staff"` / `"member"`) splits
the `AppShell` rail — both UX only, like every gate on `/client`. The server
check above is the boundary.

### Entitlements: can this workspace use my application at all

```ts
import { blockingBlockers } from "@cosmoops/connect-sdk"; // isomorphic

const readiness = await connect.entitlements.getReadiness(workspaceId);

if (!readiness.ready) {
  const [blocker] = blockingBlockers(readiness);
  return showBlocked(blocker.message, blocker.resolveUrl); // resolveUrl → the Connect screen that fixes it
}

if (await connect.entitlements.hasFeature(workspaceId, "campaign-module")) {
  // plan-gated module
}
```

[`getReadiness`](/docs/reference/server#interface-entitlementsresource) is
every gate Connect owns between a workspace and your application, resolved
in one call — subscription, verification, payment method, seats. Each
blocker carries a `code` (branch on this, never `message` — the list of
codes grows, and treating an unrecognised one as blocking is the safe
default) and a `resolveUrl` into the Connect screen that fixes it, so a
blocked user has somewhere to go instead of a dead end.
[`hasFeature`](/docs/reference/server#interface-entitlementsresource) is the
narrower check for a single plan-gated feature — deliberately `false`
rather than a throw when there's no subscription at all, so a feature gate
never needs a `try`/`catch`.

### Client-side UX gates

`/client` mirrors both checks in components, for the browser — but they are
**UX gates, not security boundaries**. Everything they read reaches your own
origin, and a browser can ignore any of it. The authoritative check is
always the server calls above, before you do the thing the gate is guarding.

[`ConnectAccessGate`](/docs/reference/client#function-connectaccessgate) is
the one to mount if you mount one — wrap your app's content once (not your
header) and it composes entitlement state into a single priority-ordered
decision:

| State | What renders |
| --- | --- |
| `suspended` | A hard stop — only a Connect admin reactivating the workspace clears it. |
| `no_subscription` | "Choose a plan", linking to Connect's subscribe page. |
| `onboarding_pending` | Waiting on a reviewer. No action offered, because there is none. |
| `blocked` | Any other blocking readiness code — verification, audience, seats. |
| `payment_retry` | **Your app still renders.** A dismissible banner counts down to the dunning deadline. |
| `clear` | Your app, nothing else. |

Signed-out renders `children`, deliberately — a gate around your whole app
must not hide a public landing page. Carve out your own plans/pricing route,
or the `no_subscription` card traps visitors it just sent there:

```tsx
const EXEMPT = new Set(["/plans", "/pricing"]);
const gated = !EXEMPT.has(usePathname());

<main>{gated ? <ConnectAccessGate>{children}</ConnectAccessGate> : children}</main>
```

For per-state UI instead of the default card, call
[`useConnectAccessGate()`](/docs/reference/client#function-useconnectaccessgate)
directly. Narrower gates exist for gating *within* an already-cleared
workspace — [`ConnectFeatureGate`](/docs/reference/client#function-connectfeaturegate)
on a single plan feature, [`ConnectSubscriptionGate`](/docs/reference/client#function-connectsubscriptiongate)
on having any subscription at all. All of them render `loading` — never the
fallback — while the answer is unknown, so a paying customer is never
flashed an upsell for something they already have.

## Wiring checks into your app: no page-wrapping HOCs

The obvious shape for all of the above is a `withAuth(Page)` HOC, or a
`<RequireRole role="admin">` wrapper around a whole page — one place that
decides, everything else just renders. **Don't build that.** [Next's own App
Router guidance](https://nextjs.org/docs/app/guides/authentication) is
explicit that a single wrapping layer isn't a safe boundary, for a structural
reason that has nothing to do with this SDK: a Next.js route has more than
one entry point. Parallel and intercepted route slots, nested layouts,
Route Handlers and Server Actions can all be reached independently of the
page component a HOC would wrap — so a check that only runs when that one
component renders doesn't run for any of them. The same reasoning rules out
putting the check in a layout instead: layouts don't re-render on every
navigation under Partial Rendering, so a check there can go stale, and a
layout doesn't control whether the route segments beneath it render anyway.

<Callout type="warning" title="Client-side gates are the same shape, and the same limit applies">
  `ConnectAccessGate` and friends are components you wrap around children —
  reasonable for UX, exactly as risky as a HOC if you were to treat them as
  the authorization boundary. That's the whole reason [the section
  above](#client-side-ux-gates) calls them out explicitly: client-side UI
  restrictions alone are never sufficient, because the Server Action or
  Route Handler behind that UI is still reachable directly.
</Callout>

What Next recommends instead is a **Data Access Layer**: plain functions, no
wrapping, called inline at every entry point that needs them — memoized per
request so calling one from a layout and again from the page beneath it
isn't two round trips. `requireConnectSession()` already *is* that function
for plain authentication — memoized, redirects on its own, nothing to write:

```tsx title="app/dashboard/page.tsx — a page"
import { requireConnectSession } from "@cosmoops/connect-sdk/next";

export default async function Dashboard() {
  await requireConnectSession();
  // ...
}
```

Role checks are the one piece that's genuinely yours — the permission
strings and where an unauthorized visitor lands are decisions only your app
can make — but with `requireConnectSession()` handling the session half,
the whole DAL is one small function:

```ts title="app/lib/dal.ts"
import { requireConnectSession } from "@cosmoops/connect-sdk/next";
import { redirect } from "next/navigation";

export async function requireRole(permission: string) {
  const session = await requireConnectSession();
  if (!session.permissions.includes(permission)) redirect("/forbidden");
  return session;
}
```

Call it plainly, wherever the check actually belongs — not through a
wrapper, just a function call at the top of whatever needs it:

```tsx title="A leaf component, rendered conditionally"
import { requireConnectSession } from "@cosmoops/connect-sdk/next";

export async function AdminActions() {
  const session = await requireConnectSession();
  if (!session.permissions.includes("admin:write")) return null;
  // ...
}
```

```ts title="A Server Action"
"use server";
import { requireRole } from "@/lib/dal";

export async function deleteInvoice(id: string) {
  await requireRole("invoice:delete");
  // ...
}
```

Same functions, several call sites, none of them a wrapper — which is the
point: a Server Action reached directly (not through the page that happens
to render a button for it) still runs its own check first, because the
check lives with the action, not with a component that merely called it.

## Putting the whole request together

| Layer | Answers | Runs | Authoritative? |
| --- | --- | --- | --- |
| `proxy.ts` | Is there a session cookie at all? | Edge, before the page renders | No — presence only |
| `getConnectSession()` / `identity.getUser()` | Who is this, and is the session still valid, right now? | Server Component / Route Handler | **Yes** |
| `permissions` / `access.getUserRoles()` | What may this member do on my application? | Server | **Yes** |
| `staff` / `role` | Is this one of Connect's own operators, and which kind? | Server | **Yes** |
| `entitlements.getReadiness()` / `hasFeature()` | Is the org allowed to use this at all, and does its plan include this feature? | Server | **Yes** |
| `ConnectAccessGate` / feature & subscription gates | Same entitlement questions, rendered for UX | Browser | No — mirrors the server checks, never replaces them |

Each layer is cheaper and less certain than the one below it. `proxy.ts`
skips rendering a page a visitor plainly can't use; everything that decides
whether they actually can runs on your server.

## Next steps

- **[Reference](/docs/reference)** — full signatures for `identity`,
  `access`, `entitlements`, and every gate on `/client`.
- **[Getting started](/docs/getting-started)** — the two-file plug-and-play
  setup this page assumes.