Cognito session management answers a question that every app with a login hears sooner or later: “Where am I actually signed in, and how do I kick out that old tablet?” Until now, Amazon Cognito gave you two options: sign the user out everywhere at once, or build your own table of refresh tokens. Since November 11, 2026, Cognito can list the sessions of an account by itself and end them one at a time.
I care about this because a “My devices” page is no longer a luxury. People who share an account, lose a phone or suspect misuse simply expect it. In this article you build that page with the Serverless Framework, TypeScript and two small Lambda functions for a fictional bike rental called “Speichenwerk”. On top, there is a support function your team can use to end suspicious sessions.
In short
With the new session management, Cognito lists every active sign-in of an account across devices and app clients and ends them individually, optionally together with linked sessions. You have to turn the feature on per user pool, and it requires the Essentials or Plus feature plan. The biggest pitfall: an access token that was already issued stays valid until it expires if your API only checks it locally.
What you will learn in this article
- how SSO sessions and token sessions differ in Cognito
- how to build a “My devices” API with HTTP API, JWT authorizer and Lambda
- how support can end sessions, including linked sessions
- how to pick session limits and SSO validity per app client
- why short-lived access tokens matter after a sign-out
What is Cognito session management?
Cognito session management is an extension of user pools, the user directory in Amazon Cognito. Once it is on, Cognito keeps a server-side record for every sign-in: when it started, when it was last active, which device type it came from, which IP address and which city. New API calls let you read that list and end individual entries.
Before, Cognito offered essentially two tools. RevokeToken revokes a refresh token the app still holds. GlobalSignOut signs the user out everywhere. The middle ground was missing: signing out one specific device whose tokens you never see. That is exactly the gap the feature fills.
SSO sessions and token sessions
Cognito knows two kinds of sessions. An SSO session is created when someone signs in through managed login, the sign-in page that Cognito hosts. It lives in a browser cookie and lets the user open other app clients of the same user pool without typing the password again. A token session is created on every sign-in to an app client and follows the lifecycle of the refresh token that client received.
One detail helps a lot: a token refresh does not create a new session, it extends the existing one. Only a real new sign-in creates a new token session. If that sign-in came through an SSO session, the token session remembers its “parent” in the field OriginSessionId.
One SSO session can carry several token sessions, a direct sign-in through the SDK has no SSO session.
The sid claim in the token
With session management turned on, Cognito adds a sid claim to ID and access tokens. A claim is a field inside the JSON Web Token (JWT). Its value stays the same across all refreshes of a session, so you can use it in logs or for your own checks. Watch out if you have a pre token generation trigger that sets its own sid: Cognito leaves your claim alone, and session management has no effect for the whole pool.
Cognito session management compared to RevokeToken
Before you build anything, it helps to put the tools side by side. The new calls do not replace the old ones, they cover different situations.
| Call | Who calls it? | What for? |
|---|---|---|
RevokeToken | App with refresh token | normal sign-out on the current device |
ListUserSessions | User with access token | show “My devices” |
RevokeUserSession | User with access token | sign out another device of your own |
AdminListUserSessions | Backend with IAM permissions | support view, suspected misuse |
AdminRevokeUserSession | Backend with IAM permissions | end one specific session |
GlobalSignOut / AdminUserGlobalSignOut | User or backend | end everything, emergency |
When you end a session, the parameter RevokeAssociatedSessions decides whether linked sessions go too. With NONE (the default), only the target session ends. With LINKED, ending an SSO session also ends every token session that came from it, and ending a token session also ends its SSO session. Other token sessions under the same SSO session stay alive in that case. For “sign out device”, LINKED is almost always right, otherwise the device quietly signs in again through the browser cookie.
🎬 Signing out a device remotely – this animated diagram only runs on my site: watch it on sascha-ms.de
Prerequisites for the Speichenwerk API
- an AWS account and the AWS CLI with permissions for Cognito, Lambda, API Gateway and CloudFormation
- Node.js 22 or newer and npm
- Serverless Framework v4 (
npm i -g serverless, sign in on first use) -
curlandjqfor the terminal test - a user pool on the Essentials or Plus feature plan (the example creates one)
The architecture is deliberately small. An HTTP API with a JWT authorizer accepts the requests. I explained in detail why I pick HTTP API over REST API in REST API vs HTTP API: Choosing API Gateway. Two Lambda functions pass the access token on to Cognito, a third function is meant for support only and has no HTTP endpoint.
Step by step: Cognito session management with Lambda
Step 1: Create the project
mkdir speichenwerk-sessions && cd speichenwerk-sessions npm init -y npm i @aws-sdk/client-cognito-identity-provider npm i -D typescript @types/aws-lambda @types/node vitest aws-sdk-client-mock mkdir src test scripts The session commands ship with the AWS SDK for JavaScript v3 from version 3.1167.0. With an older version, ListUserSessionsCommand and friends are missing and TypeScript complains right away. If in doubt, check the installed version with npm ls @aws-sdk/client-cognito-identity-provider.
Step 2: Write serverless.yml
The file creates the user pool, the app client, the HTTP API with its authorizer and the three functions. Two details matter for session management: UserPoolTier: ESSENTIALS and the short access token lifetime of 15 minutes.
# serverless.yml service: speichenwerk-sessions frameworkVersion: "4" provider: name: aws runtime: nodejs24.x region: eu-central-1 architecture: arm64 memorySize: 256 timeout: 10 logRetentionInDays: 14 httpApi: authorizers: cognito: identitySource: $request.header.Authorization issuerUrl: Fn::Join: - "" - - https://cognito-idp. - Ref: AWS::Region - .amazonaws.com/ - Ref: UserPool audience: - Ref: UserPoolClient iam: role: statements: # Only the support function needs this; use one role per function in production - Effect: Allow Action: - cognito-idp:AdminListUserSessions - cognito-idp:AdminRevokeUserSession Resource: Fn::GetAtt: [UserPool, Arn] functions: listSessions: handler: src/my-sessions.list events: - httpApi: method: GET path: /sessions authorizer: name: cognito revokeSession: handler: src/my-sessions.revoke events: - httpApi: method: DELETE path: /sessions/{id} authorizer: name: cognito support: handler: src/support.handler environment: USER_POOL_ID: Ref: UserPool resources: Resources: UserPool: Type: AWS::Cognito::UserPool Properties: UserPoolName: speichenwerk-customers # Session management is only available in the Essentials and Plus feature plans UserPoolTier: ESSENTIALS UsernameAttributes: [email] AutoVerifiedAttributes: [email] UserPoolClient: Type: AWS::Cognito::UserPoolClient Properties: ClientName: speichenwerk-app UserPoolId: Ref: UserPool GenerateSecret: false EnableTokenRevocation: true # USER_PASSWORD_AUTH only for the CLI test, prefer USER_AUTH or SRP in the app ExplicitAuthFlows: - ALLOW_USER_PASSWORD_AUTH - ALLOW_REFRESH_TOKEN_AUTH # Short access tokens: ended sessions lose their effect quickly AccessTokenValidity: 15 IdTokenValidity: 15 RefreshTokenValidity: 30 TokenValidityUnits: AccessToken: minutes IdToken: minutes RefreshToken: days Outputs: UserPoolId: Value: Ref: UserPool UserPoolClientId: Value: Ref: UserPoolClient The JWT authorizer checks the signature, expiry and issuer of the token. Cognito access tokens have no aud claim, so API Gateway compares the client_id claim with the configured audience. If you need more control, for example for API keys, a custom Lambda authorizer for HTTP APIs helps.
Step 3: Prepare sessions for display
Cognito returns IP address and location unfiltered. A device overview only needs a rough hint, though, and less personal data in the frontend is always a good idea. This file has no AWS dependency, which makes it easy to test.
// src/sessions.ts import type { SessionDescriptionType } from "@aws-sdk/client-cognito-identity-provider"; // What a session looks like in the “Speichenwerk” app export interface SessionView { id: string; type: "Browser sign-in" | "App session"; device: string; lastActive: string | null; location: string; ip: string; current: boolean; } // Show only the first two blocks of an IPv4 address, the first three for IPv6 export function maskIp(ip?: string): string { if (!ip) return "unknown"; if (ip.includes(".")) { const [a, b] = ip.split("."); return `${a}.${b}.x.x`; } return ip.split(":").slice(0, 3).join(":") + ":…"; } export function toView(session: SessionDescriptionType): SessionView { const city = session.LastLocation?.City; const country = session.LastLocation?.Country; return { id: session.SessionId ?? "", type: session.SessionType === "SSO" ? "Browser sign-in" : "App session", device: session.DeviceType ?? "unknown device", lastActive: session.LastActiveDate ? session.LastActiveDate.toISOString() : null, location: [city, country].filter(Boolean).join(", ") || "unknown", ip: maskIp(session.LastIPAddress), current: session.IsCurrentSession === true, }; } // Current session first, then the most recently active ones export function sortSessions(list: SessionView[]): SessionView[] { return [...list].sort((a, b) => { if (a.current !== b.current) return a.current ? -1 : 1; return (b.lastActive ?? "").localeCompare(a.lastActive ?? ""); }); } // Token sessions start with “tkn”, SSO sessions with “sso” (28 characters) export function isValidSessionId(id: string | undefined): id is string { return typeof id === "string" && /^(tkn|sso)[A-Za-z0-9_-]{25}$/.test(id); } Step 4: The endpoints for “My devices”
This is where the real work happens. The function takes the access token from the Authorization header and calls ListUserSessions or RevokeUserSession with it. Because Cognito authorizes these calls through the token, the Lambda function needs no IAM permissions for them. Before ending anything, we also check that the session really belongs to the account and is not the current one.
// src/my-sessions.ts import { CognitoIdentityProviderClient, ListUserSessionsCommand, RevokeUserSessionCommand, type SessionDescriptionType, } from "@aws-sdk/client-cognito-identity-provider"; import type { APIGatewayProxyEventV2WithJWTAuthorizer, APIGatewayProxyResultV2, } from "aws-lambda"; import { isValidSessionId, sortSessions, toView } from "./sessions"; const cognito = new CognitoIdentityProviderClient({}); const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({ statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); // The JWT authorizer has already checked the token, we pass it on to Cognito function accessToken(event: APIGatewayProxyEventV2WithJWTAuthorizer): string { const header = event.headers.authorization ?? ""; return header.replace(/^Bearer\s+/i, ""); } async function loadAll(token: string): Promise<SessionDescriptionType[]> { const all: SessionDescriptionType[] = []; let nextToken: string | undefined; do { const page = await cognito.send( new ListUserSessionsCommand({ AccessToken: token, NextToken: nextToken }), ); all.push(...(page.Sessions ?? [])); nextToken = page.NextToken; } while (nextToken); return all; } // Revoked session: the JWT is still valid, but Cognito rejects it function isRevoked(error: unknown): boolean { return (error as Error).name === "NotAuthorizedException"; } // GET /sessions export async function list( event: APIGatewayProxyEventV2WithJWTAuthorizer, ): Promise<APIGatewayProxyResultV2> { try { const sessions = await loadAll(accessToken(event)); return json(200, { sessions: sortSessions(sessions.map(toView)) }); } catch (error) { if (isRevoked(error)) return json(401, { message: "Session ended, please sign in again" }); throw error; } } // DELETE /sessions/{id} export async function revoke( event: APIGatewayProxyEventV2WithJWTAuthorizer, ): Promise<APIGatewayProxyResultV2> { const id = event.pathParameters?.id; if (!isValidSessionId(id)) return json(400, { message: "Invalid session ID" }); const token = accessToken(event); let own: SessionDescriptionType[]; try { own = await loadAll(token); } catch (error) { if (isRevoked(error)) return json(401, { message: "Session ended, please sign in again" }); throw error; } const target = own.find((s) => s.SessionId === id); if (!target) return json(404, { message: "Session not found" }); if (target.IsCurrentSession) { // Cognito rejects this anyway, we return a clear answer return json(409, { message: "Use “Sign out” to end your current session." }); } await cognito.send( new RevokeUserSessionCommand({ AccessToken: token, SessionId: id, // For an SSO session this also ends every app session that came from it RevokeAssociatedSessions: "LINKED", }), ); return { statusCode: 204 }; } The isRevoked block looks unnecessary at first, but it teaches something. After a session has ended, the JWT authorizer still considers its access token valid, because it only checks signature and expiry. Only Cognito itself rejects the token with NotAuthorizedException, and we turn that into a clean 401.
Step 5: The support function
For support there are the admin variants. They need IAM permissions and take the username instead of a token. Without a list, the function ends all SSO sessions and all direct sign-ins, each with LINKED. That is more precise than a global sign-out, and the return value shows you what was active before.
// src/support.ts import { AdminListUserSessionsCommand, AdminRevokeUserSessionCommand, CognitoIdentityProviderClient, type SessionDescriptionType, } from "@aws-sdk/client-cognito-identity-provider"; import { toView } from "./sessions"; const cognito = new CognitoIdentityProviderClient({}); const userPoolId = process.env.USER_POOL_ID ?? ""; export interface SupportRequest { username: string; // Without a list, all browser sign-ins and their app sessions are ended sessionIds?: string[]; } async function loadAll(username: string): Promise<SessionDescriptionType[]> { const all: SessionDescriptionType[] = []; let nextToken: string | undefined; do { const page = await cognito.send( new AdminListUserSessionsCommand({ UserPoolId: userPoolId, Username: username, NextToken: nextToken }), ); all.push(...(page.Sessions ?? [])); nextToken = page.NextToken; } while (nextToken); return all; } // Invoked directly by the support team (no HTTP endpoint) export async function handler(request: SupportRequest) { if (!request.username) throw new Error("username is missing"); const sessions = await loadAll(request.username); const targets = request.sessionIds?.length ? sessions.filter((s) => request.sessionIds!.includes(s.SessionId ?? "")) : sessions.filter((s) => s.SessionType === "SSO" || !s.OriginSessionId); // SSO sessions first: LINKED takes their ap...


