Stateless Tokens (JWTs)
Stateless tokens (JWTs) let you validate sessions without making any network calls - not to PropelAuth BYO, not to your database. You validate them using just a public key, making them incredibly fast for high-scale applications.
Think of them as a performance optimization for your regular sessions. While regular session validation adds a few milliseconds to check if a session is still valid, stateless tokens can be validated in microseconds with zero network overhead.
When to Use Stateless Tokens
Section titled “When to Use Stateless Tokens”For most applications, regular session validation is perfectly fine. The few milliseconds it takes to validate a session won’t impact your users. But there are scenarios where stateless tokens shine:
Use stateless tokens when:
- You have extremely high request volume (millions of requests per minute)
- You need to validate sessions in edge functions or serverless environments
- You have read-heavy APIs where the same session is validated hundreds of times per second
Keep using regular sessions when:
- You have low to moderate request volume
- You need instant revocation (stateless tokens can’t be revoked until they expire)
- You need to track session activity in real-time
The beauty is you don’t have to choose one or the other - you can use both. Many applications use regular sessions for sensitive operations and stateless tokens for read-heavy operations.
Getting Started
Section titled “Getting Started”The key insight is that stateless tokens are created FROM sessions. You first create a regular session, then generate a short-lived JWT from it.
app.post("/api/login", async (req, res) => { // Authenticate your user however you normally do const user = await authenticateUser(req.body.email, req.body.password);
// Step 1: Create a regular session const session = await client.session.create({ userId: user.id, ipAddress: req.ip, userAgent: req.headers["user-agent"], });
if (!session.ok) { return res.status(500).json({ error: "Could not create session" }); }
// Step 2: Create a stateless token from the session const token = await client.session.createStatelessToken({ userId: user.id, sessionId: session.data.sessionId, customClaims: { email: user.email, role: user.role, teamId: user.teamId, }, lifetimeSecs: 900, // 15 minutes });
if (!token.ok) { return res.status(500).json({ error: "Could not create token" }); }
res.cookie("sessionToken", session.data.sessionToken, COOKIE_OPTIONS); res.cookie("statelessToken", token.data.statelessToken, COOKIE_OPTIONS); res.json({ success: true });});class LoginRequest(BaseModel): email: str password: str
@app.post("/api/login")async def login(login_request: LoginRequest, request: Request, response: Response): user = await authenticate_user(login_request.email, login_request.password)
# Step 1: Create a regular session session = await client.session.create( user_id=user.id, ip_address=request.client.host, user_agent=request.headers.get("user-agent") )
if is_err(session): raise HTTPException(status_code=500, detail="Could not create session")
# Step 2: Create a stateless token from the session token = await client.session.create_stateless_token( user_id=user.id, session_id=session.data.session_id, custom_claims={ "email": user.email, "role": user.role, "teamId": user.team_id }, lifetime_secs=900 # 15 minutes )
if is_err(token): raise HTTPException(status_code=500, detail="Could not create token")
response.set_cookie("sessionToken", session.data.session_token, **COOKIE_OPTIONS) response.set_cookie("statelessToken", token.data.stateless_token, **COOKIE_OPTIONS) return {"success": True}@PostMapping("/api/login")public ResponseEntity<?> login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) { // Authenticate your user however you normally do User user = authenticateUser(loginRequest.getEmail(), loginRequest.getPassword());
// Step 1: Create a regular session CreateSessionResponse session; try { session = client.session.create( CreateSessionCommand.builder() .userId(user.getId()) .ipAddress(request.getRemoteAddr()) .userAgent(request.getHeader("User-Agent")) .build() ); } catch (CreateSessionException e) { return ResponseEntity.status(500).body(Map.of("error", "Could not create session")); }
// Step 2: Create a stateless token from the session CreateStatelessTokenResponse token; try { token = client.session.createStatelessToken( CreateStatelessTokenCommand.builder() .userId(user.getId()) .sessionId(session.getSessionId()) .customClaims(JsonValue.of(Map.of( "email", user.getEmail(), "role", user.getRole(), "teamId", user.getTeamId() ))) .lifetimeSecs(900) // 15 minutes .build() ); } catch (CreateStatelessTokenException e) { return ResponseEntity.status(500).body(Map.of("error", "Could not create token")); }
Cookie sessionCookie = new Cookie("sessionToken", session.getSessionToken()); sessionCookie.setHttpOnly(true); sessionCookie.setSecure(true); sessionCookie.setPath("/"); response.addCookie(sessionCookie);
Cookie statelessCookie = new Cookie("statelessToken", token.getStatelessToken()); statelessCookie.setHttpOnly(true); statelessCookie.setSecure(true); statelessCookie.setPath("/"); response.addCookie(statelessCookie);
return ResponseEntity.ok(Map.of("success", true));}[HttpPost("/api/login")]public async Task<IActionResult> Login([FromBody] LoginRequest loginRequest){ // Authenticate your user however you normally do var user = await AuthenticateUser(loginRequest.Email, loginRequest.Password);
// Step 1: Create a regular session CreateSessionResponse session; try { session = await client.Session.CreateAsync(new CreateSessionCommand { UserId = user.Id, IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(), UserAgent = Request.Headers["User-Agent"].ToString() }); } catch (CreateSessionException) { return StatusCode(500, new { Error = "Could not create session" }); }
// Step 2: Create a stateless token from the session CreateStatelessTokenResponse token; try { token = await client.Session.CreateStatelessTokenAsync(new CreateStatelessTokenCommand { UserId = user.Id, SessionId = session.SessionId, CustomClaims = JsonSerializer.SerializeToElement(new Dictionary<string, object> { { "email", user.Email }, { "role", user.Role }, { "teamId", user.TeamId } }), LifetimeSecs = 900 // 15 minutes }); } catch (CreateStatelessTokenException) { return StatusCode(500, new { Error = "Could not create token" }); }
Response.Cookies.Append("sessionToken", session.SessionToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax });
Response.Cookies.Append("statelessToken", token.StatelessToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax });
return Ok(new { Success = true });}Now your client has two tokens:
- Session token: For operations that need real-time validation
- Stateless token: For high-performance read operations
Validating Stateless Tokens
Section titled “Validating Stateless Tokens”To validate stateless tokens, you need the public key from PropelAuth BYO. Here’s what NOT to do:
app.get("/api/data", async (req, res) => { const token = req.cookies.accessToken;
// DON'T DO THIS - fetches JWKs on every request! // This network call defeats the purpose of stateless tokens const jwks = await client.session.getJwks({});
try { // Using your favorite JWT library const dataInStatelessToken = jwt.verify(token, jwks); // do something with the data } catch (error) { return res.status(401).json({ error: "Invalid token" }); }});@app.get("/api/data")async def get_data(request: Request): token = request.cookies.get("accessToken")
# DON'T DO THIS - fetches JWKs on every request! # This network call defeats the purpose of stateless tokens jwks = await client.session.get_jwks()
try: # Using your favorite JWT library data_in_stateless_token = jwt.decode(token, jwks) # do something with the data except Exception as error: raise HTTPException(status_code=401, detail="Invalid token")@GetMapping("/api/data")public ResponseEntity<?> getData(HttpServletRequest request) { String token = getCookie(request, "accessToken");
// DON'T DO THIS - fetches JWKs on every request! // This network call defeats the purpose of stateless tokens GetJwksResponse jwks; try { jwks = client.session.getJwks(GetJwksCommand.builder().build()); } catch (GetJwksException e) { return ResponseEntity.status(500).body(Map.of("error", "Could not fetch JWKs")); }
try { // Using your favorite JWT library Object dataInStatelessToken = jwt.verify(token, jwks); // do something with the data } catch (Exception error) { return ResponseEntity.status(401).body(Map.of("error", "Invalid token")); }
return ResponseEntity.ok().build();}[HttpGet("/api/data")]public async Task<IActionResult> GetData(){ var token = Request.Cookies["accessToken"];
// DON'T DO THIS - fetches JWKs on every request! // This network call defeats the purpose of stateless tokens GetJwksResponse jwks; try { jwks = await client.Session.GetJwksAsync(new GetJwksCommand()); } catch (GetJwksException) { return StatusCode(500, new { Error = "Could not fetch JWKs" }); }
try { // Using your favorite JWT library var dataInStatelessToken = jwt.Verify(token, jwks); // do something with the data } catch (Exception) { return StatusCode(401, new { Error = "Invalid token" }); }
return Ok();}This defeats the entire purpose! Instead, you’ll want to cache the public key in memory and refresh it periodically.
The Token Refresh Pattern
Section titled “The Token Refresh Pattern”Stateless tokens should be short-lived for security - typically 5 to 15 minutes. When they expire, use the session token to generate a new one:
app.post("/api/refresh", async (req, res) => { // Validate the session is still active const validation = await client.session.validate({ sessionToken: req.cookies.sessionToken, ipAddress: req.ip, });
if (!validation.ok) { return res.status(401).json({ error: "Session expired" }); }
// Generate a new stateless token const token = await client.session.createStatelessToken({ userId: validation.data.userId, sessionId: validation.data.sessionId, customClaims: validation.data.metadata, lifetimeSecs: 900, });
res.cookie("accessToken", token.data.statelessToken, COOKIE_OPTIONS); res.json({ success: true });});@app.post("/api/refresh")async def refresh_token(request: Request, response: Response): # Validate the session is still active validation = await client.session.validate( session_token=request.cookies.get("sessionToken"), ip_address=request.client.host )
if is_err(validation): raise HTTPException(status_code=401, detail="Session expired")
# Generate a new stateless token token = await client.session.create_stateless_token( user_id=validation.data.user_id, session_id=validation.data.session_id, custom_claims=validation.data.metadata, lifetime_secs=900 )
response.set_cookie("accessToken", token.data.stateless_token, **COOKIE_OPTIONS) return {"success": True}@PostMapping("/api/refresh")public ResponseEntity<?> refreshToken(HttpServletRequest request, HttpServletResponse response) { // Validate the session is still active ValidateSessionResponse validation; try { validation = client.session.validate( ValidateSessionCommand.builder() .sessionToken(getCookie(request, "sessionToken")) .ipAddress(request.getRemoteAddr()) .build() ); } catch (ValidateSessionException e) { return ResponseEntity.status(401).body(Map.of("error", "Session expired")); }
// Generate a new stateless token CreateStatelessTokenResponse token; try { token = client.session.createStatelessToken( CreateStatelessTokenCommand.builder() .userId(validation.getUserId()) .sessionId(validation.getSessionId()) .customClaims(validation.getMetadata()) .lifetimeSecs(900) .build() ); } catch (CreateStatelessTokenException e) { return ResponseEntity.status(500).body(Map.of("error", "Could not create token")); }
Cookie cookie = new Cookie("accessToken", token.getStatelessToken()); cookie.setHttpOnly(true); cookie.setSecure(true); cookie.setPath("/"); response.addCookie(cookie);
return ResponseEntity.ok(Map.of("success", true));}[HttpPost("/api/refresh")]public async Task<IActionResult> RefreshToken(){ // Validate the session is still active ValidateSessionResponse validation; try { validation = await client.Session.ValidateAsync(new ValidateSessionCommand { SessionToken = Request.Cookies["sessionToken"], IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString() }); } catch (ValidateSessionException) { return StatusCode(401, new { Error = "Session expired" }); }
// Generate a new stateless token CreateStatelessTokenResponse token; try { token = await client.Session.CreateStatelessTokenAsync(new CreateStatelessTokenCommand { UserId = validation.UserId, SessionId = validation.SessionId, CustomClaims = validation.Metadata, LifetimeSecs = 900 }); } catch (CreateStatelessTokenException) { return StatusCode(500, new { Error = "Could not create token" }); }
Response.Cookies.Append("accessToken", token.StatelessToken, new CookieOptions { HttpOnly = true, Secure = true, SameSite = SameSiteMode.Lax });
return Ok(new { Success = true });}This pattern gives you the best of both worlds:
- Stateless tokens for performance
- Session tokens for control (instant revocation, activity tracking)
This can also be done in middleware to transparently refresh tokens for your users.
Private Key Rotation
Section titled “Private Key Rotation”Stateless tokens are signed with a private key that’s managed by PropelAuth BYO.
Over time, you may want to rotate the keys used to sign your JWTs.
PropelAuth BYO handles this gracefully:
// Standard rotation: transition over 24 hours// This gives your applications time to fetch the new public keyconst rotation = await auth.session.rotateStatelessTokenKey({ secsBeforeNewKeyBecomesDefault: 3600, // 1 hour secsBeforeExistingKeysAreDeactivated: 86400, // 24 hours});
// Emergency rotation: rotate immediately// Something bad has happened - rotate keys right awayconst emergency = await auth.session.rotateStatelessTokenKey({ secsBeforeNewKeyBecomesDefault: 0, // Immediately secsBeforeExistingKeysAreDeactivated: 300, // Keep old key valid for 5 minutes});# Standard rotation: transition over 24 hours# This gives your applications time to fetch the new public keyrotation = await client.session.rotate_stateless_token_key( secs_before_new_key_becomes_default=3600, # 1 hour secs_before_existing_keys_are_deactivated=86400 # 24 hours)
# Emergency rotation: rotate immediately# Something bad has happened - rotate keys right awayemergency = await client.session.rotate_stateless_token_key( secs_before_new_key_becomes_default=0, # Immediately secs_before_existing_keys_are_deactivated=300 # Keep old key valid for 5 minutes)// Standard rotation: transition over 24 hours// This gives your applications time to fetch the new public keyRotateStatelessTokenKeyResponse rotation = client.session.rotateStatelessTokenKey( RotateStatelessTokenKeyCommand.builder() .secsBeforeNewKeyBecomesDefault(3600) // 1 hour .secsBeforeExistingKeysAreDeactivated(86400) // 24 hours .build());
// Emergency rotation: rotate immediately// Something bad has happened - rotate keys right awayRotateStatelessTokenKeyResponse emergency = client.session.rotateStatelessTokenKey( RotateStatelessTokenKeyCommand.builder() .secsBeforeNewKeyBecomesDefault(0) // Immediately .secsBeforeExistingKeysAreDeactivated(300) // Keep old key valid for 5 minutes .build());// Standard rotation: transition over 24 hours// This gives your applications time to fetch the new public keyvar rotation = await client.Session.RotateStatelessTokenKeyAsync(new RotateStatelessTokenKeyCommand{ SecsBeforeNewKeyBecomesDefault = 3600, // 1 hour SecsBeforeExistingKeysAreDeactivated = 86400 // 24 hours});
// Emergency rotation: rotate immediately// Something bad has happened - rotate keys right awayvar emergency = await client.Session.RotateStatelessTokenKeyAsync(new RotateStatelessTokenKeyCommand{ SecsBeforeNewKeyBecomesDefault = 0, // Immediately SecsBeforeExistingKeysAreDeactivated = 300 // Keep old key valid for 5 minutes});During rotation:
- New tokens are signed with the new key after
secsBeforeNewKeyBecomesDefault - Old tokens remain valid until
secsBeforeExistingKeysAreDeactivated - Both keys are available in the JWKs endpoint during transition
Encrypting Private Keys
Section titled “Encrypting Private Keys”Private keys are sensitive. To add an extra layer of security, we will encrypt them at the application level using the STATELESS_TOKEN_ENCRYPTION_KEY environment variable.
STATELESS_TOKEN_ENCRYPTION_KEY=your-encryption-keyYou can generate a secure key using a tool like openssl in your terminal:
openssl rand -base64 32Integrating with External Services
Section titled “Integrating with External Services”Stateless tokens are perfect for integrating with external services that need to validate authentication without calling your backend. Services like Hasura, Supabase, and other GraphQL engines can validate JWTs directly using your public key.
Hasura Example
Section titled “Hasura Example”Hasura can validate JWTs and extract claims for authorization. Include Hasura-specific claims when creating tokens:
const token = await client.session.createStatelessToken({ userId: user.id, sessionId: session.data.sessionId, customClaims: { "https://hasura.io/jwt/claims": { "x-hasura-allowed-roles": ["user", "editor"], "x-hasura-default-role": "user", "x-hasura-user-id": user.id, "x-hasura-org-id": user.organizationId, }, email: user.email, role: user.role, }, lifetimeSecs: 900,});token = await client.session.create_stateless_token( user_id=user.id, session_id=session.data.session_id, custom_claims={ "https://hasura.io/jwt/claims": { "x-hasura-allowed-roles": ["user", "editor"], "x-hasura-default-role": "user", "x-hasura-user-id": user.id, "x-hasura-org-id": user.organization_id }, "email": user.email, "role": user.role }, lifetime_secs=900)CreateStatelessTokenResponse token = client.session.createStatelessToken( CreateStatelessTokenCommand.builder() .userId(user.getId()) .sessionId(session.getData().getSessionId()) .customClaims(JsonValue.of(Map.of( "https://hasura.io/jwt/claims", Map.of( "x-hasura-allowed-roles", List.of("user", "editor"), "x-hasura-default-role", "user", "x-hasura-user-id", user.getId(), "x-hasura-org-id", user.getOrganizationId() ), "email", user.getEmail(), "role", user.getRole() ))) .lifetimeSecs(900) .build());var token = await client.Session.CreateStatelessTokenAsync(new CreateStatelessTokenCommand{ UserId = user.Id, SessionId = session.Data.SessionId, CustomClaims = JsonSerializer.SerializeToElement(new Dictionary<string, object> { { "https://hasura.io/jwt/claims", new Dictionary<string, object> { { "x-hasura-allowed-roles", new[] { "user", "editor" } }, { "x-hasura-default-role", "user" }, { "x-hasura-user-id", user.Id }, { "x-hasura-org-id", user.OrganizationId } } }, { "email", user.Email }, { "role", user.Role } }), LifetimeSecs = 900});Then, you’ll want to expose a JWKs endpoint for Hasura to fetch your public keys:
app.get("/.well-known/jwks.json", async (req, res) => { const jwks = await client.session.getJwks({}); if (!jwks.ok) { return res.status(500).json({ error: "Could not fetch JWKs" }); } res.json(jwks.data);});@app.get("/.well-known/jwks.json")async def jwks_endpoint(): jwks = await client.session.get_jwks() if is_err(jwks): raise HTTPException(status_code=500, detail="Could not fetch JWKs") return jwks.data@GetMapping("/.well-known/jwks.json")public ResponseEntity<?> jwksEndpoint() { try { GetJwksResponse jwks = client.session.getJwks(GetJwksCommand.builder().build()); return ResponseEntity.ok(jwks); } catch (GetJwksException e) { return ResponseEntity.status(500).body(Map.of("error", "Could not fetch JWKs")); }}[HttpGet("/.well-known/jwks.json")]public async Task<IActionResult> JwksEndpoint(){ try { var jwks = await client.Session.GetJwksAsync(new GetJwksCommand()); return Ok(jwks); } catch (GetJwksException) { return StatusCode(500, new { Error = "Could not fetch JWKs" }); }}Now Hasura can validate tokens and apply row-level security without calling your backend.
This pattern works with any service that supports JWT validation - just include the claims they expect when creating your stateless tokens.
Putting It Together
Section titled “Putting It Together”Stateless tokens are a powerful optimization for specific scenarios. They’re not a replacement for sessions - they’re a complement to them. Use regular sessions for your core authentication flow and stateless tokens when you need extreme performance.
Start with regular sessions. When you hit scale issues or need better performance for specific endpoints, add stateless tokens to those hot paths. Your architecture stays simple while gaining the performance exactly where you need it.