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 } | nullReturn 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
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
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
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
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.