Skip to main content
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:
{ "aud": "proj_abc123" }
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:
  1. Decodes the JWT header to get kid
  2. Fetches the corresponding public key
  3. Verifies the signature
  4. Validates claims (iss, aud, exp)
  5. 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:
1

Client detects expired access token

The client checks exp claim or receives a 401 response.
2

Request new access token

POST /api/v1/auth/oauth/token
{
  "grant_type": "refresh_token",
  "refresh_token": "<refresh_token>"
}
3

Validate refresh token

The server:
  • Looks up token in database
  • Checks if expired (expiresAt < now)
  • Verifies associated user exists
  • Returns null if invalid
4

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
    }
  })
]);
5

Log analytics events

Record session activity and token refresh:
await logEvent([SystemEventTypes.SessionActivity], { ... });
await logEvent([SystemEventTypes.TokenRefresh], { ... });
6

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:
  1. Creates ProjectUserRefreshToken record
  2. Generates access token with user claims
  3. 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.

Authentication Headers

Bearer Token Format

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. 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:
  1. Signature - Valid RS256 signature using public key
  2. Issuer (iss) - Matches expected issuer URL
  3. Audience (aud) - Matches project ID (with optional type suffix)
  4. Expiration (exp) - Token has not expired
  5. Not Before (nbf) - Token is valid (if present)
  6. 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:
1

Generate code verifier

const codeVerifier = generateSecureRandomString(128);
const codeChallenge = base64url(sha256(codeVerifier));
2

Send challenge in authorization request

GET /oauth/authorize?code_challenge=<challenge>&code_challenge_method=S256
3

Send verifier in token exchange

POST /oauth/token
{
  "code": "...",
  "code_verifier": "<verifier>"
}
4

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

Build docs developers (and LLMs) love