Stack Auth uses a secure dual-token system for managing user sessions. This guide explains how sessions work, from creation to expiration, and how to manage them effectively.
Session Architecture
Dual-Token System
Stack Auth uses two types of tokens working together:
Access Tokens (JWT)
- Short-lived (default: 10 minutes)
- Contains user information and claims
- Signed with RS256 algorithm
- Stateless (can be verified without database lookup)
- Used for API authentication
Refresh Tokens
- Long-lived (default: 1 year)
- Opaque random strings
- Stored in database (
ProjectUserRefreshToken)
- Used to obtain new access tokens
- Can be revoked server-side
This architecture balances security and performance: access tokens enable fast stateless verification, while refresh tokens provide revocation capability and long-lived sessions.
JWT Access Tokens
Token Structure
Access tokens are JWTs following the RFC 7519 standard:
Header:
{
"alg": "RS256",
"typ": "JWT",
"kid": "key_abc123" // Key ID for key rotation
}
Payload:
{
// Standard claims
"iss": "https://api.stack-auth.com/api/v1/projects/proj_...",
"aud": "proj_abc123",
"sub": "user_xyz789",
"iat": 1704067200,
"exp": 1704067800,
// Stack Auth claims
"project_id": "proj_abc123",
"branch_id": "main",
"refresh_token_id": "token_def456",
"role": "authenticated",
// User information
"name": "John Doe",
"email": "[email protected]",
"email_verified": true,
"selected_team_id": "team_ghi789",
// Access control
"is_anonymous": false,
"is_restricted": false,
"restricted_reason": null,
"requires_totp_mfa": false
}
See payload definition at /packages/stack-shared/src/sessions.ts:9.
Audience (aud) Variations
The aud claim varies based on user type:
Normal users:
Anonymous users:
{
"aud": "proj_abc123:anon",
"is_anonymous": true,
"is_restricted": true,
"restricted_reason": { "type": "anonymous" }
}
Restricted users:
{
"aud": "proj_abc123:restricted",
"is_restricted": true,
"restricted_reason": { "type": "admin", "reason": "Account suspended" }
}
The audience determines which JWK keys can verify the token.
See implementation at /apps/backend/src/lib/tokens.tsx:52.
Token Signing
Access tokens are signed using RS256 (RSA with SHA-256):
const userType = getUserType(user.is_anonymous, user.is_restricted);
const accessToken = await signJWT({
issuer: getIssuer(tenancy.project.id, userType),
audience: getAudience(tenancy.project.id, userType),
expirationTime: "10min",
payload: {
sub: user.id,
project_id: tenancy.project.id,
branch_id: tenancy.branchId,
refresh_token_id: refreshToken.id,
// ... user claims
}
});
See implementation at /apps/backend/src/lib/tokens.tsx:335.
Token Verification
Access tokens are verified using public JWK keys:
// Fetch public keys
GET /.well-known/jwks.json
// Response:
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "key_abc123",
"alg": "RS256",
"n": "...", // Modulus
"e": "AQAB" // Exponent
}
]
}
The server:
- Decodes the JWT header to get
kid
- Fetches the corresponding public key
- Verifies the signature
- Validates claims (
iss, aud, exp)
- Returns the payload if valid
See verification at /apps/backend/src/lib/tokens.tsx:77.
Refresh Tokens
Database Storage
Refresh tokens are stored in the ProjectUserRefreshToken table:
model ProjectUserRefreshToken {
id String @id
tenancyId String
projectUserId String
refreshToken String @unique // Random secure string
// Lifecycle
createdAt DateTime
updatedAt DateTime
expiresAt DateTime? // Null = no expiration
lastActiveAt DateTime
lastActiveAtIpInfo Json? // Geolocation data
// Flags
isImpersonation Boolean // Admin impersonation session
@@id([tenancyId, id])
}
See model at /apps/backend/prisma/schema.prisma:533.
Token Generation
Refresh tokens are created during authentication:
const refreshToken = generateSecureRandomString();
const refreshTokenObj = await prisma.projectUserRefreshToken.create({
data: {
tenancyId: tenancy.id,
projectUserId: user.id,
refreshToken: refreshToken,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year
isImpersonation: false
}
});
See implementation at /apps/backend/src/lib/tokens.tsx:350.
Token Refresh Flow
When an access token expires, use the refresh token to get a new one:
Client detects expired access token
The client checks exp claim or receives a 401 response.
Request new access token
POST /api/v1/auth/oauth/token
{
"grant_type": "refresh_token",
"refresh_token": "<refresh_token>"
}
Validate refresh token
The server:
- Looks up token in database
- Checks if expired (
expiresAt < now)
- Verifies associated user exists
- Returns null if invalid
Update activity tracking
If valid, update timestamps and log events:await Promise.all([
// Update user last active
prisma.projectUser.update({
where: { ... },
data: { lastActiveAt: now }
}),
// Update token last active
prisma.projectUserRefreshToken.update({
where: { ... },
data: {
lastActiveAt: now,
lastActiveAtIpInfo: ipInfo
}
})
]);
Log analytics events
Record session activity and token refresh:await logEvent([SystemEventTypes.SessionActivity], { ... });
await logEvent([SystemEventTypes.TokenRefresh], { ... });
Generate new access token
Create and return a new JWT:const accessToken = await signJWT({ ... });
return {
access_token: accessToken,
token_type: "Bearer",
expires_in: 600 // 10 minutes
};
See implementation at /apps/backend/src/lib/tokens.tsx:233.
Session Lifecycle
Session Creation
Sessions are created during authentication (sign-in or sign-up):
const { refreshToken, accessToken } = await createAuthTokens({
tenancy,
projectUserId: user.id,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
isImpersonation: false
});
return {
access_token: accessToken,
refresh_token: refreshToken,
user_id: user.id
};
This:
- Creates
ProjectUserRefreshToken record
- Generates access token with user claims
- Returns both tokens to client
See implementation at /apps/backend/src/lib/tokens.tsx:369.
Session Activity
Session activity is tracked on every token refresh:
Updated fields:
ProjectUser.lastActiveAt - User’s last activity
ProjectUserRefreshToken.lastActiveAt - Token’s last use
ProjectUserRefreshToken.lastActiveAtIpInfo - Geolocation data
Logged events:
SessionActivity - For session metrics and analytics
TokenRefresh - For security auditing
See tracking at /apps/backend/src/lib/tokens.tsx:240.
Session Expiration
Sessions can expire in two ways:
Access token expiration:
- Happens after 10 minutes (configurable via
STACK_ACCESS_TOKEN_EXPIRATION_TIME)
- Client must refresh using refresh token
- Temporary, resolved by token refresh
Refresh token expiration:
- Happens after configured period (default: 1 year)
- Cannot be renewed
- Requires re-authentication
- Checked on every token refresh attempt
Session Revocation
Revoke a session by deleting the refresh token:
DELETE /api/v1/sessions/{session_id}
// Implementation:
await prisma.projectUserRefreshToken.delete({
where: {
tenancyId_id: {
tenancyId: tenancy.id,
id: sessionId
}
}
});
After revocation:
- Existing access tokens remain valid until expiration
- Refresh token can no longer obtain new access tokens
- User must re-authenticate to create a new session
Access tokens cannot be revoked before expiration since they are stateless. For immediate access revocation, use short access token expiration times or implement a token blacklist.
Session Tracking
IP Geolocation
Session activity includes IP geolocation data:
const ipInfo = await getEndUserIpInfoForEvent();
// Stored in lastActiveAtIpInfo:
{
"ip": "203.0.113.42",
"countryCode": "US",
"regionCode": "CA",
"cityName": "San Francisco",
"latitude": 37.7749,
"longitude": -122.4194,
"tzIdentifier": "America/Los_Angeles"
}
This enables:
- Security alerts for unusual locations
- User-facing session lists with locations
- Geographic analytics
- Fraud detection
See IP tracking at /apps/backend/src/lib/events.tsx:16.
Session Replay
Stack Auth can capture session replay data for debugging:
model SessionReplay {
id String
tenancyId String
projectUserId String
refreshTokenId String // Links to session
startedAt DateTime
lastEventAt DateTime
chunks SessionReplayChunk[] // Recording data
}
Session replay features:
- Links to specific refresh token/session
- Stores events in S3 via chunks
- Tracks recording segments
- Preserves browser session ID
See model at /apps/backend/prisma/schema.prisma:283.
Access tokens are sent in the Authorization header:
Authorization: StackSession <access_token>
Example:
Authorization: StackSession eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
The StackSession scheme distinguishes Stack Auth tokens from other authentication schemes.
See schema validation at /apps/backend/src/lib/tokens.tsx:19.
Cookie Storage (Web)
For web applications, tokens can be stored in httpOnly cookies:
Access token cookie:
Set-Cookie: stack_access_token=<jwt>; HttpOnly; Secure; SameSite=Lax; Max-Age=600
Refresh token cookie:
Set-Cookie: stack_refresh_token=<token>; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000
Cookie benefits:
- Automatic inclusion in requests
- Protection against XSS attacks
- Secure storage in browser
Token Security
Signing Algorithm
Access tokens use RS256 (RSA Signature with SHA-256):
- Private key: Used by Stack Auth to sign tokens
- Public key: Available at
/.well-known/jwks.json for verification
- Key rotation: Multiple keys supported via
kid (Key ID)
- Key size: 2048-bit RSA keys minimum
See JWT utils at /packages/stack-shared/src/utils/jwt.
Token Validation
When verifying an access token, check:
- Signature - Valid RS256 signature using public key
- Issuer (iss) - Matches expected issuer URL
- Audience (aud) - Matches project ID (with optional type suffix)
- Expiration (exp) - Token has not expired
- Not Before (nbf) - Token is valid (if present)
- User type - Audience suffix matches
is_anonymous/is_restricted flags
See validation at /apps/backend/src/lib/tokens.tsx:77.
Refresh Token Security
Best practices:
- Store in httpOnly cookies (web) or secure storage (mobile)
- Never expose in URLs or logs
- Use HTTPS for all token transmission
- Implement token rotation (issue new refresh token on use)
- Monitor for unusual activity patterns
- Allow users to view and revoke sessions
PKCE for OAuth
OAuth flows use PKCE (Proof Key for Code Exchange) for security:
Generate code verifier
const codeVerifier = generateSecureRandomString(128);
const codeChallenge = base64url(sha256(codeVerifier));
Send challenge in authorization request
GET /oauth/authorize?code_challenge=<challenge>&code_challenge_method=S256
Send verifier in token exchange
POST /oauth/token
{
"code": "...",
"code_verifier": "<verifier>"
}
Verify match
Server confirms:const expectedChallenge = base64url(sha256(codeVerifier));
if (expectedChallenge !== storedCodeChallenge) {
throw new Error("Invalid code verifier");
}
PKCE prevents authorization code interception attacks.
See OAuth cookies at /apps/backend/src/lib/tokens.tsx:32.
Session Management APIs
List Active Sessions
Retrieve all active sessions for a user:
GET /api/v1/users/{user_id}/sessions
// Response:
[
{
"id": "token_abc123",
"created_at": "2024-01-01T00:00:00Z",
"last_active_at": "2024-01-15T12:30:00Z",
"ip_info": {
"ip": "203.0.113.42",
"city_name": "San Francisco",
"country_code": "US"
},
"is_impersonation": false
}
]
Revoke Session
Delete a specific session:
DELETE /api/v1/sessions/{session_id}
Revoke All Sessions
Log out from all devices:
DELETE /api/v1/users/{user_id}/sessions
This deletes all ProjectUserRefreshToken records for the user.
Current Session Info
Get information about the current session:
GET /api/v1/sessions/current
// Response:
{
"id": "token_abc123",
"user_id": "user_xyz789",
"created_at": "2024-01-01T00:00:00Z",
"expires_at": "2025-01-01T00:00:00Z",
"last_active_at": "2024-01-15T12:30:00Z"
}
Admin Impersonation
Admins can impersonate users for support purposes:
Creating Impersonation Session
POST /api/v1/users/{user_id}/impersonate
// Response:
{
"access_token": "...",
"refresh_token": "..."
}
This creates a session with isImpersonation: true.
Impersonation Restrictions
- Impersonation sessions are clearly marked
- Can be filtered out from user’s session list
- May have limited permissions
- Should be logged for audit purposes
Best Practices
Token Storage
Web applications:
- Use httpOnly cookies for both tokens
- Set
Secure flag (HTTPS only)
- Use
SameSite=Lax for CSRF protection
Mobile applications:
- Use platform secure storage (Keychain, KeyStore)
- Never store tokens in localStorage/SharedPreferences
- Implement biometric authentication for access
Server-side:
- Never log refresh tokens
- Encrypt tokens at rest if stored
- Use environment variables for API keys
Token Refresh Strategy
Proactive refresh:
// Refresh before expiration
if (accessToken.expiresInMillis < 60000) { // < 1 minute
await refreshAccessToken();
}
Reactive refresh:
// Refresh on 401 response
try {
await apiCall();
} catch (error) {
if (error.status === 401) {
await refreshAccessToken();
return await apiCall(); // Retry
}
}
Session Limits
Consider limiting active sessions per user:
// Limit to 5 active sessions
const activeSessions = await prisma.projectUserRefreshToken.count({
where: { projectUserId: user.id }
});
if (activeSessions >= 5) {
// Delete oldest session
await prisma.projectUserRefreshToken.delete({
where: { /* oldest session */ }
});
}
Security Monitoring
Track suspicious activity:
- Multiple sessions from different countries
- Rapid session creation
- Failed refresh attempts
- Unusual IP addresses
Alert users:
- New device/location login
- Password change
- Session revocation
- Suspicious activity detected