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.
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:
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.
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:
{
"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 – und du wunderst dich später über unbestätigte Adressen im Pool.
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
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
- Using the ID Token · Using the Access Token · Using the Refresh Token
- Control access to a REST API using Amazon Cognito user pools · Authorizer einrichten (ID- vs. Access-Token/Scopes)
- JWT dekodieren und verifizieren (JWKS)
- Customizing user pool workflows with Lambda triggers
Wie fandest du diesen Beitrag?
Kommentare