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.

lib/auth/jwt.ts
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.

app/api/auth/login/route.ts
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.

POST /api/auth/login response
{
  "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.