Skip to content

Sessions

CruzJS manages authentication sessions through SessionService, which stores session data in both Cloudflare KV (for fast reads) and D1 (for persistence and audit). Sessions use opaque tokens — never JWTs — for server-side revocability.

The SessionModule from @cruzjs/core/sessions provides a provider-agnostic session storage layer.

All session backends implement the SessionAdapter interface:

interface SessionAdapter {
store(session: StoredSession): Promise<void>;
get(tokenHash: string): Promise<StoredSession | null>;
getById(id: string): Promise<StoredSession | null>;
delete(tokenHash: string): Promise<void>;
deleteById(id: string): Promise<void>;
getUserSessions(userId: string): Promise<StoredSession[]>;
invalidateAll(userId: string): Promise<void>;
}

The CloudflareKVSessionAdapter stores sessions in KV for sub-millisecond validation at the edge. Sessions are indexed by both token hash (for fast lookup during request validation) and session ID (for management operations like listing and revoking).

ProcedureTypeDescription
session.listSessionsqueryList all active sessions for the current user
session.getCurrentSessionqueryGet details of the current session
session.revokeSessionmutationRevoke a specific session by ID
session.revokeAllSessionsmutationRevoke all sessions except the current one
function ActiveSessions() {
const { data: sessions } = trpc.session.listSessions.useQuery();
const revoke = trpc.session.revokeSession.useMutation();
return (
<ul>
{sessions?.map((s) => (
<li key={s.id}>
{s.userAgent} — {s.ipAddress}
<button onClick={() => revoke.mutate({ sessionId: s.id })}>
Revoke
</button>
</li>
))}
</ul>
);
}

When a user registers, logs in, or completes OAuth, SessionService.createSession() generates a session:

const session = await sessionService.createSession({
userId: identity.id,
currentOrgId: null, // set later when user selects an org
userAgent, // captured from request headers
ipAddress, // captured from x-forwarded-for
});
// session.token -- raw token returned to client
// session.expiresAt -- Date, 30 days from creation

Internally:

  1. A 32-byte random token is generated via crypto.randomBytes
  2. The token is SHA-256 hashed for storage
  3. The session is written to KV with a TTL matching the session TTL
  4. The hashed token, userId, metadata, and expiry are inserted into the D1 sessions table
  5. The raw (unhashed) token is returned to the client

The session middleware extracts the token from requests in this order:

  1. Authorization: Bearer <token> header
  2. session=<token> cookie
// In a React Router loader
import { requireSession } from '@cruzjs/core/shared/middleware/session.middleware';
export async function loader({ request }: LoaderFunctionArgs) {
const auth = await requireSession(request, container);
// auth.user.id -- authenticated user ID
// auth.session -- full SessionData
}

SessionService.getSession(token) follows a two-tier lookup:

  1. KV cache (fast path): Check KV by raw token key. If found and not expired, return it.
  2. D1 fallback: Hash the token, query the sessions table. If found and not expired, restore it to KV cache with the remaining TTL.

If the session is expired in either store, it is deleted from both.

Sessions use a sliding window expiry. When refreshSession(token) is called, if the session has less than the refresh threshold remaining, its expiry is extended to a full TTL from now:

const refreshed = await sessionService.refreshSession(token);

Default configuration:

  • Session TTL: 30 days (config.session.ttlSeconds)
  • Refresh threshold: 7 days (config.session.refreshThresholdSeconds)

This means a session is refreshed when it has less than 7 days remaining. If the user is active at least once every 23 days, their session never expires.

The auth.session tRPC query automatically triggers a refresh check on every call, so active users stay logged in seamlessly.

Sessions are stored in KV under the session namespace using the raw token as the key. The value is a JSON-serialized SessionData object:

type SessionData = {
userId: string;
currentOrgId?: string | null;
expiresAt: Date;
userAgent?: string;
ipAddress?: string;
};

KV entries have a TTL matching the session TTL, so expired sessions are automatically cleaned up.

The sessions table stores the hashed token, user ID, org context, expiry, and request metadata:

ColumnTypeDescription
sessionTokentextSHA-256 hash of the raw token
userIdtextForeign key to authIdentity
currentOrgIdtextCurrently selected org (nullable)
expiresAttextISO 8601 expiry timestamp
userAgenttextBrowser user agent string
ipAddresstextClient IP address

Users can have multiple active sessions (e.g., different devices or browsers). Each login creates a separate session token. The auth.session query returns data for the specific session token used in the request, not all sessions.

Delete a specific session by its token (used by the auth.logout mutation):

await sessionService.deleteSession(token);

This removes the session from both KV and D1.

Revoke all sessions for a user (used during password reset for security):

const count = await sessionService.deleteAllSessions(userId);

This deletes all D1 rows for the user. KV entries expire naturally since we cannot reverse-lookup tokens from user IDs. For immediate KV invalidation in production, consider maintaining a userId -> tokens[] reverse index.

When a user switches organizations, the session’s currentOrgId is updated in both KV and D1:

await sessionService.updateCurrentOrg(token, newOrgId);
cruz.config.ts
export default defineConfig({
session: {
ttlSeconds: 30 * 24 * 60 * 60, // 30 days (default)
refreshThresholdSeconds: 7 * 24 * 60 * 60, // 7 days (default)
},
});
  • Token hashing: Raw tokens are never stored. Only SHA-256 hashes are persisted in D1, so a database leak does not compromise active sessions.
  • Metadata tracking: User agent and IP address are recorded for audit purposes and can be used to detect suspicious activity.
  • Forced logout on password reset: AuthService.resetPassword() calls deleteAllSessions() to invalidate all sessions, forcing re-authentication on all devices.
  • No token in URL: Sessions are transmitted via Authorization header or session cookie, never in query parameters.