post

Serverless-Auth mit Cognito User Pools

Cognito User Pools als managed Identity-Backend: Sign-up, Confirm und Sign-in ohne eigenen Auth-Server, die drei Tokens richtig verstanden und ein API-Gateway-Endpunkt, den ein Cognito-Authorizer absichert.

In fast jedem Serverless-Workshop, den ich halte, kommt derselbe Moment. Wir haben eine Handvoll Lambdas hinter einem API Gateway, alles läuft, und dann fragt jemand aus der Runde: „Und wie kommt jetzt der User da rein? Wer darf das eigentlich aufrufen?" Reflexartig fangen die meisten Teams an, einen eigenen Auth-Server zu skizzieren – eine Datenbank für User, ein Passwort-Hashing, ein Login-Endpunkt, der ein Token ausstellt. Und genau an dem Punkt halte ich sie an. Denn in der Serverless-Welt willst du diese Verantwortung normalerweise nicht selbst tragen. Passwörter zu speichern, E-Mail-Bestätigungen zu verschicken, Tokens zu signieren und zu rotieren – das ist ein gelöstes Problem, und AWS hat dafür einen managed Baustein: Cognito User Pools.

Ich habe Serverless: Grundlagen und Grenzen an anderer Stelle ausführlicher beschrieben. Kurz gesagt: Serverless heißt für mich vor allem, Betriebsverantwortung abzugeben, wo sie kein Wettbewerbsvorteil ist. Und ein eigener Auth-Server ist selten ein Wettbewerbsvorteil. 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:

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 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:

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 – wieder ein Stück Infrastruktur, das du nicht selbst betreiben musst.

Die drei Tokens – und warum „drei JWT" falsch ist

Nach erfolgreicher Anmeldung liefert Cognito genau drei Tokens zurück. Das klingt banal, aber hier liegt der häufigste Denkfehler, den ich in Workshops korrigiere: 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. Wer versucht, ein Refresh-Token zu dekodieren, um an Claims zu kommen, sucht vergeblich. Und wer im Frontend das falsche Token an die API schickt, bekommt ein 401, ohne zu verstehen warum. Die Faustregel für den REST-Cognito-Authorizer ohne konfigurierte Scopes lautet: ID-Token senden. 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:

{
  "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:

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:

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:

{
  "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:

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:

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. Das kann gewollt sein, ist es aber meistens nicht – und wer es unbeabsichtigt tut, wundert sich später, dass unbestätigte Adressen im Pool landen.

Das kleine Komponentenbild dazu:

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

Wichtige Abgrenzung: Infrastruktur-Auth ist nicht Resolver-Auth

Zum Schluss die Unterscheidung, die ich am stärksten betone, weil sie am häufigsten 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 den überwiegenden Teil der Serverless-Projekte, die ich begleite, ist Cognito genau der richtige Zuschnitt: ein managed Identity-Backend, das dir Passwort-Handling, Token-Ausstellung und Verifizierung abnimmt, ergänzt um Trigger für die Fälle, in denen du eigene Regeln brauchst. Du sparst dir einen Auth-Server, den ohnehin niemand betreiben will – und behältst genau dort die Kontrolle, wo dein Fachwissen zählt: bei der Frage, wer was mit welchen Daten tun darf.

Weiterführende Quellen

Kommentare

Kommentar schreiben