Daily Actions: Save data in DynamoDB through an API Gateway and Lambda secured by Cognito

Daily Actions: Save data in DynamoDB through an API Gateway and Lambda secured by Cognito

In the previous article, our static Next.js application learned to authenticate users with Amazon Cognito. But a login page protects nothing by itself: as long as our backend accepts anonymous requests, anyone can write into our database.

In this article we connect the pieces. The signed-in user will send a new action to an API Gateway endpoint, a Lambda function will insert it into DynamoDB, and only requests carrying a valid Cognito token will get through.

Prerequisites

How the request is secured

  sequenceDiagram
    actor U as Browser (signed-in user)
    participant G as API Gateway
    participant C as Cognito authorizer
    participant L as Lambda create-action
    participant D as DynamoDB

    U->>G: POST /actions + Authorization: ID token
    G->>C: Verify the token
    alt token missing or invalid
        C-->>G: Rejected
        G-->>U: 401 Unauthorized (Lambda never invoked)
    else token valid
        C-->>G: Claims (sub, email...)
        G->>L: Invoke with the claims
        L->>D: PutItem
        D-->>L: OK
        L-->>U: 201 Created
    end

The authorizer verifies the token signature, its expiry and its audience against our User Pool. If the check fails, API Gateway answers 401 itself: our code does not run and we are not billed for a Lambda invocation.

1. The DynamoDB table

Each action belongs to a user, so the partition key is the user and the sort key identifies the action. All the actions of one user will live in the same partition and can be read with a cheap Query (we will do it in the next article).

In infrastructure/lib/infrastructure-stack.ts, add the imports:

TS
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as apigateway from "aws-cdk-lib/aws-apigateway";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs";
Click to expand and view more

then the table, after the Cognito resources:

TS
const actionsTable = new dynamodb.Table(this, "ActionsTable", {
  tableName: "DailyActions",
  partitionKey: { name: "userId", type: dynamodb.AttributeType.STRING },
  sortKey: { name: "actionId", type: dynamodb.AttributeType.STRING },
  billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
  removalPolicy: cdk.RemovalPolicy.DESTROY, // tutorial only, not for production
});
Click to expand and view more

2. The Lambda function

Install the tooling in the infrastructure folder:

SH
cd infrastructure
npm install -D esbuild @types/aws-lambda
npm install @aws-sdk/client-dynamodb
Click to expand and view more

Create infrastructure/lambda/create-action.ts:

TS
import { randomUUID } from "node:crypto";
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";

const client = new DynamoDBClient({});
const TABLE_NAME = process.env.TABLE_NAME!;
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? "").split(",");

const reply = (
  event: APIGatewayProxyEvent,
  statusCode: number,
  body: unknown,
): APIGatewayProxyResult => {
  const origin = event.headers?.origin ?? event.headers?.Origin ?? "";
  return {
    statusCode,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin": ALLOWED_ORIGINS.includes(origin)
        ? origin
        : ALLOWED_ORIGINS[0],
    },
    body: JSON.stringify(body),
  };
};

export const handler = async (
  event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
  // 1. The identity comes from the verified token, never from the request body
  const userId = event.requestContext.authorizer?.claims?.sub;
  if (!userId) {
    return reply(event, 401, { message: "Unauthorized" });
  }

  // 2. Validate the input
  let body: { title?: unknown; dueDate?: unknown };
  try {
    body = JSON.parse(event.body ?? "{}");
  } catch {
    return reply(event, 400, { message: "Invalid JSON body" });
  }

  const title = typeof body.title === "string" ? body.title.trim() : "";
  if (title.length === 0 || title.length > 120) {
    return reply(event, 400, { message: "title must be 1 to 120 characters" });
  }

  // dueDate is optional, YYYY-MM-DD format
  const dueDate = body.dueDate;
  if (
    dueDate !== undefined &&
    (typeof dueDate !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(dueDate))
  ) {
    return reply(event, 400, { message: "dueDate must be YYYY-MM-DD" });
  }

  // 3. Insert
  const action = {
    userId,
    actionId: randomUUID(),
    title,
    done: false,
    createdAt: new Date().toISOString(),
    ...(dueDate && { dueDate }),
  };

  try {
    await client.send(
      new PutItemCommand({
        TableName: TABLE_NAME,
        // Each value carries its DynamoDB type: S = string, BOOL = boolean
        Item: {
          userId: { S: action.userId },
          actionId: { S: action.actionId },
          title: { S: action.title },
          done: { BOOL: action.done },
          createdAt: { S: action.createdAt },
          ...(action.dueDate && { dueDate: { S: action.dueDate } }),
        },
        ConditionExpression: "attribute_not_exists(actionId)",
      }),
    );
    return reply(event, 201, { action });
  } catch (error) {
    console.error("Error creating action:", error);
    return reply(event, 500, { message: "Failed to create action" });
  }
};
Click to expand and view more

