API Gateway Cognito Authorizer 401 Unauthorized: ID Token vs Access Token, the Console Test Trap, OAuth Scopes, Redeploy and CORS

Logeshwaran
—

{"message":"Unauthorized"} with HTTP 401 from an API Gateway method that uses an Amazon Cognito authorizer means API Gateway rejected the token before your code ever ran. The most common reason surprises almost everyone: you sent the wrong kind of token. When the method has no OAuth scopes configured, API Gateway treats whatever you send as an ID token, so the access token that most login libraries hand you is rejected. When the method does have scopes, it expects an access token carrying one of them. That one rule also explains the most confusing symptom of all: the authorizer's Test button in the console only accepts ID tokens, so "it works in the console but not in Postman" is exactly what you see when your app sends the access token. The rest of the causes are short and checkable: the wrong header name, a token from a different user pool, an expired token, a change that was never redeployed to the stage, and a browser hiding the 401 behind a CORS error. HTTP APIs, with their JWT authorizers, follow different rules again, also covered below.

Ethan finished the booking app for Jake's repair shop: customers sign in with Amazon Cognito, and the app reads their bookings from an API in API Gateway. It worked perfectly in Ethan's testing. Then he connected the shop's tablet app, signed in as Jake, opened the bookings screen, and got one line: {"message":"Unauthorized"}. The token was fresh, Jake's account was real, and the authorizer's Test button in the console said "200." Jake asked whether the tablet was broken. It was not. The tablet was sending a perfectly valid token of the wrong kind. This page explains the ID token and access token rule, how to tell which one you have in ten seconds, and every other cause of a Cognito authorizer 401, from header names and user pools to redeploying, CORS and HTTP API JWT authorizers.

⚡ Quick Answer

• Method has no OAuth scopes? → Send the ID token, not the access token. The token rule.

• Need the access token? → Add OAuth scopes to the method that also appear in the token's scope claim. Scopes.

• Works in the console Test, fails elsewhere? → The Test button only accepts ID tokens. Why.

• Changed the authorizer and nothing changed? → Redeploy the API to the stage. Redeploy.

• Browser says CORS instead of 401? → Add CORS headers to the 4XX gateway responses. CORS.

Every check on this page is read-only, except the redeploy, which changes nothing but the stage's copy of your API.

New to these services? Our plain-English guides to what API Gateway is and what Amazon Cognito is explain the two pieces this error sits between.

🧭 NEW HERE? READ THESE FIRST

New to API Gateway or Cognito? These five pages make the rest of this one easier:

📌 Bookmark this; the token rule and the decode command solve most of these errors in a minute.

What the 401 means, and how it differs from 403 and 500

A Cognito user pool authorizer is a doorman in front of your API. Every request must carry a token from your user pool in a header; API Gateway checks the token's signature, its expiry and its claims, and only then passes the request to your Lambda function or backend. A 401 with {"message":"Unauthorized"} means the doorman said no. Your backend never saw the request, which is why its logs are empty.

API Gateway sends different "gateway responses" for different refusals, and the status code tells you which door you are stuck at:

StatusGateway response typeWhen API Gateway sends it
401UNAUTHORIZEDThe Cognito (or custom) authorizer failed to authenticate the caller: missing, wrong-type, expired or invalid token
403ACCESS_DENIEDAuthorization failed, for example access denied by a custom or Cognito authorizer
403MISSING_AUTHENTICATION_TOKENOften not about tokens at all: the path or method does not exist on the deployed stage
500AUTHORIZER_FAILURE / AUTHORIZER_CONFIGURATION_ERRORThe authorizer itself failed or could not be reached

So a 401 is almost always about the token you sent. If you see a 403 "Missing Authentication Token" instead, the URL or method is wrong, and our guide to that error covers it. If you see a 500, look at the authorizer's configuration, such as a deleted user pool.

Jake: "So the doorman never even phoned the kitchen. He looked at my ticket and turned me away."

Ethan: "Exactly. And it turns out he only accepts the green ticket, and my app was handing him the blue one. Both are real tickets from the same box office."

The token rule: ID token or access token?

When someone signs in to a Cognito user pool, Cognito hands back three tokens: an ID token, an access token and a refresh token. The first two look almost identical, long strings in three dot-separated parts, and both work as a bearer token. API Gateway's Cognito authorizer accepts only one of them for any given method, and which one depends on a single setting on the method: OAuth Scopes.

