Skip to content

Commit 5f8a5f8

Browse files
committed
fix(security): project Better Auth session payload
1 parent e66236e commit 5f8a5f8

3 files changed

Lines changed: 212 additions & 3 deletions

File tree

apps/api/src/auth.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { expo } from "@better-auth/expo";
22
import { i18n } from "@better-auth/i18n";
33
import { passkey } from "@better-auth/passkey";
4-
import { betterAuth } from "better-auth";
4+
import { type BetterAuthOptions, betterAuth } from "better-auth";
55
import { drizzleAdapter } from "better-auth/adapters/drizzle";
6-
import { admin, emailOTP, genericOAuth, twoFactor } from "better-auth/plugins";
6+
import { admin, customSession, emailOTP, genericOAuth, twoFactor } from "better-auth/plugins";
77
import { emailHarmony } from "better-auth-harmony";
88
import { eq } from "drizzle-orm";
99
import { db } from "./db";
@@ -17,6 +17,7 @@ import {
1717
verifyEmailEmail,
1818
} from "./utils/emailTemplates";
1919
import { envString } from "./utils/env";
20+
import { projectSessionPayload } from "./utils/session-projection";
2021

2122
const secret = process.env.BETTER_AUTH_SECRET;
2223
if (!secret) throw new Error("BETTER_AUTH_SECRET env var is required");
@@ -43,7 +44,7 @@ async function fetchProviderImage(
4344
return undefined;
4445
}
4546

