top of page
Search

Secure Session Workflows with AWS KMS-Signed JWTs (JWS)

Setting the scene


How do you trust a client’s session during upload without re-running expensive validation logic or exposing yourself to tampering?


In this post, we’ll walk through a production-grade pattern using:


- Short-lived signed JWTs (JWS)

- AWS KMS for signing & verification

- A token chaining model that enforces trust across steps


The Goal


We wanted to guarantee:


- A session is validated exactly once

- Uploads are cryptographically bound to that validation

- No reliance on:

- Long-lived credentials

- Re-validating session ownership on every request


The Problem


Our session lifecycle has two phases:


1. Validation


Client proves ownership of a key and receives:


- Session state

- Progress info


2. Upload


Client submits gameplay events tied to that session


The Naive Approach


POST /upload_session

→ Accept key + session_key

→ Re-run validation logic or accept the data

Why this fails:


- Tight coupling between upload & validation

- Now the upload service must answer questions like:

- Does this session belong to this key?

- Was the session already validated?

- Is the session still active?

- Was it already completed?


- Vulnerable to session key substitution

- If the backend only checks:

- “Does this session exist?” => Then uploads may be accepted for the wrong session.


- Easy to accept events for unauthorized sessions

- Without a signed validation token, the upload endpoint has no proof that:

- Validation actually happened

- The session was approved

- The uploader is authorized for that session


- Example

1. Client validates Session A

2. Client modifies request

3. Uploads events for Session B

4. Server accepts upload



The Key Idea


“If validation already proved this session is valid — just sign that fact.”


Instead of re-validating:


- Issue a signed token after validation

- Require that token for upload


The Token Chain Architecture


We introduce three endpoints and two token types:


High-Level Flow




Step-by-Step Breakdown


1) GET /prevalidate/key:key


Purpose


- Validate the key

- Return session state only (no session creation)


Output


A prevalidation token


Example Claims

{
  "key": "k_abc123",
  "sessionStatus": "NotStarted" | "Started" | "Completed",
  "timestamp": "2024-01-15T10:00:00Z"
}

2) POST /validate


Input

- Bearer token: prevalidation JWT


What happens

- Verify token via KMS

- Enforce token type (cty = prevalidation)

- Run validation logic

- Create or resume session


Output


A session token

{
  "sessionKey": "sk_xyz789",
  "key": "kk_abc123",
  "timestamp": "2024-01-15T10:01:00Z"
}

3) POST /upload_session


Input

- Bearer token: validation JWT


What happens

- Verify token via KMS

- Extract sessionKey from token

- Insert events using that key


Request Example

{
  "key": "kk_abc123",
  "session_events": {
    "data": [
      ...
    ]
  }
}

Token Design


Two Token Types


Property

Prevalidation

Validation

cty

prevalidation

validation

sessionKey

Optional

Required

Used by

validate

upload


JWT Structure


Header:  { "alg": "RS256", "kid": "<kms-key-id>", "cty": "session" }

Payload: { ...claims, "iat": ..., "exp": ... }

Signing Flow



We use:


- RSA asymmetric key

- Algorithm: RSASSA_PKCS1_V1_5_SHA_256 (→ JWT RS256)


Flow

- Build header.payload

- Send to KMS Sign

- Encode signature

- Construct final JWT


private def signString(keyId: String, unsignedString: String): App[String] =
  RIO.ask[Env].mapIO { env =>
    IO.fromCompletableFuture(IO(
      env.kmsClient.sign(
        SignRequest.builder()
          .keyId(keyId)
          .message(SdkBytes.fromUtf8String(unsignedString))   .signingAlgorithm(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256)
          .build()
      ))).map(_.signature())
       .map(sdkBytes => JwtBase64.encodeString(sdkBytes.asByteArray()))
  }

Verification with AWS KMS




Steps:

- Decode JWT (skip signature, enforce expiration)

- Validate kid

- Call KMS Verify

- Parse claims by cty


private def verifyWithKms(keyId: String, signedSessionString: String, signature: String): App[Unit] =
  RIO.ask[Env].mapIO { env =>
    val unsignedTokenString = signedSessionString.substring(0, signedSessionString.lastIndexOf('.'))
    IO.fromCompletableFuture(IO(
      env.kmsClient.verify(
        VerifyRequest.builder()
          .keyId(keyId)
          .message(SdkBytes.fromUtf8String(unsignedTokenString))
    .signature(SdkBytes.fromByteArray(JwtBase64.decode(signature)))  .signingAlgorithm(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256)
          .build()
      )
    )).flatMap { vr =>
      if (vr.signatureValid().booleanValue()) IO.unit
      else IO.raiseError(InvalidSessionVerification("The token did not pass signature verification"))

    }

  }

Error Handling

{
  "result": "failure",
  "code": 1043,
  "message": "Error authenticating session token"
}

| Code | Meaning |

| ---- | ---------------- |

| 1043 | Invalid token |

| 1017 | Key expired |

| 1011 | Key not found |

| 1041 | Duplicate upload |

| 1042 | Session locked |

| 1044 | Wrong type of token |


Final Architecture Insight



Why This Pattern Works

- Eliminates re-validation overhead

- Prevents session tampering

- Cryptographic guarantees

- Scales cleanly across services


Summary

- Validation happens once

- Trust is enforced via signed tokens

- Upload becomes:

- Stateless

- Fast

- Secure

 
 
 

Recent Posts

See All
Supporting Robolectric tests in Mill

While building Android support for the Mill build tool, our approach has been to treat the Android Gradle Plugin (AGP) as a black box at first, inspect what it produces, and replicate its behaviour

 
 
 

Comments


Archiepiskopou Makariou Iii 42
2574 Sia
Lefkosia - Cyprus

engineering@vaslabs.io

  • Instagram
  • Facebook
  • LinkedIn
  • X

©2025 Vaslabs LTD

bottom of page