Method settingToken API Gateway expectsWhat it checks
No OAuth scopes (the default)ID tokenThat the token is a valid identity token from the authorizer's user pool
One or more OAuth scopesAccess tokenThat the token carries at least one of the method's scopes in its scope claim

That is the whole rule, and it is why so many people lose a day to this error. Most front-end login libraries and OAuth tools put the access token front and center, often in a field just called "token." Send that to a method with no scopes and API Gateway treats it as an ID token, the identity check fails, and you get 401. The fix is to send the ID token instead, or to add scopes to the method so the access token is the right one.

Tell which token you have, in ten seconds

A Cognito token is a JWT: three base64url parts separated by dots, and the middle part is a readable JSON list of claims. Two claims settle the question:

  • token_use is "id" in an ID token and "access" in an access token.
  • An ID token has an aud claim, the app client ID. An access token has no aud; it carries client_id and a scope claim instead.

Decode the middle part locally. Pasting tokens into websites hands a working credential to a stranger, so use one of these instead. In Python, on any system:

import base64, json

token = input("Paste the token: ").strip()
header, payload, signature = token.split(".")
payload += "=" * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload))
print(claims.get("token_use"), claims.get("aud"), claims.get("client_id"), claims.get("scope"))

Save it as whichtoken.py, run python3 whichtoken.py and paste the token at the prompt, which also keeps it out of your shell history. An access token from a normal user sign-in prints something like access None 1example23456789 aws.cognito.signin.user.admin: type access, no audience, the app client ID, and its scope. An ID token prints id followed by the app client ID as its audience.

Or in bash with jq, on Linux, macOS or Kali:

