Sessions & Token Management#
AuthHero implements a secure session system with short-lived JWT access tokens, long-lived refresh tokens, automatic rotation, and reuse detection.
Token Types#
| Token | Format | Lifetime | Storage |
|---|---|---|---|
Access Token | JWT (signed with ACCESS_TOKEN_SECRET) | 15 minutes | Client memory / Zustand |
Refresh Token | Random 80-hex-char string | 30 days | httpOnly secure cookie |
MFA Temp Token | JWT (signed with MFA_TEMP_TOKEN_SECRET) | 5 minutes | Client memory / Zustand |
Access Token (JWT)#
The access token is a compact JWT containing:
{
"userId": "cm...",
"sessionId": "cm...",
"iat": 1700000000,
"exp": 1700000900
}It is signed with ACCESS_TOKEN_SECRET using HS256. The token is never stored in cookies — it stays in memory (Zustand store) to prevent CSRF attacks.
Refresh Token#
The refresh token is a cryptographically random 80-hex-character string (40 bytes). Only its SHA-256 hash is stored in the database — the server never persists the plaintext token.
The refresh token is set as a cookie with these properties:
| Property | Value | Why |
|---|---|---|
httpOnly | true | Prevents JavaScript access (XSS protection) |
secure | true (production) | Only sent over HTTPS |
sameSite | strict | Prevents CSRF — cookie not sent in cross-origin requests |
path | /auth | Only sent to auth endpoints, not to every request |
maxAge | 30 days | Cookie expiry matches session expiry |
Session Creation#
Sessions are created through a centralized createSession()function used by all login paths (email/password, OAuth, MFA challenge):
export async function createSession(
userId: string,
userAgent?: string,
ipAddress?: string,
) {
// 1. Generate random refresh token
const refreshToken = crypto.randomBytes(TOKEN_LENGTH.REFRESH).toString("hex");
const refreshTokenHash = hashToken(refreshToken);
// 2. Sign JWT access token
const session = await prisma.session.create({
data: {
userId,
refreshTokenHash,
expiresAt: new Date(Date.now() + TOKEN_EXPIRY.REFRESH_TOKEN_DAYS * 86400000),
userAgent,
ipAddress,
},
});
const accessToken = signAccessToken({ userId, sessionId: session.id });
return { accessToken, refreshToken };
}Refresh Token Rotation#
Every time the access token expires and the client refreshes it, the refresh token is rotated: the old one is consumed and a new one is issued.
Client sends POST /auth/refresh-token
The httpOnly cookie is automatically included. No request body needed.
Server looks up the session
The refresh token is hashed (SHA-256) and used to look up the session in the database. The server checks:
- Session exists
- Session is not revoked (
revokedAtis null) - Session is not expired
New tokens are generated
A new random refresh token is generated. The session's refreshTokenHash is updated with the new hash. lastRotatedAt is set to now. A new access token JWT is signed.
Response
New access token in the JSON body. New refresh token in the cookie (same cookie name, replaces the old one).
Reuse Detection#
If a refresh token that has already been rotated is used again, AuthHero assumes the token was stolen. It immediately revokes all sessions for that user — forcing both the attacker and the real user to re-authenticate.
How it works:
- When a refresh request comes in, the server hashes the token and looks for a matching session
- If no session has that hash, but the token was previously valid (meaning it was rotated), this is reuse
- The server calls
logoutAllSessions(userId)which setsrevokedAton every session for that user - The response includes error code
SESSION_REVOKED
Logout#
Single Session Logout#
POST /auth/logout (requires auth) — Revokes the current session by setting revokedAt. Clears the refresh token cookie.
Logout All Sessions#
POST /auth/logout-all (requires auth) — Revokes all sessions for the user across all devices. Triggered automatically on password change for security.
Session Lifecycle Summary#
CREATED ──────→ ACTIVE ──────→ EXPIRED
│ │ │
│ ├── rotate ──→ ACTIVE (new token hash)
│ │
│ ├── logout ──→ REVOKED
│ │
│ └── reuse detected ──→ ALL SESSIONS REVOKED
Events that revoke all sessions:
• Refresh token reuse detected
• Password changed
• POST /auth/logout-allAuthentication Middleware#
The authenticate middleware runs on protected routes and performs two checks:
- JWT verification — Validates the signature and extracts
userId+sessionId - Session check — Queries the database to confirm the session hasn't been revoked (this catches cases where a user logged out but the JWT hasn't expired yet)
// 1. Extract token from Authorization header
const token = req.headers.authorization?.split(" ")[1];
// 2. Verify JWT and extract payload
const { userId, sessionId } = verifyAccessToken(token);
// 3. Check session is still valid in DB
const session = await prisma.session.findUnique({ where: { id: sessionId } });
if (!session || session.revokedAt) throw new AppError(401, "Session revoked");
// 4. Attach user context to request
req.user = { userId, sessionId };