# Serverless-Auth mit Cognito User Pools

URL: https://www.mikebild.dev/de/blog/cognito-userpool-auth-serverless/

Cognito nimmt einem das Passwort-Handling ab – die Entscheidung darüber, wem die Identitäten der Nutzer gehören, nimmt es einem nicht ab. Diese beiden Dinge werden regelmäßig in einen Topf geworfen. Der Aufruf `signUp()` ist an einem Nachmittag integriert; die `sub`, die Cognito dabei vergibt, wandert danach als Fremdschlüssel durch jede Tabelle und bleibt Jahre. Die Integration ist billig, das Eigentum an den Identitäten teuer.

Passwörter speichern, Bestätigungsmails verschicken, Tokens signieren und rotieren – gelöste Probleme, und selten das, was ein Produkt von der Konkurrenz unterscheidet. Ein selbstgebauter Auth-Server ist keine Differenzierung, sondern eine Fläche, die dauerhaft gepflegt und abgesichert werden will. AWS hat für diese Verantwortung einen managed Baustein: Cognito User Pools. Interessant ist daran weniger der API-Aufruf als der Trade-off dahinter: Welche Betriebsverantwortung gebe ich ab, und welchen Preis zahle ich bei einem späteren Wechsel? Genau den nehme ich hier auseinander – an einem durchgehenden Beispiel, das den Weg von der Registrierung bis zum abgesicherten Endpunkt zeigt.

Serverless heißt für mich vor allem, Betriebsverantwortung dort abzugeben, wo sie keinen Wettbewerbsvorteil schafft. Ein eigener Auth-Server ist selten einer – er ist eine Angriffsfläche, die man pflegen muss.

## Was ein User Pool eigentlich ist

Ein Cognito User Pool ist ein managed Verzeichnis für deine Endnutzer plus die komplette Maschinerie drumherum: Registrierung, E-Mail- oder SMS-Verifizierung, Anmeldung, Passwort-Reset und die Ausstellung von Tokens. Deine Anwendung speichert keine Passwörter mehr – sie redet nur noch mit Cognito. Das ist der entscheidende Perspektivwechsel: Nicht deine App entscheidet, ob ein Passwort stimmt, sondern der User Pool. Deine App bekommt am Ende nur ein signiertes Token in die Hand und muss diesem Token vertrauen können.

Zwei Objekte musst du dabei auseinanderhalten. Der **User Pool** selbst ist das Verzeichnis samt Policies – Passwortregeln, welche Attribute Pflicht sind, welche automatisch verifiziert werden. Der **App Client** ist die Anwendung, die sich beim Pool authentifiziert; er legt fest, welche Auth-Flows erlaubt sind und wie lange ein Refresh-Token gültig bleibt. Ein Pool kann mehrere App Clients haben – etwa einen fürs Web und einen für die native App.

So sieht das minimal in CDK aus. Ich bleibe bewusst bei den L1-Constructs (`Cfn...`), weil sie eins zu eins auf die CloudFormation-Ressourcen abbilden und man dadurch genau sieht, was entsteht:

```js
const {
  CfnUserPool,
  CfnUserPoolClient,
  CfnUserPoolGroup,
} = require('@aws-cdk/aws-cognito');

const userPool = new CfnUserPool(this, 'UserPool', {
  userPoolName: 'UserPool',
  adminCreateUserConfig: {
    allowAdminCreateUserOnly: false,
  },
  policies: {
    passwordPolicy: {
      minimumLength: 6,
    },
  },
  schema: [
    {
      attributeDataType: 'String',
      name: 'email',
      required: true,
    },
  ],
  autoVerifiedAttributes: ['email'],
});

const userPoolClient = new CfnUserPoolClient(this, 'UserPoolClient', {
  clientName: 'UserPoolClient',
  explicitAuthFlows: ['ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
  refreshTokenValidity: 30,
  userPoolId: userPool.ref,
});
```