What matters here:

3. API Gateway with a Cognito authorizer

Still in the stack, define the function, the API and the authorizer:

TS
const allowedOrigins = [
  `https://${distribution.domainName}`,
  "http://localhost:3001", // local dev
];

const createActionFunction = new lambdaNodejs.NodejsFunction(
  this,
  "CreateActionFunction",
  {
    entry: "lambda/create-action.ts",
    handler: "handler",
    runtime: lambda.Runtime.NODEJS_22_X,
    environment: {
      TABLE_NAME: actionsTable.tableName,
      ALLOWED_ORIGINS: allowedOrigins.join(","),
    },
  },
);

// Least privilege: this function may only call PutItem on this table
actionsTable.grant(createActionFunction, "dynamodb:PutItem");

const api = new apigateway.RestApi(this, "DailyActionsApi", {
  restApiName: "Daily Actions API",
  defaultCorsPreflightOptions: {
    allowOrigins: allowedOrigins,
    allowMethods: ["POST"],
    allowHeaders: apigateway.Cors.DEFAULT_HEADERS, // includes Authorization
  },
});

const authorizer = new apigateway.CognitoUserPoolsAuthorizer(
  this,
  "ActionsAuthorizer",
  { cognitoUserPools: [userPool] },
);

const actions = api.root.addResource("actions");
actions.addMethod(
  "POST",
  new apigateway.LambdaIntegration(createActionFunction),
  {
    authorizer,
    authorizationType: apigateway.AuthorizationType.COGNITO,
  },
);

// Errors generated by API Gateway itself (401, 403...) need CORS headers too
const corsHeader = {
  "Access-Control-Allow-Origin": `'https://${distribution.domainName}'`,
};
api.addGatewayResponse("Unauthorized", {
  type: apigateway.ResponseType.UNAUTHORIZED,
  responseHeaders: corsHeader,
});
api.addGatewayResponse("Default4xx", {
  type: apigateway.ResponseType.DEFAULT_4XX,
  responseHeaders: corsHeader,
});

new cdk.CfnOutput(this, "ApiUrl", { value: api.url });
Click to expand and view more

Then give the front-end the API URL, by adding one line to the config.json object from the previous article:

TS
const config = {
  authority: `https://cognito-idp.${this.region}.amazonaws.com/${userPool.userPoolId}`,
  client_id: userPoolClient.userPoolClientId,
  redirect_uri: "https://" + distribution.domainName + "/",
  response_type: "code",
  scope: "phone openid email",
  apiUrl: api.url, // new
};
Click to expand and view more

Points worth explaining:

4. Calling the API from Next.js

The layout already loads config.json. We now need the apiUrl in other components, so we expose the config through a small React context.

Create lib/config.ts:

TS
import { createContext, useContext } from "react";

export interface AppConfig {
  apiUrl: string;
}

export const ConfigContext = createContext<AppConfig | null>(null);

export function useAppConfig() {
  const config = useContext(ConfigContext);
  if (!config)
    throw new Error("useAppConfig must be used inside ConfigContext");
  return config;
}
Click to expand and view more

In app/layout.tsx, split the config: the OIDC part goes to AuthProvider, apiUrl goes to our context.

TSX
import { ConfigContext } from "@/lib/config"

interface Config {
  authority: string
  client_id: string
  redirect_uri: string
  response_type: string
  scope: string
  apiUrl: string // new
}

// ...inside RootLayout, before the return
const { apiUrl, ...oidcConfig } = config ?? ({} as Config)

