Skip to content

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.

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.

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

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.

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.

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 key
const rotation = await auth.session.rotateStatelessTokenKey({
secsBeforeNewKeyBecomesDefault: 3600, // 1 hour
secsBeforeExistingKeysAreDeactivated: 86400, // 24 hours
});
// Emergency rotation: rotate immediately
// Something bad has happened - rotate keys right away
const 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 key
rotation = 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 away
emergency = 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 key
RotateStatelessTokenKeyResponse 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 away
RotateStatelessTokenKeyResponse 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 key
var 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 away
var 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

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-key

You can generate a secure key using a tool like openssl in your terminal:

Terminal window
openssl rand -base64 32

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 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.

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.