`autoVerifiedAttributes: ['email']` sorgt dafür, dass Cognito nach der Registrierung automatisch einen Bestätigungscode per E-Mail verschickt. `refreshTokenValidity: 30` setzt die Lebensdauer des Refresh-Tokens auf 30 Tage – dazu gleich mehr. Wer es lieber mit den höheren L2-Constructs (`UserPool`, `UserPoolClient`) baut, bekommt bequemere Defaults; für den Einstieg finde ich die explizite L1-Variante lehrreicher. Wenn du dich fragst, warum ich Infrastruktur überhaupt in einer Programmiersprache beschreibe statt in YAML: Das habe ich in [CDK: Infrastruktur als Programmiersprache](https://www.mikebild.dev/de/blog/cdk-infrastruktur-als-programmiersprache/) begründet.

## Sign-up, Confirm, Sign-in

Der Lebenszyklus eines Nutzers hat drei Stationen. Auf dem Client – oder wie in meinem Beispiel in einem dünnen Node-Backend – nutze ich dafür `amazon-cognito-identity-js`, das offizielle JavaScript-SDK für User Pools. Registrierung, Bestätigung, Anmeldung:

```ts
import {
  CognitoUserPool,
  CognitoUser,
  AuthenticationDetails,
  CognitoUserAttribute,
} from 'amazon-cognito-identity-js';

const userPool = new CognitoUserPool({
  UserPoolId: process.env.USER_POOL_ID,
  ClientId: process.env.USER_POOL_CLIENT_ID,
});

// 1) sign-up: create the user, Cognito emails a confirmation code
userPool.signUp(
  username,
  password,
  [new CognitoUserAttribute({ Name: 'email', Value: username })],
  [],
  (err, result) => {
    /* result.user is created but not yet confirmed */
  }
);

// 2) confirm: user submits the code from the email
const cognitoUser = new CognitoUser({ Username: username, Pool: userPool });
cognitoUser.confirmRegistration(code, true, (err, result) => {
  /* result === 'SUCCESS' */
});

// 3) sign-in: authenticate and receive tokens
cognitoUser.authenticateUser(
  new AuthenticationDetails({ Username: username, Password: password }),
  {
    onSuccess(session) {
      const idToken = session.getIdToken().getJwtToken();
      const accessToken = session.getAccessToken().getJwtToken();
      // refresh token is available too, but opaque
    },
    onFailure(err) {
      /* wrong password, unconfirmed user, ... */
    },
  }
);
```

Der wichtige Punkt: Zwischen Schritt eins und drei liegt der Confirm-Schritt. Ein frisch registrierter Nutzer ist zunächst `UNCONFIRMED` und kann sich nicht anmelden, bis er den per E-Mail zugestellten Code bestätigt hat. Diese E-Mail schickt Cognito selbst.

## Die drei Tokens – und warum „drei JWT“ falsch ist

Nach erfolgreicher Anmeldung liefert Cognito genau drei Tokens zurück. Das klingt banal, aber hier sitzt ein hartnäckiger Denkfehler: Es heißt oft, man bekomme „drei JWT“. Das stimmt nicht. Zwei davon sind JWT, eines ist es ausdrücklich nicht.

Das ID-Token ist ein JWT und trägt die Identität des Nutzers – Claims wie `sub`, `email` und `cognito:username`. Es beantwortet die Frage „wer ist dieser User?“ und ist das Standard-Token für den klassischen API-Gateway-Cognito-Authorizer. Das Access-Token ist ebenfalls ein JWT, trägt aber OAuth-Scopes und beantwortet „was darf dieser Client?“; es kommt ins Spiel, sobald du scope-basierte Autorisierung mit Resource Servern nutzt. Das Refresh-Token schließlich ist kein JWT, sondern ein verschlüsselter, nur für Cognito lesbarer String. Es ist langlebig – in meiner Konfiguration 30 Tage – und dient einzig dazu, ohne erneute Passworteingabe frische ID- und Access-Tokens einzutauschen.

Warum ist das mehr als Begriffsklauberei? Weil man ein JWT lokal verifizieren kann – Signatur prüfen, Ablauf prüfen, fertig – ein opakes Token dagegen nicht. Und wer im Frontend das falsche Token an die API schickt, bekommt ein `401`, ohne zu verstehen warum. Für den REST-Cognito-Authorizer ohne konfigurierte Scopes gehört das ID-Token in den Header. Erst wenn du `authorizationScopes` konfigurierst, erwartet das Gateway das Access-Token. Genau diese Verwechslung ist eine der häufigsten Fehlerquellen.

Ein ID-Token, dekodiert, sieht im Kern so aus – man erkennt Issuer, Audience und den Verwendungszweck:

```json
{
  "sub": "a1b2c3d4-....",
  "email": "user@example.com",
  "cognito:username": "user@example.com",
  "aud": "the-app-client-id",
  "iss": "https://cognito-idp.eu-central-1.amazonaws.com/eu-central-1_ABC123",
  "token_use": "id",
  "exp": 1611060000
}
```

## Den Endpunkt absichern – ohne eigenen Verifikationscode

Jetzt kommt der Teil, an dem sich managed Auth wirklich auszahlt. Ich könnte in jeder Lambda das Token selbst prüfen: das JWKS-Dokument des Pools laden, den passenden Schlüssel über die `kid` aus dem Token-Header finden, in ein PEM umwandeln und die Signatur verifizieren. In einem klassischen Express-Backend habe ich genau das gebaut, und es ist lehrreich, es einmal von Hand gesehen zu haben:

```ts
export async function validateToken({ token, region, userPoolId }) {
  const response = await fetch(
    `https://cognito-idp.${region}.amazonaws.com/${userPoolId}/.well-known/jwks.json`
  );
  const { keys } = await response.json();

  const decoded = jwt.decode(token, { complete: true });
  if (!decoded) throw new Error('Invalid JWT');

  const key = keys.find((k) => k.kid === decoded.header.kid);
  if (!key) throw new Error('Invalid JWT');

  const pem = jwkToPem({ kty: key.kty, n: key.n, e: key.e });
  return new Promise((resolve, reject) => {
    jwt.verify(token, pem, (err, payload) =>
      err ? reject(new Error('Invalid JWT')) : resolve(payload)
    );
  });
}
```

Das funktioniert – aber es ist Code, den ich schreiben, testen und pflegen muss, und der in jeder abgesicherten Route wieder auftaucht. Genau diese Arbeit nimmt einem der **API-Gateway-Cognito-Authorizer** ab. Man verweist das Gateway auf den User Pool, und es übernimmt die komplette Prüfung managed: Signatur gegen das Pool-JWKS, `exp`, `aud`, `iss`. Kommt ein gültiges Token, wird die Lambda aufgerufen; kommt ein ungültiges oder abgelaufenes, antwortet das Gateway mit `401`, bevor deine Funktion überhaupt startet. Kein eigener Verifikationscode.

In CDK verbindest du das über einen `CognitoUserPoolsAuthorizer` mit einer REST-API-Route:

```ts
import {
  RestApi,
  CognitoUserPoolsAuthorizer,
  AuthorizationType,
  LambdaIntegration,
} from '@aws-cdk/aws-apigateway';

