My First Time with Cognito Managed Login

My First Time with Cognito Managed Login

Well, I don't think it's easy, but what do you think?
2026.10.18

This page has been translated by machine translation. View original

I'm Kazue from the Manufacturing Business Technology Department.

I'm a Cognito beginner. A toddler, really. My understanding is only rough: it's a login feature for web apps, and a handy service for managing logged-in users.
But I have to use it for work, so I'm studying it now.

Cognito has two features, "user pools" and "identity pools," but this article covers user pools, which handle the login feature.

A world without Cognito

You can build login processing without Cognito by storing user IDs and passwords in the web app's database. I used to build it that way. Wouldn't adding a third party like Cognito just make things harder to understand? What's the benefit of adding Cognito?

What's nice about having Cognito

You don't have to keep passwords in your own database

I see. Indeed, if you implement login processing yourself, you need to store passwords in the app's DB. You would probably hash them and add salt, taking into account a possible leak, but it's still worrying. If you leave it to Cognito, this worry goes away.

You don't have to build the features that come with login

Login needs a lot of surrounding features.

  • Verifying email / phone number at sign-up
  • Resetting a forgotten password
  • MFA (TOTP, SMS, email one-time codes)
  • Signing in with passkeys or passwordless
  • The sign-in screen itself

You can shortcut implementing all of these by using Cognito.

※ Note that MFA with email one-time codes, passkeys, and passwordless sign-in are features of the Essentials plan or higher (the default plan for newly created user pools is Essentials).

Logins with external IDs can be handled together too

This may overlap with the "shortcut implementation using Cognito" mentioned above, but
it supports social login such as Google, as well as login using corporate identity platforms (SAML / OIDC) such as Okta and ADFS. Whichever method is used to log in, Cognito unifies the result into tokens of the same format, JWT, and hands them to the app. The app side doesn't need to care about the differences between login methods. Implementing each of these individually would be quite a chore.

Learning the flow

oidc_authorization_code_flow_cognito_v2.png
A diagram made by Opus

The diagram above is apparently the basic form of logging in to an app. (According to Opus.) I thought, "See, adding Cognito makes things complicated."
② and ④ are redirects (302). The user is redirected to the Cognito screen, logs in, and is then redirected again back to the app. The URL in ④ looks like https://アプリ/callback?code=abc123, and the code is right there in the URL.

Here's one question. In ④, why not pass the token in the URL right away instead of a code? Then ⑥ and ⑦ wouldn't be needed, right?

In fact, that approach (passing the token in the URL right away) exists as the Implicit grant, and you can choose it in Cognito too. However, the Cognito documentation treats it as a legacy method and does not recommend it.

The reason it's not recommended is that the token could be stolen. As mentioned, in ④ the value is placed in the URL, so it may be visible from various places such as the browser's address bar, history, and extensions. So at the ④ stage, a code that is hard to abuse on its own even if leaked is returned. The token is obtained in the exchange in ⑥ and ⑦, which doesn't go through the browser.

  • To exchange a code for a token, ⑥ requires the client secret, which only the app has
  • The code is valid for 5 minutes and becomes invalid once used (Cognito documentation)

Trying Managed Login

First, this time we'll check up to ④ in the diagram (the moment the code is returned in the URL).

oidc_authorization_code_flow_cognito_v2.png
The diagram again

1. Create a user pool

Open Cognito in the Management Console and click "Create user pool."

console.png

Select "Traditional web application" as the application type.

type.png

For "Options for sign-in identifiers" in the option settings, select "Email address."

mail.png

Enter http://localhost:3000/callback as the return URL and click "Create user directory."
returnurl.png

The screen changes and shows "Application and user pool created successfully."

done.png

The two things created here have roughly the following roles.

  • User pool: A place to store user information (email addresses, passwords, and so on). A user roster, so to speak
  • Application (called an "app client" in the documentation): Settings for each app that uses the user pool. It holds the client ID, the client secret, the return URL entered in this procedure, the permitted sign-in methods, and so on

You can create multiple applications in one user pool, so for example a web app and an Android app can share the same user roster while having separate settings per app (Cognito documentation). The client secret used in ⑥ of the diagram is also tied to this application.

Now, the screen showing "Application and user pool created successfully" includes a Quick Setup guide, but ignore it and click "Go to overview" at the bottom of the screen.

go-to-summary.png

2. Create a user

Click "Users" in the left pane.

user.png

Create one user.
create-user.png

Create it with the following.

  • Don't send an invitation
  • Set a password

