Stateless Tokens (JWTs) | PropelAuth BYO Documentation

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

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:

Keep using regular sessions when:

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

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.

Node

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

Python

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}

Java

@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));
}

Validating Stateless Tokens

To validate stateless tokens, you need the public key from PropelAuth BYO. Here’s what NOT to do:

Node

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" });
    }
});

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:

Node

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

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.