const authorizer = new CognitoUserPoolsAuthorizer(this, 'Authorizer', {
  cognitoUserPools: [userPool],
});

const api = new RestApi(this, 'Api');
const items = api.root.addResource('items');

items.addMethod('GET', new LambdaIntegration(getItemsFn), {
  authorizer,
  authorizationType: AuthorizationType.COGNITO,
});
```

Wer direkt auf der API-Gateway-Ebene arbeitet – etwa in einer OpenAPI-Definition – erkennt dieselbe Konfiguration in der Rohform. Der Authorizer ist vom Typ `cognito_user_pools`, verweist per ARN auf den Pool und liest das Token aus dem `Authorization`-Header:

```json
{
  "type": "apiKey",
  "name": "Authorization",
  "in": "header",
  "x-amazon-apigateway-authtype": "cognito_user_pools",
  "x-amazon-apigateway-authorizer": {
    "type": "cognito_user_pools",
    "providerARNs": [
      "arn:aws:cognito-idp:eu-central-1:123456789012:userpool/eu-central-1_ABC123"
    ],
    "identitySource": "method.request.header.Authorization"
  }
}
```

Der Client schickt sein ID-Token also schlicht im `Authorization`-Header mit – in der Praxis meist als `Bearer <idToken>` – und muss sich um nichts weiter kümmern. Der gesamte Ablauf sieht so aus:

```mermaid
sequenceDiagram
  participant C as Client
  participant Cog as Cognito User Pool
  participant GW as API Gateway
  participant L as Lambda
  C->>Cog: sign-in (username / password)
  Cog-->>C: ID + Access + Refresh tokens
  C->>GW: GET /items<br/>Authorization: Bearer idToken
  GW->>Cog: verify JWT via JWKS (signature, exp, aud, iss)
  GW->>L: invoke (token valid)
  L-->>C: 200 OK
