Cognito and JWT Authorizer: Only Logged-In Users Can Create Links

K
Kai··6 min read

By the end of the previous article, every link was created under the name user-001, a string hard-coded in the code. There was no real person there, and anyone calling the API could create a link. This article adds real users with Cognito, then builds a door: only requests carrying a valid token get through the create-link route, while the open-link route stays public because the whole world needs to be able to open a short link.

Goal

Stand up a Cognito user pool that issues JWTs on login, attach the HTTP API's JWT authorizer to protect POST /links, keep GET /{code} public, and have create-link read the user identity from a claim in the token. We create a real user, get a real token, and call the API in both states, with and without a token. Cognito has a generous free tier, so the cost of this part is negligible.

What a Cognito user pool is

A user pool is an AWS-managed user directory: it stores accounts, handles sign-up, email verification, login, password changes, and most important for us, it issues tokens on successful login. That token is a JSON Web Token (JWT), a digitally signed string containing claims about the user (such as sub, the user's unique identifier). Because the token is signed, anyone with the pool's public key can verify whether it's genuine or forged without asking Cognito again.

This is where it fits with the HTTP API. Article 03 chose the HTTP API partly because it has a native JWT authorizer. Now we use exactly that feature.

What the JWT authorizer checks

When a route is attached to a JWT authorizer, API Gateway verifies the token itself before calling Lambda. The documentation describes the steps: take the token from the header, decode it, "Check the token's algorithm and signature by using the public key that is fetched from the issuer's jwks_uri", then validate the claims, including iss (the issuer must match the configured pool), aud (the audience must match the app client id), and exp (not expired). If any step fails, "API Gateway denies the API request."

The key point for the code: after the token passes, "API Gateway passes the claims in the token to the API route's integration." Lambda reads the claims at event.requestContext.authorizer.jwt.claims. That means the handler doesn't have to decode or check the token itself; by the time the code runs, the identity is already authenticated and sitting in the event.

   1. Client logs in to Cognito ──▶ Cognito issues JWT (ID token, signed)
                                         │
   2. Client calls API with        Authorization: Bearer <JWT>
      Authorization header ───────────────┐
                                         ▼
   3. API Gateway (JWT authorizer): checks signature via jwks_uri,
      checks iss/aud/exp                  │
        ├── fail ─▶ 401, does NOT call Lambda
        └── pass ─▶ passes claims into the event
                                         ▼
   4. Lambda reads event.requestContext.authorizer.jwt.claims.sub

Standing up Cognito in SAM

Two resources: a user pool, and an app client (representing the application logging in to the pool):

UserPool:
  Type: AWS::Cognito::UserPool
  Properties:
    UserPoolName: url-shortener-users
    UsernameAttributes: [email]
    AutoVerifiedAttributes: [email]
    Policies:
      PasswordPolicy:
        MinimumLength: 8
        RequireUppercase: true
        RequireLowercase: true
        RequireNumbers: true
        RequireSymbols: false

UserPoolClient:
  Type: AWS::Cognito::UserPoolClient
  Properties:
    UserPoolId: !Ref UserPool
    ClientName: url-shortener-client
    GenerateSecret: false
    ExplicitAuthFlows:
      - ALLOW_USER_PASSWORD_AUTH
      - ALLOW_ADMIN_USER_PASSWORD_AUTH
      - ALLOW_REFRESH_TOKEN_AUTH

UsernameAttributes: [email] lets users log in by email. GenerateSecret: false creates a public client with no client secret, which fits a browser-based app (where a secret can't be kept). ExplicitAuthFlows enables the login flows we'll use to get a token.

Attach the authorizer, protect the right route

Declare an authorizer on the HTTP API, pointing at the pool's issuer and an audience of the app client:

HttpApi:
  Type: AWS::Serverless::HttpApi
  Properties:
    Auth:
      Authorizers:
        CognitoAuthorizer:
          IdentitySource: "$request.header.Authorization"
          JwtConfiguration:
            issuer: !Sub "https://cognito-idp.${AWS::Region}.amazonaws.com/${UserPool}"
            audience:
              - !Ref UserPoolClient

Deliberately do not set a DefaultAuthorizer. If you set a default, every route is protected, including GET /{code}, and no one can open a short link. Instead, attach the authorizer only to the create-link route:

CreateLinkFunction:
  ...
  Events:
    Create:
      Type: HttpApi
      Properties:
        Path: /links
        Method: POST
        Auth:
          Authorizer: CognitoAuthorizer

ResolveLinkFunction declares no Auth, so the GET /{code} route is public. This is the right design decision for a URL shortener: creating a link requires login, opening one does not.

Read identity from the claim

The create-link handler now takes ownerId from the sub claim instead of a constant:

export const handler = async (
  event: APIGatewayProxyEventV2WithJWTAuthorizer
): Promise<APIGatewayProxyResultV2> => {
  const OWNER = event.requestContext.authorizer?.jwt?.claims?.sub as string;
  if (!OWNER) return json(401, { error: "thieu danh tinh nguoi dung" });
  // ...rest as in Article 06, using OWNER for ownerId and GSI1PK...
};

sub is the user's stable identifier in the pool, unchanged even if they change their email, so using it as the owner key is correct.

Test for real

Calling POST /links without a token is blocked right at the gateway layer with 401, and Lambda is never called:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST "$API/links" \
    -H 'content-type: application/json' -d '{"url":"https://aws.amazon.com/"}'
401

Create a demo user and set a permanent password to skip the forced first-login password change:

aws cognito-idp admin-create-user --user-pool-id "$POOL" \
  --username demo@example.com --message-action SUPPRESS
aws cognito-idp admin-set-user-password --user-pool-id "$POOL" \
  --username demo@example.com --password 'Demo-Pw-12345' --permanent

Log in to get the ID token:

IDTOKEN=$(aws cognito-idp admin-initiate-auth --user-pool-id "$POOL" --client-id "$CLIENT" \
  --auth-flow ADMIN_USER_PASSWORD_AUTH \
  --auth-parameters USERNAME=demo@example.com,PASSWORD=Demo-Pw-12345 \
  --query 'AuthenticationResult.IdToken' --output text)

The token is a JWT; the middle part (payload) base64-decodes to the claims, among them sub:

$ echo "$IDTOKEN" | cut -d. -f2 | base64 -d | sed 's/.*"sub":"//;s/".*//'
e9dae5dc-e001-7019-c242-53fd9434f6b3

Now call POST /links again with the token. This time it passes:

$ curl -s -X POST "$API/links" -H "Authorization: Bearer $IDTOKEN" \
    -H 'content-type: application/json' -d '{"url":"https://aws.amazon.com/cognito/"}'
{"code":"EFgrT5P","shortUrl":".../EFgrT5P","target":"https://aws.amazon.com/cognito/"}

Proof that the handler really reads the identity from the token: the item just created has ownerId equal to the demo user's sub, and GSI1PK is built from it too:

$ aws dynamodb get-item --table-name url-shortener \
    --key '{"PK":{"S":"LINK#EFgrT5P"},"SK":{"S":"META"}}' \
    --query 'Item.{ownerId:ownerId.S,GSI1PK:GSI1PK.S}'
{
    "ownerId": "e9dae5dc-e001-7019-c242-53fd9434f6b3",
    "GSI1PK": "USER#e9dae5dc-e001-7019-c242-53fd9434f6b3"
}

And a forged token is rejected because its signature doesn't match the pool's public key:

$ curl -s -o /dev/null -w "%{http_code}\n" -X POST "$API/links" \
    -H "Authorization: Bearer not.a.realtoken" -H 'content-type: application/json' -d '{"url":"https://x.com/"}'
401

API Gateway does all the token verification; the handler only sees requests that got through the door, and sees the caller's identity along with them.

🧹 Cleanup

Delete the demo user and link, keep the user pool for later articles:

aws dynamodb delete-item --table-name url-shortener \
  --key '{"PK":{"S":"LINK#EFgrT5P"},"SK":{"S":"META"}}'
aws cognito-idp admin-delete-user --user-pool-id "$POOL" --username demo@example.com

Wrap-up

The URL shortener now has real users. Cognito issues a JWT on login, the HTTP API's JWT authorizer checks the token right at the gateway and only lets valid requests reach Lambda, and the handler reads the identity from the sub claim instead of hard-coding it. The create-link route is protected, the open-link route stays public, exactly as a URL shortener needs.

Each link is now tied to a real owner, but we haven't yet used that to separate data. The next article turns the system into a real multi-tenant one: list links by user via the GSI built in Article 05, and more importantly, ensure one person can't edit or delete another person's link, even if they guess the code right, that is, close the IDOR vulnerability right in the data access layer.