DEV Community

Cover image for Cognito session management: sign out devices with Lambda
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Cognito session management: sign out devices with Lambda

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.

Cognito session management: diagram of an SSO session, three token sessions and the effect of LINKED revocation

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)
  • curl and jq for 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 app sessions along
  targets.sort((a, b) => (a.SessionType === "SSO" ? -1 : 0) - (b.SessionType === "SSO" ? -1 : 0));

  const revoked: string[] = [];
  for (const s of targets) {
    try {
      await cognito.send(
        new AdminRevokeUserSessionCommand({
          UserPoolId: userPoolId,
          Username: request.username,
          SessionId: s.SessionId,
          RevokeAssociatedSessions: "LINKED",
        }),
      );
      revoked.push(s.SessionId ?? "");
    } catch (error) {
      // Already ended through a linked session? Then all is well.
      if ((error as Error).name !== "ResourceNotFoundException") throw error;
    }
  }

  return {
    before: sessions.map(toView),
    revoked,
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Tests without an AWS account

With aws-sdk-client-mock you replace the Cognito client in tests. That way you check how the endpoints behave without creating a user pool.

// test/sessions.test.ts
import { mockClient } from "aws-sdk-client-mock";
import {
  CognitoIdentityProviderClient,
  ListUserSessionsCommand,
  RevokeUserSessionCommand,
  type SessionDescriptionType,
} from "@aws-sdk/client-cognito-identity-provider";
import { beforeEach, describe, expect, it } from "vitest";
import { isValidSessionId, maskIp, sortSessions, toView } from "../src/sessions";
import { list, revoke } from "../src/my-sessions";

const cognito = mockClient(CognitoIdentityProviderClient);
const phone = "tkn" + "a".repeat(25);
const laptop = "sso" + "b".repeat(25);

const event = (id?: string) =>
  ({
    headers: { authorization: "Bearer test-token" },
    pathParameters: id ? { id } : undefined,
  }) as never;

beforeEach(() => {
  cognito.reset();
  cognito.on(ListUserSessionsCommand).resolves({
    Sessions: [
      { SessionId: laptop, SessionType: "SSO", LastActiveDate: new Date("2026-11-10T08:00:00Z") },
      {
        SessionId: phone,
        SessionType: "TOKEN",
        IsCurrentSession: true,
        DeviceType: "iOS, Speichenwerk-App",
        LastIPAddress: "203.0.113.42",
        LastLocation: { City: "Hamburg", Country: "Germany" },
        LastActiveDate: new Date("2026-11-12T07:00:00Z"),
      },
    ],
  });
});

describe("helpers", () => {
  it("masks IP addresses", () => {
    expect(maskIp("203.0.113.42")).toBe("203.0.x.x");
    expect(maskIp("2001:db8:85a3::8a2e:370:7334")).toBe("2001:db8:85a3:…");
    expect(maskIp(undefined)).toBe("unknown");
  });

  it("validates session IDs", () => {
    expect(isValidSessionId(phone)).toBe(true);
    expect(isValidSessionId("abc")).toBe(false);
  });

  it("shows the current session first", () => {
    const raw: SessionDescriptionType[] = [
      { SessionId: laptop, SessionType: "SSO" },
      { SessionId: phone, IsCurrentSession: true },
    ];
    const views = sortSessions(raw.map(toView));
    expect(views[0].id).toBe(phone);
    expect(views[1].type).toBe("Browser sign-in");
  });
});

describe("endpoints", () => {
  it("lists sessions with a masked IP", async () => {
    const res = (await list(event())) as { statusCode: number; body: string };
    const body = JSON.parse(res.body);
    expect(res.statusCode).toBe(200);
    expect(body.sessions[0]).toMatchObject({ current: true, ip: "203.0.x.x", location: "Hamburg, Germany" });
    expect(cognito.commandCalls(ListUserSessionsCommand)[0].args[0].input.AccessToken).toBe("test-token");
  });

  it("refuses to end the current session", async () => {
    const res = (await revoke(event(phone))) as { statusCode: number };
    expect(res.statusCode).toBe(409);
    expect(cognito.commandCalls(RevokeUserSessionCommand)).toHaveLength(0);
  });

  it("ends another session including linked sessions", async () => {
    cognito.on(RevokeUserSessionCommand).resolves({});
    const res = (await revoke(event(laptop))) as { statusCode: number };
    expect(res.statusCode).toBe(204);
    expect(cognito.commandCalls(RevokeUserSessionCommand)[0].args[0].input).toMatchObject({
      SessionId: laptop,
      RevokeAssociatedSessions: "LINKED",
    });
  });

  it("returns 401 when the session has already ended", async () => {
    const revoked = Object.assign(new Error("Access Token has been revoked"), { name: "NotAuthorizedException" });
    cognito.on(ListUserSessionsCommand).rejects(revoked);
    const res = (await list(event())) as { statusCode: number };
    expect(res.statusCode).toBe(401);
  });

  it("returns 404 for unknown IDs", async () => {
    const res = (await revoke(event("tkn" + "z".repeat(25)))) as { statusCode: number };
    expect(res.statusCode).toBe(404);
  });
});
Enter fullscreen mode Exit fullscreen mode
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Enter fullscreen mode Exit fullscreen mode
npx tsc --noEmit
npx vitest run
Enter fullscreen mode Exit fullscreen mode

Expected result: TypeScript reports no errors, and Vitest shows eight passing tests.

Step 7: Deploy and turn on session management

Session management is off by default for every user pool. According to the developer guide there are two new CloudFormation resources for it, AWS::Cognito::UserPoolSessionConfigurationAttachment and AWS::Cognito::UserPoolClientSessionConfigurationAttachment. Their reference pages were not published yet when I wrote this, so here I turn the feature on with a small AWS CLI script. Make sure your CLI is up to date, older versions do not know the commands yet.

#!/usr/bin/env bash
# scripts/enable-sessions.sh
# Turns on session management and caps app sessions at 5 per person
set -euo pipefail

POOL_ID="$1"
CLIENT_ID="$2"

aws cognito-idp set-user-pool-session-configuration \
  --user-pool-id "$POOL_ID" \
  --user-session-tracking ENABLED \
  --token-session MaxPerUser=5

# Silently reuse the browser sign-in for at most 8 hours (default: 60 minutes)
aws cognito-idp set-user-pool-client-session-configuration \
  --user-pool-id "$POOL_ID" \
  --client-id "$CLIENT_ID" \
  --sso-session MaxValidityMinutes=480

aws cognito-idp get-user-pool-session-configuration --user-pool-id "$POOL_ID"
Enter fullscreen mode Exit fullscreen mode
serverless deploy
POOL=$(aws cloudformation describe-stacks --stack-name speichenwerk-sessions-dev \
  --query "Stacks[0].Outputs[?OutputKey=='UserPoolId'].OutputValue" --output text)
CLIENT=$(aws cloudformation describe-stacks --stack-name speichenwerk-sessions-dev \
  --query "Stacks[0].Outputs[?OutputKey=='UserPoolClientId'].OutputValue" --output text)
./scripts/enable-sessions.sh "$POOL" "$CLIENT"
Enter fullscreen mode Exit fullscreen mode

The last output of the script should show "UserSessionTracking": "ENABLED" and "MaxPerUser": 5. As long as tracking is off, Cognito stores no session records. So sign in for the test only after turning it on.

Step 8: Try it with two sign-ins

Create a test user and sign in twice. Each sign-in creates its own token session, just as if there were two devices. serverless deploy prints the API URL at the end.

API=https://abc123.execute-api.eu-central-1.amazonaws.com
aws cognito-idp admin-create-user --user-pool-id "$POOL" \
  --username lena@example.com --message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id "$POOL" \
  --username lena@example.com --password 'Saddle-Chain-2026!' --permanent

login() {
  aws cognito-idp initiate-auth --client-id "$CLIENT" --auth-flow USER_PASSWORD_AUTH \
    --auth-parameters USERNAME=lena@example.com,PASSWORD='Saddle-Chain-2026!' \
    --query AuthenticationResult.AccessToken --output text
}
PHONE=$(login)
TABLET=$(login)

curl -s -H "Authorization: Bearer $PHONE" "$API/sessions" | jq
Enter fullscreen mode Exit fullscreen mode

You see two entries of type “App session”. The first one has "current": true, because you ask with the “phone” token. Copy the id of the second entry and end it:

curl -i -X DELETE -H "Authorization: Bearer $PHONE" "$API/sessions/tkn..."
# HTTP/2 204

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TABLET" "$API/sessions"
# 401
Enter fullscreen mode Exit fullscreen mode

The “tablet” now gets a 401 because our function asks Cognito. An endpoint that only relies on the JWT authorizer and never talks to Cognito would still accept the token for up to 15 minutes. That brings us to the most important point of the whole article.

Choosing session limits and SSO validity

With TokenSession.MaxPerUser you cap the number of concurrent token sessions per person. The default is 20, the maximum is 100. The limit applies across all app clients of the pool. When it is exceeded, Cognito does not reject the new sign-in, it ends the least recently used session instead. For the bike rental I picked 5: phone, tablet, two browsers and a little buffer. SSO sessions have a fixed limit of 10 per person.

If you lower the limit later, nothing happens at first. Only the next sign-in cleans up the extra sessions. That is friendly to existing users, but it can confuse support when someone is suddenly signed out on an old device after the change.

Per app client, SSOSession.MaxValidityMinutes defines how long it silently reuses an existing SSO session. So far this was fixed at 60 minutes, now anything from 0 to 129,600 minutes (90 days) is possible. I would set an admin portal to 0, so it always asks for a fresh sign-in. A customer app can be more generous. Independently of that, every SSO session ends 90 days after the last real sign-in at the latest.

Common mistakes with Cognito session management

The access token keeps working after sign-out

Cognito tokens are self-contained JWTs. A library that only checks signature and expiry knows nothing about revocation. This applies to the API Gateway JWT authorizer as much as to your own check with a JWT library. Keep access tokens short, 5 to 15 minutes is a sensible range. For particularly sensitive actions, such as changing bank details, you can also ask Cognito on the server side, for example with GetUser.

FeatureUnavailableInTierException when turning it on

Session management only exists in the Essentials and Plus feature plans. A pool on the Lite plan returns exactly this error when you turn it on. The reverse also holds: to downgrade a pool with active session management to Lite, switch the feature off first.

You cannot end your own session

RevokeUserSession will not end the session tied to the access token you call it with. For “sign out here”, RevokeToken with the refresh token is the right tool. Our API catches the case and answers with 409 and a clear message.

Calls with tokens from managed login fail

The calls with an access token belong to the self-service operations of Cognito. Tokens from managed login need the aws.cognito.signin.user.admin scope for them. If it is missing from the allowed OAuth scopes of the app client, Cognito rejects the call. Tokens from a direct sign-in through the SDK, as in our test, get it automatically.

More pitfalls

Machine-to-machine tokens from the client credentials flow have no refresh token and therefore never appear in a session list. The list itself is eventually consistent: a fresh sign-in usually shows up after a few seconds. And in user pools spanning several Regions, Cognito counts the limit per Region, so near-simultaneous sign-ins can briefly exceed it before Cognito cleans up.

Cost, privacy and security

AWS lists no separate price for session management, what counts is the feature plan. Essentials and Lite come with a permanent free tier of 10,000 monthly active users for direct and social sign-ins, beyond that you pay per active user. The Lambda functions and the HTTP API cost practically nothing for a device overview, because they only run on a click.

IP address and location are personal data. Show them to the person themselves, since that is exactly how they recognize a foreign device. Full values have no place in logs, analytics or support tickets, though. That is why sessions.ts masks the IP in the backend already. The support function needs IAM permissions for two admin calls, and in the example all functions share one role. For production I would give each function its own role, so the public endpoints do not inherit admin permissions.

My take

I think the feature is overdue and well done. Anyone who wanted a device list so far had to track sessions themselves, typically in a DynamoDB table filled by a pre token generation trigger. That works, but it is code nobody enjoys maintaining, and it is always a bit imprecise. Having Cognito do this bookkeeping itself, including device type and location, removes a whole class of bugs.

Still, I would not underestimate two things. First, the basic problem of self-contained tokens remains: if you offer “sign out device”, you must keep the access token lifetime short, or your promise to users is only half true. Second, the option is tied to the paid feature plan. For many apps Essentials is a given anyway, but if you deliberately stayed on Lite, run the numbers first.

My advice for getting started: turn on session management in a test environment first and check whether you have a pre token generation trigger with its own sid. After that, the “My devices” page takes little code, and your support team finally has a finer tool than the global sign-out.

FAQ

Does Cognito session management cost extra?

AWS lists no separate price. You do need a user pool on the Essentials or Plus feature plan, which are billed per monthly active user. Essentials has a permanent free tier of 10,000 active users.

Can I fetch the sessions directly from the frontend?

Yes, ListUserSessions and RevokeUserSession only need the access token. A Lambda backend in between pays off when you want to shorten data such as the IP address, log calls or add your own rules.

What is the difference between RevokeToken and RevokeUserSession?

Both end a token session together with its tokens. RevokeToken needs the refresh token and fits the normal sign-out on your own device. RevokeUserSession works with the session ID and suits other devices, such as a lost phone.

Does it work with managed login and social sign-in?

Yes. Managed login additionally creates an SSO session whose validity you set per app client. For the self-service calls, the access token then needs the aws.cognito.signin.user.admin scope.

Your next step

Look at your user pools and check which feature plan they use and how long your access tokens stay valid. If the lifetime is one hour or more, that is the first lever, even before you build a device page. After that, deploy the example from this article to a test environment and try it with two sign-ins. How do you handle “My devices” today: with your own table, with a global sign-out or not at all?

If an AI agent calls other APIs on behalf of your users, Cognito token exchange helps: the agent gets its own narrowly scoped token instead of the original one.

All code from this article, ready to run: smirowstanitzok/sascha-ms-examples

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

📬 Stay updated: New articles on serverless, AI and leading tech teams via RSS, browser push or the weekly newsletter (in German).

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)