```

## Lambda-Trigger: eigene Logik in den Ablauf hängen

Manchmal reicht der Standardablauf nicht. Du willst zum Beispiel nur E-Mail-Adressen einer bestimmten Firmendomain zulassen oder beim Registrieren zusätzliche Daten in eine eigene Tabelle schreiben. Dafür bietet der User Pool **Lambda-Trigger** – Einsprungspunkte an definierten Stellen im Lebenszyklus. 2021 verfügbar sind unter anderem `PreSignUp`, `PostConfirmation`, `PreAuthentication`, `PostAuthentication`, `PreTokenGeneration` und `CustomMessage`.

Ein `PreSignUp`-Trigger läuft, bevor Cognito einen neuen Nutzer anlegt. Hier prüfe ich etwa die E-Mail-Domain:

```js
exports.handler = async (event) => {
  const email = event.request.userAttributes.email || '';
  if (!email.endsWith('@example.com')) {
    throw new Error('Only example.com addresses are allowed');
  }
  // do NOT auto-confirm: keep the email verification step intact
  event.response.autoConfirmUser = false;
  return event;
};
```

Zwei Dinge sind hier entscheidend, und beide sind klassische Stolperfallen. Erstens: Der Trigger muss das `event`-Objekt am Ende unverändert – bis auf die gewollten Response-Felder – **zurückgeben**. Vergisst du das `return event`, bricht der Flow ab. Zweitens: `autoConfirmUser` und die verwandten Auto-Verify-Felder sind mächtig. Setzt du sie versehentlich auf `true`, umgeht ein Nutzer den E-Mail-Bestätigungsschritt komplett – und du wunderst dich später über unbestätigte Adressen im Pool.

Das kleine Komponentenbild dazu:

```mermaid
flowchart LR
  Pool[User Pool + App Client] -->|JWKS| GW[API Gateway<br/>Cognito Authorizer]
  GW --> Fn[Lambda]
  Trigger[PreSignUp trigger] -.-> Pool