46-
export const auth = betterAuth({
47+
const authOptions = {
4748
database: drizzleAdapter(db, {
4849
provider: "pg",
4950
}),
@@ -319,4 +320,25 @@ export const auth = betterAuth({
319320
},
320321
}),
321322
],
323+
} satisfies BetterAuthOptions;
324+
325+
/**
326+
* better-auth's own session endpoint returns the full stored session row. That
327+
* row carries the session token, the client IP address and the user agent,
328+
* none of which any caller reads, and the token is the value the signed
329+
* session cookie is built from. `customSession` replaces the endpoint's
330+
* response with the projection below, so those fields never leave the server.
331+
*
332+
* The options object is handed to the plugin so it keeps inferring the fields
333+
* the enabled plugins add to the user and session models.
334+
*/
335+
export const auth = betterAuth({
336+
...authOptions,
337+
plugins: [
338+
...authOptions.plugins,
339+
customSession(
340+
async ({ user, session }) => projectSessionPayload({ user, session }),
341+
authOptions,
342+
),
343+
],
322344
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { betterAuth } from "better-auth";
2+
import { memoryAdapter } from "better-auth/adapters/memory";
3+
import { admin, customSession } from "better-auth/plugins";
4+
import { describe, expect, it } from "vitest";
5+
import { projectSessionPayload, projectSessionRow } from "../session-projection";
6+
7+
const storedRow = {
8+
id: "s1",
9+
userId: "u1",
10+
token: "fixture-not-a-real-token",
11+
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
12+
createdAt: new Date(0),
13+
updatedAt: new Date(0),
14+
ipAddress: "203.0.113.7",
15+
userAgent: "fixture-agent/1.0",
16+
impersonatedBy: null,
17+
};
18+
19+
describe("projectSessionRow", () => {
20+
it("drops the session token, the stored IP address and the user agent", () => {
21+
const projected = projectSessionRow(storedRow);
22+
expect(projected).not.toHaveProperty("token");
23+
expect(projected).not.toHaveProperty("ipAddress");
24+
expect(projected).not.toHaveProperty("userAgent");
25+
expect(JSON.stringify(projected)).not.toContain("fixture-not-a-real-token");
26+
expect(JSON.stringify(projected)).not.toContain("203.0.113.7");
27+
});
28+
29+
it("keeps the fields callers read", () => {
30+
expect(projectSessionRow(storedRow)).toEqual({
31+
id: "s1",
32+
userId: "u1",
33+
expiresAt: storedRow.expiresAt,
34+
createdAt: storedRow.createdAt,
35+
updatedAt: storedRow.updatedAt,
36+
impersonatedBy: null,
37+
});
38+
});
39+
40+
it("keeps the impersonation marker the admin banner reads", () => {
41+
expect(projectSessionRow({ ...storedRow, impersonatedBy: "admin-1" }).impersonatedBy).toBe(
42+
"admin-1",
43+
);
44+
});
45+
46+
it("does not let an unknown upstream column through", () => {
47+
const withExtra = { ...storedRow, futureSecret: "leak-me" };
48+
expect(JSON.stringify(projectSessionRow(withExtra))).not.toContain("leak-me");
49+
});
50+
});
51+
52+
describe("projectSessionPayload", () => {
53+
it("passes the user object through untouched", () => {
54+
const user = { id: "u1", name: "Ada", email: "ada@example.com", role: "admin" };
55+
const result = projectSessionPayload({ user, session: storedRow });
56+
expect(result.user).toBe(user);
57+
expect(result.session).not.toHaveProperty("token");
58+
});
59+
});
60+
61+
function buildTestAuth() {
62+
const options = {
63+
database: memoryAdapter({ user: [], session: [], account: [], verification: [] }),
64+
baseURL: "http://localhost:3000",
65+
secret: "session-projection-test-secret-not-a-real-credential",
66+
emailAndPassword: { enabled: true, autoSignIn: true },
67+
plugins: [admin()],
68+
};
69+
return betterAuth({
70+
...options,
71+
plugins: [
72+
...options.plugins,
73+
customSession(async ({ user, session }) => projectSessionPayload({ user, session }), options),
74+
],
75+
});
76+
}
77+
78+
describe("the session endpoint", () => {
79+
it("does not return the session token, IP address or user agent", async () => {
80+
const testAuth = buildTestAuth();
81+
82+
const signUp = await testAuth.api.signUpEmail({
83+
body: { name: "Ada", email: "ada@example.com", password: "fixture-password-1234" },
84+
asResponse: true,
85+
});
86+
const cookie = signUp.headers
87+
.getSetCookie()
88+
.map((entry) => entry.split(";")[0])
89+
.join("; ");
90+
expect(cookie).not.toBe("");
91+
92+
const result = await testAuth.api.getSession({ headers: new Headers({ cookie }) });
93+
94+
expect(result).not.toBeNull();
95+
expect(result?.user.email).toBe("ada@example.com");
96+
expect(result?.session).not.toHaveProperty("token");
97+
expect(result?.session).not.toHaveProperty("ipAddress");
98+
expect(result?.session).not.toHaveProperty("userAgent");
99+
expect(JSON.stringify(result)).not.toContain(signUp.headers.get("set-cookie") ?? "");
100+
});
101+
102+
it("still sets no-store on the session response", async () => {
103+
const testAuth = buildTestAuth();
104+
const signUp = await testAuth.api.signUpEmail({
105+
body: { name: "Ada", email: "ada2@example.com", password: "fixture-password-1234" },
106+
asResponse: true,
107+
});
108+
const cookie = signUp.headers
109+
.getSetCookie()
110+
.map((entry) => entry.split(";")[0])
111+
.join("; ");
112+
113+
const response = await testAuth.api.getSession({
114+
headers: new Headers({ cookie }),
115+
asResponse: true,
116+
});
117+
118+
expect(response.headers.get("cache-control")).toContain("no-store");
119+
});
120+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* The session-row fields the session endpoint is allowed to expose. Declared by
3+
* hand rather than derived from better-auth's session type so that a column
4+
* added by a library upgrade is excluded until someone deliberately adds it
5+
* here.
6+
*
7+
* Three stored fields are deliberately absent. `token` is the value the signed
8+
* session cookie is built from, so handing it to a client would put credential
9+
* material in a place the HttpOnly cookie exists to keep it out. `ipAddress`
10+
* and `userAgent` are personal data that no caller reads. None of the three is
11+
* usable as a credential against this API as configured, but there is no reason
12+
* to publish them and every reason not to.
13+
*/
14+
export interface PublicSessionRow {
15+
id: string;
16+
userId: string;
17+
expiresAt: Date | string;
18+
createdAt: Date | string;
19+
updatedAt: Date | string;
20+
impersonatedBy: string | null;
21+
}
22+
23+
/**
24+
* The subset of better-auth's session row this projection reads. Declared
25+
* structurally rather than imported so this module never has to reference the
26+
* configured auth instance, which imports it.
27+
*/
28+
interface StoredSessionRow {
29+
id: string;
30+
userId: string;
31+
expiresAt: Date | string;
32+
createdAt: Date | string;
33+
updatedAt: Date | string;
34+
impersonatedBy?: string | null | undefined;
35+
}
36+
37+
/**
38+
* Reduce a stored session row to the fields that may leave the server. Every
39+
* field is copied explicitly; nothing is spread, so upstream additions cannot
40+
* leak through.
41+
*/
42+
export function projectSessionRow(row: StoredSessionRow): PublicSessionRow {
43+
return {
44+
id: row.id,
45+
userId: row.userId,
46+
expiresAt: row.expiresAt,
47+
createdAt: row.createdAt,
48+
updatedAt: row.updatedAt,
49+
impersonatedBy: row.impersonatedBy ?? null,
50+
};
51+
}
52+
53+
/**
54+
* Reduce a whole session payload. The user object is passed through by
55+
* reference and generically, so every field better-auth and its plugins put on
56+
* it survives unchanged for both browser callers and the server-side helpers
57+
* that read the session.
58+
*/
59+
export function projectSessionPayload<TUser>(payload: { user: TUser; session: StoredSessionRow }): {
60+
user: TUser;
61+
session: PublicSessionRow;
62+
} {
63+
return {
64+
user: payload.user,
65+
session: projectSessionRow(payload.session),
66+
};
67+
}

0 commit comments

Comments
 (0)