Authentication & authorization
The full OAuth2 round trip, proxy.ts route protection, and how role- and plan-based access control layer on top.
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.
Already wired up?
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.
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.
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.
export { GET, POST } from "@cosmoops/connect-sdk/next";import { requireConnectSession } from "@cosmoops/connect-sdk/next";
const session = await requireConnectSession(); // UserResponse — redirects if signed outSend 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.
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:
<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.
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.
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:
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.
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.
Silent sign-in: the redirect-based predecessor
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.
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:
<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.
Three ways to get this wrong
prompt=nonenever 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_requirederror 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.
/nexttracks this insessionStoragefor you.
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.
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.
2. Add the cookie check
Keep it to a presence check — nothing more:
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).
3. Scope the matcher to the routes that need it
// 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.
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.
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:
proxy is for optimistic checks, "not … a full session management or
authorization solution."
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:
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
does the same resolution directly. access.listRoles
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:
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
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 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 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 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:
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()
directly. Narrower gates exist for gating within an already-cleared
workspace — ConnectFeatureGate
on a single plan feature, 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 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.
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 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.
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:
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:
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:
import { requireConnectSession } from "@cosmoops/connect-sdk/next";
export async function AdminActions() {
const session = await requireConnectSession();
if (!session.permissions.includes("admin:write")) return null;
// ...
}"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 — full signatures for
identity,access,entitlements, and every gate on/client. - Getting started — the two-file plug-and-play setup this page assumes.