```

## Der eigentliche Trade-off: managed Identity gegen Anbieterbindung

Alles bisher Gezeigte ist die Habenseite: kein Passwort-Handling, keine selbstgebaute Token-Ausstellung, keine Verifikationslogik in jeder Route. Diese Verantwortung samt Angriffsfläche wandert zu AWS – meist der schwerere Posten. Die Sollseite verschweige ich trotzdem nicht: Bindung an den Anbieter.

Diese Bindung ist konkreter, als sie zunächst wirkt. Deine Tokens sind gegen das JWKS dieses Pools signiert, dein Client redet über das Cognito-SDK, deine Trigger sind auf Cognitos Event-Struktur zugeschnitten – und am schwersten wiegend: Die `sub` jedes Nutzers ist eine von Cognito vergebene UUID, die als Fremdschlüssel quer durch deine Datenbanken wandert. Nicht der Login-Code klebt, sondern diese Identitäten.

Zwei Grenzfälle machen das greifbar. Cognito gibt keine Passwort-Hashes heraus: Wer wegzieht, kann seine Nutzer nicht still mitnehmen – entweder alle setzen ihr Passwort zurück, oder du migrierst sie lazy beim nächsten Login über einen `UserMigration`-Trigger auf dem Zielsystem. Und alles jenseits des Standards – Custom Attributes, SRP-Annahmen im Client, MFA-Konfiguration – ist Cognito-Vokabular, das ein anderer Anbieter nicht eins zu eins spricht. Das Migrationsrisiko sitzt also nicht im Auth-Flow, den man in Tagen neu baut, sondern in den Identitäten und ihren Referenzen, die über Jahre gewachsen sind.

Daraus wird ein klares Entscheidungsraster. Für Standard-Anmeldung mit E-Mail und Passwort, kleine bis mittlere Teams ohne dediziertes Security-Personal und ohne Multi-Cloud-Zwang gewinnt managed Identity fast immer – der eingesparte Betrieb schlägt das Bindungsrisiko deutlich. Neu abwägen würde ich, sobald ein bestehender Identity-Provider im Haus steht, echte Anbieterportabilität gefordert ist oder Datenresidenz vorschreibt, wo Identitäten überhaupt liegen dürfen. Der Trade-off verschwindet nicht – aber man trifft ihn bewusst statt aus Reflex, und man trifft ihn am Anfang, wo er noch billig ist.

## Wichtige Abgrenzung: Infrastruktur-Auth ist nicht Resolver-Auth

Zum Schluss die Unterscheidung, die in der Praxis am schnellsten verschwimmt. Was der Cognito-Authorizer am API Gateway macht, ist **Infrastruktur- oder Edge-Auth**. Er beantwortet eine einzige, grobe Frage: „Ist der Aufrufer überhaupt authentifiziert?“ Ist das Token gültig, kommt die Anfrage durch – ist es das nicht, fliegt sie mit `401` raus, noch bevor eigener Code läuft. Das ist ein Türsteher, kein Sachbearbeiter.

Was der Authorizer nicht macht: entscheiden, ob *dieser konkrete Nutzer* *dieses konkrete Objekt* sehen oder verändern darf. Darf User A den Kommentar von User B löschen? Darf jemand nur die eigenen Bestellungen abfragen? Das ist feingranulare Autorisierung, und die gehört in deine Anwendungslogik – bei einer GraphQL-API zum Beispiel in den Resolver-Kontext, wo du die Identität aus dem Token gegen die tatsächlichen Datenobjekte prüfst. Der Authorizer liefert dir die verlässliche Identität; was du damit an Rechten durchsetzt, bleibt deine Aufgabe. Diese beiden Ebenen sauber zu trennen, erspart eine Menge Verwirrung – und verhindert den Trugschluss, mit einem Authorizer sei „die Autorisierung erledigt“.

Für das hier gezeigte Setup – User Pool, Standard-Login und ein abgesicherter API-Endpunkt – nimmt Cognito viel eigene Infrastruktur ab. Ein erster durchgängiger Pfad lässt sich damit überschaubar aufbauen; produktionsreif wird er erst mit geklärten Recovery-Flows, MFA, Monitoring und feingranularer Autorisierung. Der Authorizer liefert die verlässliche Identität, die Rechte auf konkrete Fachobjekte bleiben bewusst in deiner Anwendungslogik. Den Preis, die Anbieterbindung, zahlst du sehenden Auges – und du triffst die Entscheidung dort, wo sie hingehört: auf der Architekturebene, nicht beiläufig beim ersten API-Aufruf.

## Weiterführende Quellen

- Foliensatz zu diesem Thema: https://supabase.mikebild.dev/storage/v1/object/public/slides/introduction-aws-serverless.html
- Repository mit dem vollständigen Beispiel: `introduction-aws/examples/userpool` (CDK-Stack, Sign-up/Confirm/Login und die manuelle Token-Prüfung als Kontrast zum Authorizer)
- [Amazon Cognito – Using Tokens with User Pools](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-with-identity-providers.html)
- [Using the ID Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-id-token.html) · [Using the Access Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html) · [Using the Refresh Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-refresh-token.html)
- [Control access to a REST API using Amazon Cognito user pools](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-integrate-with-cognito.html) · [Authorizer einrichten (ID- vs. Access-Token/Scopes)](https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-enable-cognito-user-pool.html)
- [JWT dekodieren und verifizieren (JWKS)](https://repost.aws/knowledge-center/decode-verify-cognito-json-token)
- [Customizing user pool workflows with Lambda triggers](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools-working-with-aws-lambda-triggers.html)