create-user2.png

It was created.
creating-user-done.png

3. Display the login page

Click "App clients" in the left pane. Then click the app client we just created.
appclient.png

Click "View login page" at the top right.

show-login-page.png

There it is! The login page.

login-page.png

The URL was like https://ap-northeast-1xxxx.auth.ap-northeast-1.amazoncognito.com/login?client_id=yyy&response_type=code&scope=email+openid+phone&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback. So the return URL I set is attached as a query parameter.

4. Try logging in

Let's log in with the email address and password of the user created in "2. Create a user."

After entering them, I was taken to the Change password screen.

change-pw.png

Sure enough, "Force password change" was shown when the user was created. ↓Shown again

creating-user-done.png
Shown again: right after user creation. Look at the "Confirmation status" column

After registering a new password, I was taken to "This site can't be reached."

cannot-access.png

The URL is http://localhost:3000/callback?code=43609aaf-7271-46b0-82f3-afd7b08d65d2. Nothing is running on localhost:3000, so it's only natural that this happens (= "This site can't be reached"). We were redirected to the return URL, and the code is attached as a query parameter. We've confirmed "④ Return to the app with the code" from the diagram above!

oidc_authorization_code_flow_cognito_v2.png
The diagram again

Next, we'll check the continuation, the token exchange in ⑥ and ⑦.

Doing the token exchange yourself

1. Note down the necessary information

Note down the following three items shown in the Management Console.

Domain

It's under "Branding → Domain" in the left pane.

domain.png

Client ID and client secret

They're on the "App clients" screen.
id-and-secret.png

2. Store the client secret in a variable

After running the command below, paste the client secret from above. The value is assigned to the CLIENT_SECRET variable.

read -s CLIENT_SECRET

3. Get the code again

The code we obtained earlier has probably expired. Let's log in again and get a new code.

4. Hit the token endpoint

curl -s -X POST "https://<ドメイン>/oauth2/token" \
     -u "<クライアント ID>:${CLIENT_SECRET}" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d "grant_type=authorization_code" \
     -d "code=<新しいcode>" \
     -d "redirect_uri=http://localhost:3000/callback"

If it succeeds, JSON is returned.

By the way, if you run the same curl command again, the response is {"error":"invalid_grant"}. This means the code → token exchange works only once.

The response JSON contains the following five fields.

Field Content What it's used for
id_token JWT A certificate proving "who logged in." The app (BFF) reads it to learn the user's information.
access_token JWT A pass indicating "what you are allowed to do." You attach it as Authorization: Bearer ... when calling an API
refresh_token Encrypted JWT (JWE). The app can't read its contents; it just passes it to Cognito Used to get the two above reissued without making the user log in again when they expire. Because its lifetime is long (30 days by default), the damage is also greatest if it leaks.
expires_in Number (default 3600) How many more seconds id_token and access_token are valid. One hour by default
token_type "Bearer" The kind of usage for access_token. It means "whoever holds it can use it (Bearer = bearer of the token)." Simply holding the token is treated as being the user, so if it's stolen it can be used as is.

JWT?

It's pronounced "jot." It stands for JSON Web Token.

https://datatracker.ietf.org/doc/html/rfc7519

JWT has two forms: signed (JWS) and encrypted (JWE). id_token and access_token are the signed kind, consisting of three parts, header.payload.signature, joined by . (the encrypted JWE is split into five).

The payload of a signed JWT is not encrypted; it is merely encoded in Base64url (Base64 using only URL-safe characters). Anyone can decode and read it, so the rule is not to put secret information in it.

What prevents tampering is the third part, the signature. The app verifies this signature with the public key published by Cognito before trusting the contents.

Looking inside the id_token

Log in again to get a new code, and run the previous curl wrapped in RESP=$(...). This stores the JSON from earlier in an intermediate variable RESP, which we decode with the following Python command. Within the same session, the CLIENT_SECRET variable defined earlier can be used as is.

RESP=$(curl -s -X POST "https://<ドメイン>/oauth2/token" \
     -u "<クライアント ID>:${CLIENT_SECRET}" \
     -H "Content-Type: application/x-www-form-urlencoded" \
     -d "grant_type=authorization_code" \
     -d "code=<新しいcode>" \
     -d "redirect_uri=http://localhost:3000/callback")

Decode and display the contents (payload) of the id_token.

