better-push
Integrate your stack

Sessions

Wire better-push to your auth - better-auth, NextAuth, Clerk, or a custom JWT.

better-push never authenticates anyone. It asks your app one question per request - who is this? - through the session function:

session: (request: Request) => { userId: string } | null

Return null and the router answers 401. That is the whole contract, and it is why better-push works with any auth library: the userId you return is what lands in bp_device.user_id and what notify() targets.

It must be cheap

session runs on every request to a mounted endpoint, including each feed poll. Read a cookie or verify a token; do not go to the database twice.

better-auth

src/push.ts
import { betterPush } from "@better-push/core";
import { betterAuthSession } from "@better-push/core/auth/better-auth";
import { auth } from "@/auth";

export const push = betterPush({
  session: betterAuthSession(auth),
  // ...
});

That is the whole integration. It reads the request headers, so it covers both transports better-push clients use: the browser SDK sends a cookie and @better-push/core/native sends a bearer token.

A getSession that throws is logged and treated as unauthenticated. A session backend having a bad minute should answer 401 on the push endpoints, not turn every one of them into a 500.

mapUserId derives the id better-push stores, and rejects the session when it returns null:

betterAuthSession(auth, {
  mapUserId: (session) => `${session.user.id}`,
  onError: (error) => logger.warn({ error }, "push session lookup failed"),
});

better-auth is not a dependency of better-push. betterAuthSession needs auth.api.getSession({ headers }), which is an interface, not an import.

NextAuth

src/push.ts
import { auth } from "@/auth";

export const push = betterPush({
  session: async () => {
    const session = await auth();
    return session?.user?.id ? { userId: session.user.id } : null;
  },
  // ...
});

NextAuth v5's auth() reads the request from Next's async context, so the request argument goes unused. On v4 use getServerSession(authOptions) the same way.

Clerk

src/push.ts
import { getAuth } from "@clerk/nextjs/server";

export const push = betterPush({
  session: (request) => {
    const { userId } = getAuth(request as never);
    return userId ? { userId } : null;
  },
  // ...
});

getAuth expects Next's NextRequest, which is a Request with extra properties Clerk does not read here.

A custom JWT

src/push.ts
import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.JWT_SECRET!);

export const push = betterPush({
  session: async (request) => {
    const header = request.headers.get("authorization") ?? "";
    if (!header.startsWith("Bearer ")) return null;
    try {
      const { payload } = await jwtVerify(header.slice(7), secret);
      return typeof payload.sub === "string" ? { userId: payload.sub } : null;
    } catch {
      // An expired or forged token is not an error, it is anonymous.
      return null;
    }
  },
  // ...
});

The catch matters: a verification failure must answer 401, not 500. Every recipe on this page follows the same rule.

Multi-tenancy

userId is an opaque string. Prefix it to scope devices and feeds per tenant:

session: async (request) => {
  const session = await auth.api.getSession({ headers: request.headers });
  if (!session) return null;
  return { userId: `${session.session.activeOrganizationId}:${session.user.id}` };
},

Then notify() targets the same composite id. There is no separate tenant column, and no query that can forget to filter by one.

The studio is separate

session gates the user-facing endpoints. The studio has its own permissions callback with its own actor and its own audit trail - see Studio. Do not reuse one for the other: a user session says who someone is, and a studio permission says what an operator may look at.

On this page