decode() { local p; p=$(echo "$1" | cut -d. -f2 | tr '_-' '/+'); while [ $(( ${#p} % 4 )) -ne 0 ]; do p="$p="; done; echo "$p" | base64 -d; echo; }
decode "PASTE_TOKEN" | jq '{token_use, aud, client_id, scope, iss, exp}'

While you are there, look at two more claims. iss must end with the ID of the user pool your authorizer uses, and exp is the expiry time in seconds since 1970; compare it with date +%s.

Get both tokens from the command line

To test without your app at all, sign in with the AWS CLI. This needs the ALLOW_USER_PASSWORD_AUTH flow enabled on the app client, and a user who has already set a permanent password:

aws cognito-idp initiate-auth --auth-flow USER_PASSWORD_AUTH \
  --client-id YOUR_APP_CLIENT_ID \
  --auth-parameters USERNAME=jake,PASSWORD='the-password' \
  --query 'AuthenticationResult.IdToken' --output text

Change IdToken to AccessToken in the query to get the access token. Then call your API with each, and the difference is obvious:

curl -s -H "Authorization: PASTE_ID_TOKEN" https://API_ID.execute-api.us-east-1.amazonaws.com/prod/bookings

If the ID token works and the access token gets 401, you have found the cause: the method has no scopes. If your app client uses a client secret, the same command also needs a SECRET_HASH parameter; a client without a secret is simpler for testing.

"Works in the console Test, fails in Postman or my app"

The authorizer's Test button in the API Gateway console only accepts an ID token. You cannot test an access token there at all. So the console happily says 200 for the ID token you pasted, while your app, Postman or Insomnia sends the access token and gets 401. Nothing is broken; the two are simply testing different tokens.

Postman adds a second trap. Its OAuth 2.0 helper fetches tokens for you and, by default, uses the access token, which is exactly the wrong one for a method without scopes. Either copy the ID token into an Authorization header yourself, or add scopes to the method so the access token is the right one. And check the obvious: in Postman's Headers tab, the checkbox beside the Authorization row must be checked, or the header is never sent.

Ethan's tablet app was using the access token, exactly as its login library suggested. Switching one line to send the ID token fixed Jake's bookings screen. Later he added scopes, for a reason covered below.

The authorizer reads the token from one request header, its token source. In the console you type a header name, normally Authorization. In the API, CloudFormation or the CLI, the same setting is the identity source, written method.request.header.Authorization. Those are the same thing in two notations; trouble starts when they are mixed up, for example a console field containing the full method.request.header.Authorization text, so the authorizer looks for a header by that literal name.

Check three things about the header your client actually sends:

  • The name matches the token source. If the authorizer reads Authorization, a token in X-Auth-Token or in a cookie is invisible to it.
  • Nothing extra sneaks in. A trailing newline from copying, a space, or quotes around the token make it invalid.
  • The Bearer prefix. HTTP APIs with a JWT authorizer accept the token on its own or with Bearer in front. For REST API Cognito authorizers, the safest test is to try both forms with curl and keep the one that works; many working setups send the raw token alone.
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: $TOKEN" https://API_ID.execute-api.us-east-1.amazonaws.com/prod/bookings
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN" https://API_ID.execute-api.us-east-1.amazonaws.com/prod/bookings

Changed the authorizer, but nothing changed? Redeploy

API Gateway REST APIs have two copies of your configuration: the one you edit in the console, and the one each stage serves. Creating an authorizer, attaching it to a method, or changing scopes or the token source only edits the first copy. Until you deploy the API to the stage again, callers keep hitting the old configuration. This is behind many reports of "I fixed it and it still fails," and behind the occasional "it suddenly works" an hour later, after someone else deployed.

Redeploy from the console with Deploy API and the stage name, or from the CLI:

aws apigateway create-deployment --rest-api-id API_ID --stage-name prod

Then test again with the same token. HTTP APIs usually deploy automatically to a stage with automatic deployment switched on, so this step is mostly a REST API problem.

Wrong user pool, expired token or a failed audience check

A token from a different user pool

The authorizer trusts only the user pools it is configured with. A token issued by another pool, such as your test pool when the API uses the production one, or the same pool name in another Region, fails. Compare the token's iss claim with the authorizer's user pool: the issuer ends with the pool ID, and the pool ID begins with its Region, as in us-east-1_Ab12Cd34E. One authorizer can trust up to 1,000 user pools, if you really need several.

An expired token

ID and access tokens expire after a time you set per app client, anywhere from 5 minutes to 1 day. A token that worked this morning can fail this afternoon. The exp claim tells you exactly when it ends. Apps normally use the refresh token to get new ID and access tokens before they expire; a 401 that appears after a fixed time every session is a refresh that is not happening.

The optional "Token validation" check

A Cognito authorizer has an optional Token validation field: a regular expression that API Gateway matches against the token's aud claim before accepting it. Access tokens have no aud claim, so with this field filled in, every access token is rejected, even on a method with the right scopes. Leave it empty if you use access tokens, or make sure your app sends ID tokens.

Using the access token: OAuth scopes on the method

There are good reasons to prefer the access token: it is the token meant for calling APIs, and scopes let you allow one app client to read bookings and another to change them. To make the authorizer accept access tokens, three pieces must line up:

  1. The scope exists. Custom scopes are defined on a resource server in your user pool, with names like https://api.example.com/bookings.read.
  2. The token carries it. The app client must be allowed that scope, and the app must ask for it when the user signs in through the OAuth flow. Check the token's scope claim with the decode command above.
  3. The method lists it. In the console, add the full scope name under the method's Authorization Scopes; from the CLI, add --authorization-scopes when you set up the method. Then redeploy.
aws apigateway put-method --rest-api-id API_ID --resource-id RESOURCE_ID --http-method GET \
  --authorization-type COGNITO_USER_POOLS --authorizer-id AUTHORIZER_ID \
  --authorization-scopes https://api.example.com/bookings.read

At runtime, the call succeeds if any scope on the method matches any scope in the token, and fails with 401 otherwise. A token from a plain username-and-password sign-in carries only the aws.cognito.signin.user.admin scope, which lets a user manage their own Cognito profile; add that scope to a method only if that is really what you want to allow.

Ethan eventually moved the booking API to scopes: bookings.read for the tablet and bookings.write for the shop's back-office app, so a stolen tablet token could never change a booking.

The browser says CORS, but the real error is 401

In a web app, an authorizer 401 often shows up in the browser console as a CORS error instead, something like "No 'Access-Control-Allow-Origin' header is present." That happens because the 401 comes from API Gateway itself, not from your Lambda function, and API Gateway's error responses carry no CORS headers unless you add them. The browser hides the response from your code, so all you see is CORS.

Two fixes, and you usually need both:

  • Add CORS headers to the gateway responses for 4XX errors (and 5XX). In the console's CORS settings you can select the gateway responses to include, or add Access-Control-Allow-Origin to the DEFAULT_4XX gateway response yourself, then redeploy. Your app will then see the real 401.
  • Leave the authorizer off the OPTIONS method. Browsers send a preflight OPTIONS request before the real one, and preflight requests carry no Authorization header. An OPTIONS method protected by the authorizer rejects every preflight, and nothing works in the browser even with perfect tokens. The OPTIONS method should use no authorization and a mock integration that returns the CORS headers, including Authorization in Access-Control-Allow-Headers.

Our guide to API Gateway CORS errors walks through the full setup.

HTTP APIs: the JWT authorizer plays by different rules

If your API is an HTTP API rather than a REST API, Cognito is connected through a JWT authorizer, and its checks are different and more explicit. For each request, API Gateway:

  1. Reads the token from the identity source, usually the Authorization header. The token may be sent alone or with the Bearer prefix.
  2. Checks the signature against the issuer's public keys, which it caches for up to two hours.
  3. Checks that iss matches the authorizer's issuer, which for Cognito is https://cognito-idp.REGION.amazonaws.com/USER_POOL_ID.
  4. Checks aud, or client_id when there is no aud, against the authorizer's audience list: put your app client ID there.
  5. Checks that exp, nbf and iat make sense against the current time.
  6. If the route has authorization scopes, checks that the token's scope claim includes at least one.

So on an HTTP API, both ID and access tokens can pass, as long as the audience list contains the app client ID; that works for ID tokens through aud and for access tokens through client_id. Because nothing in a JWT reliably marks it as an access token rather than an ID token, requiring scopes on routes is the way to accept only access tokens. A wrong issuer, often a typo in the pool ID or a missing Region, and an audience list holding the user pool ID instead of the app client ID are the two most common HTTP API mistakes.

Find the exact reason in the access logs

When the cause is not obvious, let API Gateway tell you. Turn on access logging for the stage, with a log format that includes the authorizer's own error message. For a REST API, these variables are the useful ones:

VariableWhat it records
$context.authorizer.errorThe error message returned from the authorizer
$context.authenticate.errorThe error message from the authentication attempt
$context.error.messageAPI Gateway's own error message, such as "Unauthorized"
$context.error.responseTypeThe gateway response type, such as UNAUTHORIZED
$context.statusThe status code returned to the caller

A format such as {"status":"$context.status","type":"$context.error.responseType","authorizer":"$context.authorizer.error","authn":"$context.authenticate.error"} puts the answer in CloudWatch for every failed request. HTTP APIs support $context.authorizer.error too. If the log group you expect does not appear, our guide to "The specified log group does not exist" covers why.

The five-minute diagnosis, in order

  1. Decode the token you are sending. Note token_use, iss and exp.
  2. Check the method's OAuth scopes. None means send an ID token; some means send an access token whose scope includes one of them.
  3. Compare the issuer with the authorizer's user pool, including the Region.
  4. Check the header name, the absence of stray characters, and try with and without Bearer .
  5. Redeploy the API to the stage, then test again with curl, outside the browser.
  6. Still failing? Turn on access logs with $context.authorizer.error and read the reason.

Troubleshooting by symptom

401 with an access token, 200 with the ID token

The method has no OAuth scopes, so it only accepts ID tokens. Send the ID token, or add scopes to the method and redeploy.

401 with an access token even though the method has scopes

The token's scope claim has none of the method's scopes, or the authorizer's Token validation field is set, which rejects every access token. Decode the token and compare.

The console Test works; Postman or the app gets 401

Test only accepts ID tokens. Your client is probably sending the access token, or not sending the header at all; check the header checkbox in Postman.

It worked yesterday; today every call gets 401

The tokens expired and the app is not refreshing them, or someone changed the authorizer and redeployed. Check exp, then the deployment history.

I fixed the configuration and nothing changed

The change was not deployed to the stage. Run create-deployment for that stage.

The browser shows a CORS error, curl shows 401

The gateway's 4XX responses have no CORS headers. Add them, and make sure OPTIONS is not behind the authorizer.

Test pool works, production pool fails

The token's issuer is a different user pool from the authorizer's. Use a token from the pool the authorizer trusts, or add the pool to the authorizer.

HTTP API returns 401 for every token

Check the JWT authorizer's issuer URL, including the Region, and that the audience list holds the app client ID, not the user pool ID.

500 instead of 401

The authorizer could not run, for example because its user pool was deleted, or a pre token generation Lambda put an invalid value into a claim. Check the authorizer configuration and any Cognito triggers.

403 "Missing Authentication Token" instead of 401

The path, method or stage in the URL does not exist on the deployed API. Check the URL against the stage's resources.

API Gateway Cognito authorizer 401: frequently asked questions

Why does my API Gateway Cognito authorizer return 401 Unauthorized?

API Gateway rejected the token before calling your backend. Most often you sent an access token to a method with no OAuth scopes, which only accepts ID tokens. Other causes are the wrong header, an expired token, a token from another user pool, or a change that was not redeployed.

Should I send the ID token or the access token to API Gateway?

For a REST API Cognito authorizer, send the ID token when the method has no OAuth scopes, and an access token when it has scopes that also appear in the token's scope claim. HTTP API JWT authorizers can accept either, depending on their audience and route scopes.

Why does the authorizer test work in the console but not in Postman?

The console's authorizer Test only accepts ID tokens. Postman's OAuth helper usually sends the access token, which a method without scopes rejects. Send the ID token in the Authorization header, or add OAuth scopes to the method.

How do I tell an ID token from an access token?

Decode the middle part of the JWT. An ID token has token_use set to id and an aud claim holding the app client ID. An access token has token_use set to access, no aud, and client_id and scope claims instead.

Do I need "Bearer" before the token in API Gateway?

For HTTP API JWT authorizers, the token can be sent alone or with the Bearer prefix. For REST API Cognito authorizers, test both forms with curl and keep the one that works; many setups send the raw token alone in the Authorization header.

Why do I still get 401 after changing the authorizer?

REST API changes take effect only after you deploy the API to the stage again. Use Deploy API in the console or aws apigateway create-deployment with the stage name, then test again.

How do I use access tokens with a Cognito authorizer?

Define custom scopes on a resource server in the user pool, allow them on the app client, request them at sign-in, and add the same scope names to the method's Authorization Scopes. Then redeploy. The call succeeds if any method scope is in the token.

Why is my access token rejected even with scopes configured?

Either the token's scope claim lacks the method's scopes, or the authorizer's optional Token validation regex is set. That check runs against the aud claim, which access tokens do not have, so it rejects every access token.

What does {"message":"Unauthorized"

mean in API Gateway?}

It is the UNAUTHORIZED gateway response, status 401: a Cognito or custom authorizer failed to authenticate the caller. Your backend was not called. Check the token type, header, expiry, user pool and deployment.

What is the difference between 401 Unauthorized and 403 in API Gateway?

401 means the authorizer could not authenticate the token. 403 ACCESS_DENIED means authorization was denied, and 403 Missing Authentication Token usually means the path or method does not exist on the deployed stage.

How long do Cognito tokens last?

ID and access tokens last a time you set per app client, from 5 minutes to 1 day. The exp claim in each token shows when it expires. Apps use the refresh token to get new ones before that.

Why does my browser show a CORS error instead of 401?

API Gateway's own 401 response has no CORS headers unless you add them to the 4XX gateway responses, so the browser blocks it and reports CORS. Also keep the authorizer off the OPTIONS method, because preflight requests carry no Authorization header.

How do I get a Cognito ID token from the command line?

Run aws cognito-idp initiate-auth with --auth-flow USER_PASSWORD_AUTH, your app client ID and the username and password, and query AuthenticationResult.IdToken. The app client must allow USER_PASSWORD_AUTH, and a client secret needs a SECRET_HASH too.

Why does my HTTP API JWT authorizer return 401?

The issuer URL, the audience list or the token's times do not match. The issuer must be the user pool's URL with its Region, and the audience list must contain the app client ID, which matches the ID token's aud or the access token's client_id.

What is the aws.cognito.signin.user.admin scope?

A scope Cognito puts in access tokens from user sign-in. It lets the user manage their own Cognito user profile. Add it to an API method only if every signed-in user should be allowed to call that method.

Do I need a Lambda function to use a Cognito authorizer?

No. A Cognito user pool authorizer is built into API Gateway, which checks the token itself. A Lambda authorizer is a separate type you write yourself, for custom rules such as tokens from another identity provider.

How do I see why the Cognito authorizer rejected a request?

Enable access logging on the stage with $context.authorizer.error, $context.authenticate.error and $context.error.responseType in the log format. CloudWatch then records the authorizer's own reason for every failed request.

The tablet app has worked ever since. Jake's favorite part is that Ethan printed the token rule on a sticky note for the next developer: "No scopes, ID token. Scopes, access token. Redeploy." Jake still could not explain what a JWT is, but he could now say, with some pride, why a doorman might turn away a perfectly good ticket. "It's not fake," he told a friend who was building an app. "It's just the wrong color."

📌 If you keep one line from this page

No scopes on the method, send the ID token; scopes on the method, send an access token that carries one; then redeploy.

Decode the token's token_use, iss and exp before changing anything; the answer is usually written inside it.

Revision note. Written October 12, 2026, for everyone holding a valid token the doorman would not accept. As the saying goes, measure twice, cut once; decode the token before you change the authorizer.

Related