// ...then, in place of <AuthProvider {...config}>
<AuthProvider {...oidcConfig}>
  <ConfigContext.Provider value={{ apiUrl }}>
    <QueryClientProvider client={queryClient}>
      <ThemeProvider>{children}</ThemeProvider>
    </QueryClientProvider>
  </ConfigContext.Provider>
</AuthProvider>
Click to expand and view more

The project already has a service layer in app/todo: TodoServices talks to the backend, useTodoQuery wraps it with TanStack Query, and the page only calls addTodo. We keep that structure and only change the POST.

In app/todo/services/TodoServices.ts, replace addTodo:

TS
public async addTodo(
  apiUrl: string,
  idToken: string,
  todo: Omit<Todo, "id" | "createdAt">
): Promise<Todo> {
  // The Cognito authorizer expects the ID token, without the "Bearer" prefix
  const response = await fetch(`${apiUrl}actions`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: idToken,
    },
    body: JSON.stringify({ title: todo.text, dueDate: todo.dueDate }),
  })
  if (!response.ok) {
    const { message } = await response
      .json()
      .catch(() => ({ message: response.statusText }))
    throw new Error(message)
  }

  // The API returns an "action": convert it to a Todo
  const { action } = await response.json()
  return {
    id: action.actionId,
    userId: action.userId,
    text: action.title,
    completed: action.done,
    dueDate: action.dueDate,
    createdAt: Date.parse(action.createdAt),
  }
}
Click to expand and view more

The API speaks in title / done while the front-end model uses text / completed, so addTodo translates in both directions.

Then in app/todo/services/todoQuery.ts, give addTodo the API URL and the ID token. The other calls (fetchTodos, updateTodo, deleteTodo) still target the old backend: we will replace them in the next articles.

TS
import { useAppConfig } from "@/lib/config";

export function useTodoQuery() {
  const auth = useAuth();
  const token = auth.user?.access_token;
  const idToken = auth.user?.id_token; // new
  const { apiUrl } = useAppConfig(); // new
  // ...

  const addTodoMutation = useMutation({
    mutationFn: (newTodo: Omit<Todo, "id" | "createdAt">) =>
      todoServices.addTodo(apiUrl, idToken!, newTodo), // changed
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
  // ...
}
Click to expand and view more

Finally, in app/todo/page.tsx, remove the line userId: \USER#${auth?.user?.profile?.sub}`fromnewTodo(and the now unuseduseAuth`). The backend takes the owner from the token, so the client has nothing to send.

The page itself does not change: its form already calls addTodo with the text and the optional due date.

5. Deploy

Build the front-end, then deploy everything with the command from the previous article:

BASH
cd infrastructure
npm run deploy:all
Click to expand and view more

At the end, CDK prints the ApiUrl output (https://xxxx.execute-api.<region>.amazonaws.com/prod/).

6. Test it

An anonymous request must fail

BASH
curl -i -X POST "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
  -H "Content-Type: application/json" \
  -d '{"title":"I am not signed in"}'
Click to expand and view more

You should get HTTP/2 401 with {"message":"Unauthorized"}. If you open CloudWatch, there is no log for CreateActionFunction: the request never reached it.

A signed-in request must succeed

Sign in on the CloudFront URL, open the /todo page and add a task: it is saved in DynamoDB (check it with the scan command below). The list will not show it yet, because reading comes in the next article.

To replay the call with curl, copy the ID token from the browser: DevTools → Application → Session Storage → the key starting with oidc.user:, field id_token.

BASH
curl -i -X POST "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
  -H "Content-Type: application/json" \
  -H "Authorization: <id_token>" \
  -d '{"title":"Write my article"}'
Click to expand and view more

Expected: HTTP/2 201 and the created action in the body.

Check the data

BASH
aws dynamodb scan --table-name DailyActions
Click to expand and view more

The item must carry your Cognito sub as userId, not anything sent by the client. Try sending {"title":"x","userId":"someone-else"}: the extra field is ignored.

7. Cleanup

SH
cd infrastructure
cdk destroy
Click to expand and view more

Comments

Start searching

Enter keywords to search articles

↑↓
↵
ESC
⌘K Shortcut