python3 -c 'import json,sys,base64; t=json.loads(sys.stdin.read())["id_token"].split(".")[1]; print(json.dumps(json.loads(base64.urlsafe_b64decode(t+"="*(-len(t)%4))),indent=2,ensure_ascii=False))' <<< "$RESP"

You should see JSON output.

{
  "at_hash": "BV9bhy0XmKRLc0HuAntskA",
  "sub": "47a40af8-8021-7087-bc3b-xxxxxxxxxxxx",
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxxxxxx",
  "cognito:username": "47a40af8-8021-7087-bc3b-xxxxxxxxxxxx",
  "origin_jti": "f02c185e-de3b-4182-98ad-366f0dbc228f",
  "aud": "72cd9abvmlmkt99xxxxxxxxxxx",
  "token_use": "id",
  "auth_time": 1792251173,
  "exp": 1792254773,
  "iat": 1792251173,
  "jti": "41f55969-8359-4a1f-bcec-f989e549a454",
  "email": "kazue@example.com"
}

The field names in the JSON should match the claim column in the table below. Here is what each one does.

Role Claim Meaning
Who sub An ID (UUID) that uniquely identifies the user within the user pool
cognito:username The username on Cognito
email Email address
Validity checks iss Issuer. The URL of the user pool. It's checked to reject tokens from other pools
aud Audience. It contained the client ID. As mentioned, you can create multiple applications in one user pool, so this is how you tell whether a received token is addressed to you.
exp / iat Expiration time and issue time (UNIX seconds)
auth_time The time the user actually entered the password and logged in. It doesn't change even when reissued by refresh
Token management token_use Whether it's id or access. Used to prevent mix-ups
jti / origin_jti The ID of this token itself, and the ID of the original login. Used for revocation
at_hash A hash of the access_token issued together. Proof that the two were issued as a set

Can we say "login succeeded"?

So far, we've followed the flow of login processing using Cognito.

oidc_authorization_code_flow_cognito_v2.png
The diagram again

We've completed ① through ⑦ in the diagram, but can we say that the user's login is complete at this point (= at the ⑦ stage)?

The interesting thing about this question is that the answer changes depending on whose viewpoint you take. Each participant has its own separate "logged in" state.

First, Cognito's viewpoint. For Cognito, "login succeeded" is the moment in ③ when it confirms that the ID and password are correct. At this point, Cognito places a cookie (Cognito's session) addressed to its own domain in the browser.

So what about the app (BFF)? In fact, at the ⑦ point it can't yet be said to be logged in.

  1. Verification: Check the signature, iss, aud, and exp of the received id_token. Until it passes here, the contents can't be trusted. (The "JWT?" section also said, "The app verifies this signature with the public key published by Cognito before trusting the contents.")
  2. Create the app's session: Generally, once verification passes, the app stores the information "this person is the user with sub = xxx" on the server side. It puts that session ID in a cookie, returns it to the browser, and redirects to the original page.

Thanks to 2, for subsequent requests the browser just sends the cookie, and the app knows "it's the same person as before." There's no need to query Cognito every time.

In other words, taking the browser, the app, and Cognito together, the login state is held in three layers.

Layer Where it is Approximate lifetime
Cognito session Cookie on the Cognito domain Depends on Cognito's settings. In my testing, it was a cookie that expired about 1 hour after login.
Token The app's server side id/access: 1 hour, refresh: 30 days
App session Cookie on the app's domain + server-side storage Decided by the app

So for the app, it is not yet in a "logged in" state. It's an intermediate stage.

Summary

I created a Cognito user pool and followed the login flow step by step using only the Management Console and curl.

  • Using Cognito lets you separate password storage and login-related features from the app
  • Login is a two-stage process: "receive a code via the browser (①–⑤)" and "exchange the code for a token server to server (⑥⑦)." The code that appears in the URL is designed so that it's hard to abuse on its own even if leaked
  • The code is valid for 5 minutes and can be used only once. A second exchange with the same code results in invalid_grant
  • The token endpoint returns id_token, access_token, and refresh_token. Anyone can decode the id_token and read its contents
  • The aud in the id_token contains the client ID, which lets you tell whether the token is addressed to you
  • However, merely receiving the token at ⑦ is not yet "login complete" for the app. Verification and session creation are needed

At first I thought, "See, adding Cognito makes things complicated," but after hitting each step by hand, I found that even the steps that look like detours each have their reasons.

Next time, I'd like to check by hand the question I left pending this time: "why can we trust the contents of a token that anyone can read?" In other words, signature verification.

Share this article

AWSのお困り事はクラスメソッドへ