Authentication & sessions
v1 (current)JWT access tokens, opaque refresh tokens, and how rotation works.
Signing in issues two cookies: a signed JWT access token (15 minutes) and an opaque, randomly generated refresh token (30 days), hashed before it's stored — a database read of the refresh_tokens table never reveals a usable token.
export const ACCESS_TOKEN_TTL_SECONDS = 15 * 60;
export async function signAccessToken(payload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(`${ACCESS_TOKEN_TTL_SECONDS}s`)
.sign(secret);
}Refresh tokens rotate on every use: calling /api/auth/refresh revokes the current token and issues a new one, rather than extending the same token's life. A stolen, already-used refresh token is worthless.
Login is timing-safe
A login attempt against a non-existent email still runs a bcrypt comparison of equivalent cost, against a precomputed dummy hash — the response time doesn't leak whether the account exists.
const DUMMY_HASH = bcrypt.hashSync("dummy-password-for-timing-safety", 12);
const valid = await verifyPassword(
password,
user?.passwordHash ?? DUMMY_HASH
);Organization scoping
A session can be scoped to zero, or exactly one, organization at a time — the JWT carries an optional orgId and role claim. A user with more than one membership gets requiresOrgSelection: true back from login and is routed to pick one; a user with exactly one membership is scoped to it automatically.
{
"user": { "id": "...", "name": "...", "email": "...", "emailVerified": true },
"memberships": [
{ "organizationId": "...", "name": "Test University", "role": "OWNER" }
],
"requiresOrgSelection": false
}Changing your password intentionally keeps the session you're currently using alive and only revokes your other sessions — you won't get logged out of the device you're using to change it.