Daily Actions: Enregistrer des données dans DynamoDB via une API Gateway et une Lambda sécurisées par Cognito
Dans l’article précédent, notre application Next.js statique a appris à authentifier ses utilisateurs avec Amazon Cognito. Mais une page de connexion ne protège rien à elle seule : tant que notre backend accepte des requêtes anonymes, n’importe qui peut écrire dans notre base.
Dans cet article, nous relions les morceaux. L’utilisateur connecté enverra une nouvelle action vers un endpoint API Gateway, une fonction Lambda l’insérera dans DynamoDB, et seules les requêtes portant un jeton Cognito valide passeront.
Important
Cet article fait suite à la série Daily Actions. Partez du projet tel que nous l’avons laissé (/frontend et /infrastructure, avec Cognito déjà déployé).
Objectif du tutoriel
- Créer une table DynamoDB qui stocke les actions de chaque utilisateur.
- Créer une fonction Lambda qui insère une action.
- L’exposer en
POST /actionssur une API Gateway protégée par un authorizer Cognito. - Appeler l’endpoint depuis Next.js avec le jeton de l’utilisateur.
- Vérifier que les requêtes anonymes sont rejetées.
Prérequis
- Avoir lu Daily Actions: Sécuriser son application Next.js avec AWS Cognito et CDK
- Jeter un œil à Donnez de la mémoire à votre API: Créez un CRUD complet avec DynamoDB et CDK si Lambda + DynamoDB avec CDK est nouveau pour vous.
Comment la requête est sécurisée
sequenceDiagram
actor U as Navigateur (utilisateur connecté)
participant G as API Gateway
participant C as Authorizer Cognito
participant L as Lambda create-action
participant D as DynamoDB
U->>G: POST /actions + Authorization: ID token
G->>C: Vérifier le jeton
alt jeton absent ou invalide
C-->>G: Rejeté
G-->>U: 401 Unauthorized (Lambda jamais invoquée)
else jeton valide
C-->>G: Claims (sub, email...)
G->>L: Invoquer avec les claims
L->>D: PutItem
D-->>L: OK
L-->>U: 201 Created
end
L’authorizer vérifie la signature du jeton, son expiration et son audience par rapport à notre User Pool. Si la vérification échoue, API Gateway répond 401 lui-même : notre code ne s’exécute pas et aucune invocation Lambda n’est facturée.
1. La table DynamoDB
Chaque action appartient à un utilisateur : la clé de partition est donc l’utilisateur, et la clé de tri identifie l’action. Toutes les actions d’un utilisateur vivent dans la même partition et pourront être lues avec un Query peu coûteux (ce sera l’objet du prochain article).
Dans infrastructure/lib/infrastructure-stack.ts, ajoutez les imports :
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";puis la table, après les ressources Cognito :
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, // tutoriel uniquement, pas pour la production
});Note
Choisir ses clés à partir des patterns d’accès est la décision DynamoDB la plus importante. Pour comprendre le raisonnement derrière userId + actionId, lisez Comment concevoir (ou designer) une table DynamoDB performante ?.
2. La fonction Lambda
Installez les outils dans le dossier infrastructure :
cd infrastructure
npm install -D esbuild @types/aws-lambda
npm install @aws-sdk/client-dynamodbCréez infrastructure/lambda/create-action.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. L'identité vient du jeton vérifié, jamais du corps de la requête
const userId = event.requestContext.authorizer?.claims?.sub;
if (!userId) {
return reply(event, 401, { message: "Unauthorized" });
}
// 2. Valider l'entrée
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 est optionnel, au format YYYY-MM-DD
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. Insérer
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" });
}
};Ce qui compte ici :
event.requestContext.authorizer.claims.sub: une fois le jeton validé par l’authorizer Cognito, API Gateway injecte ses claims dans l’événement.subest l’identifiant unique et immuable de l’utilisateur. C’est lui que nous stockons commeuserId.- Nous ne lisons jamais
userIddepuis le corps. Sinon, n’importe quel utilisateur connecté pourrait écrire dans les données d’un autre en envoyant un autre identifiant. - Validation : nous rejetons les titres vides ou trop longs avant de toucher à la base.
PutItemCommand: c’est le client bas niveau, donc chaque valeur est enveloppée avec son type de donnée DynamoDB :{ S: "texte" }pour une chaîne,{ BOOL: false }pour un booléen.ConditionExpression:PutItemremplace silencieusement un élément existant ayant la même clé. La condition transforme un écrasement accidentel en erreur.reply: le navigateur appelle l’API depuis une autre origine (CloudFront), donc chaque réponse doit porter un en-têteAccess-Control-Allow-Origin, sinon le navigateur cache la réponse à notre JavaScript. Nous ne renvoyons l’origine que si elle figure dans notre liste autorisée.
3. API Gateway avec un authorizer Cognito
Toujours dans la stack, définissez la fonction, l’API et l’authorizer :
const allowedOrigins = [
`https://${distribution.domainName}`,
"http://localhost:3001", // dev local
];
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(","),
},
},
);
// Moindre privilège : cette fonction ne peut appeler que PutItem sur cette 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, // inclut 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,
},
);
// Les erreurs générées par API Gateway elle-même (401, 403...) ont aussi besoin des en-têtes CORS
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 });Donnez ensuite l’URL de l’API au front-end en ajoutant une ligne à l’objet config.json de l’article précédent :
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, // nouveau
};Les points à retenir :
CognitoUserPoolsAuthorizerest la pièce qui rend l’endpoint privé. Sans l’optionauthorizer, la méthode serait publique.- ID token ou access token ? Sans scopes configurés sur la méthode, cet authorizer attend l’ID token dans l’en-tête
Authorization. Un access token n’est accepté que si vous déclarez des scopes OAuth sur la méthode, ce qui demande un resource server. L’ID token est l’option la plus simple ici, etreact-oidc-contextnous le fournit directement. actionsTable.grant(fn, "dynamodb:PutItem")est plus strict quegrantWriteData, qui autorise aussi les suppressions et mises à jour. Une fonction qui ne fait qu’insérer ne doit pouvoir qu’insérer.- Le CORS a deux faces : la requête préliminaire
OPTIONS(gérée pardefaultCorsPreflightOptions, et jamais authentifiée), et les en-têtes des vraies réponses (notre fonctionreplypour la Lambda,addGatewayResponsepour les erreurs produites par API Gateway).
4. Appeler l’API depuis Next.js
Le layout charge déjà config.json. Il nous faut maintenant apiUrl dans d’autres composants : nous exposons donc la config via un petit contexte React.
Créez lib/config.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;
}Dans app/layout.tsx, séparez la config : la partie OIDC va à AuthProvider, apiUrl va dans notre contexte.
import { ConfigContext } from "@/lib/config"
interface Config {
authority: string
client_id: string
redirect_uri: string
response_type: string
scope: string
apiUrl: string // nouveau
}
// ...dans RootLayout, avant le return
const { apiUrl, ...oidcConfig } = config ?? ({} as Config)
// ...puis, à la place de <AuthProvider {...config}>
<AuthProvider {...oidcConfig}>
<ConfigContext.Provider value={{ apiUrl }}>
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
</ConfigContext.Provider>
</AuthProvider>Le projet possède déjà une couche de services dans app/todo : TodoServices dialogue avec le backend, useTodoQuery l’enveloppe avec TanStack Query, et la page n’appelle que addTodo. Nous gardons cette structure et ne changeons que le POST.
Dans app/todo/services/TodoServices.ts, remplacez addTodo :
public async addTodo(
apiUrl: string,
idToken: string,
todo: Omit<Todo, "id" | "createdAt">
): Promise<Todo> {
// L'authorizer Cognito attend le jeton ID, sans préfixe "Bearer"
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)
}
// L'API renvoie une "action" : on la convertit en 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),
}
}Note
api.url se termine déjà par un /, c’est pourquoi nous écrivons `${apiUrl}actions` et non `${apiUrl}/actions`.
L’API parle en title / done alors que le modèle du front utilise text / completed : addTodo fait donc la traduction dans les deux sens.
Ensuite, dans app/todo/services/todoQuery.ts, donnez à addTodo l’URL de l’API et le jeton ID. Les autres appels (fetchTodos, updateTodo, deleteTodo) visent encore l’ancien backend : nous les remplacerons dans les prochains articles.
import { useAppConfig } from "@/lib/config";
export function useTodoQuery() {
const auth = useAuth();
const token = auth.user?.access_token;
const idToken = auth.user?.id_token; // nouveau
const { apiUrl } = useAppConfig(); // nouveau
// ...
const addTodoMutation = useMutation({
mutationFn: (newTodo: Omit<Todo, "id" | "createdAt">) =>
todoServices.addTodo(apiUrl, idToken!, newTodo), // modifié
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
// ...
}Enfin, dans app/todo/page.tsx, supprimez la ligne userId: \USER#${auth?.user?.profile?.sub}`denewTodo(etuseAuth`, devenu inutile). Le backend déduit le propriétaire du jeton, le client n’a rien à envoyer.
La page elle-même ne change pas : son formulaire appelle déjà addTodo avec le texte et l’échéance optionnelle.
Avertissement
L’ID token expire (une heure par défaut). react-oidc-context peut le renouveler silencieusement si vous configurez automaticSilentRenew ; si vous obtenez un 401 après une longue session, se reconnecter règle le problème rapidement.
5. Déployer
Construisez le front-end, puis déployez le tout avec la commande de l’article précédent :
cd infrastructure
npm run deploy:allÀ la fin, CDK affiche la sortie ApiUrl (https://xxxx.execute-api.<region>.amazonaws.com/prod/).
6. Tester
Une requête anonyme doit échouer
curl -i -X POST "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
-H "Content-Type: application/json" \
-d '{"title":"Je ne suis pas connecté"}'Vous devez obtenir HTTP/2 401 avec {"message":"Unauthorized"}. Dans CloudWatch, il n’y a aucun log pour CreateActionFunction : la requête ne l’a jamais atteinte.
Une requête authentifiée doit réussir
Connectez-vous sur l’URL CloudFront, ouvrez la page /todo et ajoutez une tâche : elle est enregistrée dans DynamoDB (vérifiez-la avec la commande scan plus bas). La liste ne l’affiche pas encore, car la lecture arrive dans le prochain article.
Pour rejouer l’appel avec curl, copiez l’ID token depuis le navigateur : DevTools → Application → Session Storage → la clé qui commence par oidc.user:, champ id_token.
curl -i -X POST "https://xxxx.execute-api.<region>.amazonaws.com/prod/actions" \
-H "Content-Type: application/json" \
-H "Authorization: <id_token>" \
-d '{"title":"Écrire mon article"}'Attendu : HTTP/2 201 et l’action créée dans le corps.
Vérifier les données
aws dynamodb scan --table-name DailyActionsL’élément doit porter votre sub Cognito comme userId, et non une valeur envoyée par le client. Essayez d’envoyer {"title":"x","userId":"quelqu-un-d-autre"} : le champ en trop est ignoré.
7. Nettoyage
cd infrastructure
cdk destroyConclusion
Dans cet article, nous avons :
- Créé une table DynamoDB organisée par utilisateur
- Inséré des données avec une fonction Lambda derrière
POST /actions - Protégé l’endpoint avec un authorizer Cognito : seuls les utilisateurs authentifiés peuvent écrire
- Déduit le propriétaire de chaque élément du jeton vérifié plutôt que de faire confiance au client
- Appliqué le moindre privilège sur les permissions IAM et configuré correctement le CORS
- Appelé l’API depuis Next.js avec l’ID token de l’utilisateur
- Projet GitLab (branche feat/create-action-lambda)
Note
Nos utilisateurs peuvent maintenant enregistrer leurs actions. La prochaine étape est de les relire : un endpoint GET /actions qui utilise un Query DynamoDB sur la partition de l’utilisateur, puis la mise à jour et la suppression.

Commentaires