Authentication vs Authorization: Why Valid Logins Get 403

Guilliano MolaireGuilliano Molaire18 min read

Authentication proves who a user or client is (logging in and receiving a token), while authorization decides what that identity is allowed to do (checking the token’s roles or scopes against the resource). When authentication fails the API should return 401 Unauthorized, and when a logged-in user lacks permission it should return 403 Forbidden.

Below, a small Node and Express API runs against a Keycloak realm, and the same request gets 401, then 403, then succeeds, with the lines of code and the Keycloak settings that decide each outcome.

Authentication vs authorization at a glance

Both words start with “auth”, both show up in the same middleware stack, and both produce an HTTP error when they fail, but they answer different questions and fail with different status codes.

Authentication Authorization
Question it answers Who is making this request? Is this identity allowed to do this?
When it happens in the example At login, and each time the API verifies a token After the token is verified, per route
What it checks Credentials at login; at the API, the token’s issuer, audience, signature and expiry (RFC 9068, section 4) Roles, scopes, group membership, ownership of the record, policy rules
Who does it in this post’s example Keycloak issues the token; the Express API verifies it The Express API, using claims Keycloak put in the token
Failure status 401 Unauthorized 403 Forbidden
What the client can do about it Get a valid token for the API Not repeat the request with the same credentials (RFC 9110, section 15.5.4)
Claims the example below checks iss, aud, exp, typ, the signature realm_access.roles, scope

What authentication actually proves

Authentication is the step where someone presents credentials and the identity provider decides whether to believe them. In the OpenID Connect authorization code flow, once the authorization server has validated the token request, it “returns a successful response that includes an ID Token and an Access Token” (OpenID Connect Core, section 3.1.3.3). The specification’s introduction says the protocol “enables Clients to verify the identity of the End-User based on the authentication performed by an Authorization Server” (OpenID Connect Core, section 1). The access token the client then sends to your API is, in the words of the OAuth spec, “a string representing an authorization issued to the client” (RFC 6749, section 1.4). A JWT access token also says who it is about: the JWT profile requires a sub claim for the subject and a client_id claim for the client that requested it (RFC 9068, section 2.2).

For your API, authentication means verifying that token. The JWT access token profile says that resource servers receiving one “MUST validate it in the following manner”, and its list includes these checks: the issuer identifier “MUST exactly match the value of the iss claim”, the resource server “MUST validate that the aud claim contains a resource indicator value corresponding to an identifier the resource server expects for itself”, it “MUST validate the signature of all incoming JWT access tokens” using the algorithm in the header, and “the current time MUST be before the time represented by the exp claim” (RFC 9068, section 4). For Keycloak tokens specifically, see how to verify a Keycloak access token on the backend and Keycloak token validation for APIs.

Those checks establish that the token is genuine and meant for your API. Deciding whether the call is allowed is a separate step. The last paragraph of the same section says: “If the JWT access token includes authorization claims as described in Section 2.2.3, the resource server SHOULD use them in combination with any other contextual information available to determine whether the current call should be authorized or rejected” (RFC 9068, section 4).

What authorization actually decides

Authorization is the decision your API makes once it knows who is calling: may this identity perform this action on this resource? The inputs usually come from the token (roles, scopes, groups) and from your own data (does this user own the order they are trying to delete?). The OAuth spec is explicit that this check belongs to the resource server: “The resource server MUST validate the access token and ensure that it has not expired and that its scope covers the requested resource” (RFC 6749, section 7).

In the Keycloak example below the two inputs are realm roles, which appear under realm_access.roles in the sample tokens, and scopes, which appear in the scope claim that the JWT profile says the token should carry when the request included a scope (RFC 9068, section 2.2.3). They come from different places: a role is assigned to the user in Keycloak, while a scope is something the client asks for, since in OAuth “the authorization and token endpoints allow the client to specify the scope of the access request using the scope request parameter” (RFC 6749, section 3.3). For more on the two, see Keycloak client scopes vs roles and OAuth scopes explained, and for decisions that depend on the resource itself, fine-grained authorization in Keycloak.

OWASP’s guidance is that the application “must always make a decision, whether implicitly or explicitly, to either deny or permit the requested access” and that permission “should be validated correctly on every request” (OWASP Authorization Cheat Sheet). Broken access control moved to the top of the OWASP Top 10 in 2021: 94% of applications were tested for some form of it, with an average incidence rate of 3.81% and over 318,000 occurrences in the contributed dataset (OWASP Top 10, A01:2021).

Why 401 and 403 are not interchangeable

The HTTP specification draws the line for you. A 401 “indicates that the request has not been applied because it lacks valid authentication credentials for the target resource”, and a server sending one “MUST send a WWW-Authenticate header field” telling the client how to authenticate (RFC 9110, section 15.5.2). A 403 “indicates that the server understood the request but refuses to fulfill it”, and if credentials were provided “the server considers them insufficient to grant access”, so the client “SHOULD NOT automatically repeat the request with the same credentials” (RFC 9110, section 15.5.4).

The naming is unfortunate, which makes the two easy to mix up: the status is called “Unauthorized”, but the HTTP spec defines it by the request lacking “valid authentication credentials” (RFC 9110, section 15.5.2), which is an authentication problem. The bearer token spec makes the mapping concrete for APIs that accept OAuth tokens (RFC 6750, section 3.1):

Situation Bearer error code HTTP status
No token at all none (when “the request lacks any authentication information”, the server “SHOULD NOT include an error code”, RFC 6750, section 3.1) 401, as in the example response in RFC 6750, section 3.1
Token is “expired, revoked, malformed, or invalid for other reasons” (RFC 6750, section 3.1) invalid_token 401 (the resource “SHOULD respond with the HTTP 401 (Unauthorized) status code”, RFC 6750, section 3.1)
Token is valid but “the request requires higher privileges than provided by the access token” (RFC 6750, section 3.1) insufficient_scope 403 (the resource server “SHOULD respond with the HTTP 403 (Forbidden) status code”, RFC 6750, section 3.1)

The practical consequence is on the client side. A 401 tells a client to authenticate again, which is right when the old token expired. If you return 401 for a permission problem, a client that reacts to 401 by refreshing its token or sending the user to log in will do that, and the new token will be refused the same way. A 403 tells the client not to repeat the request with the same credentials, so it can show the user that they are not allowed.

Talk through your identity setup with us

Book a demo and we will walk you through Skycloak with the sign-in, SSO and migration questions your team is working on.

A runnable Express API: 401, then 403, then 204

Here is the whole example. The API is a small orders service with two routes: reading orders needs the orders:read scope, and deleting an order needs the orders-admin realm role. We use jose for verification because its remote key set fetches the realm’s JSON Web Key Set (JWKS, the public keys Keycloak signs tokens with) “when the cache is missing or stale”, and a token signed with a key it does not know “triggers another fetch only when cooldownDuration has elapsed since the last successful fetch” (jose docs: createRemoteJWKSet). By default the cache lasts 10 minutes and the cooldown is 30 seconds (jose docs: RemoteJWKSetOptions), so after Keycloak rotates its signing key, a token signed with the new key gets a 401 if it arrives within 30 seconds of the last fetch.

You need a Keycloak server where you can use the admin console, curl and jq, and Node.js 18 or later, since the engines field of the Express package manifest on its main branch reads "node": ">= 18" (Express package.json).

Set up the Keycloak side

In the Keycloak admin console:

  1. Create the realm. The docs’ procedure is to click Create Realm next to the current realm, enter a name and click Create (Keycloak docs: creating a realm), so do that with the name shop, and do the remaining steps in shop.
  2. Create the API’s client. Go to Clients, click Create client, leave the type as OpenID Connect, set the client ID to orders-api and save (Keycloak docs: creating an OIDC client). It stands for the API, which the Keycloak docs call a service client: usually a client with no flows enabled and no tokens issued to it, which “represents an OAuth 2 Resource Server” (Keycloak docs: automatically add audience based on client roles), so you can turn its flows off.
  3. Create the front-end client. Create a second client with the client ID orders-web. Leave Client authentication off, which makes it what the docs call a public client, one that does not need a client secret to request a token (Keycloak docs: capability config). Turn Direct access grants on, so the client can use those grants; the docs describe them as a way for REST clients to obtain tokens on behalf of users with a POST that carries the user’s credentials (Keycloak docs: direct access grants), which is what the curl call below does.
  4. Create the user. Go to Users, click Add user, set the username to dana and save (Keycloak docs: creating users). On the user’s Credentials tab, set a password (the examples use the-test-password) with Temporary off, so the user is not asked to change it at the first login (Keycloak docs: setting a password).
  5. Create the role. Go to Realm roles, click Create role, name it orders-admin and save (Keycloak docs: creating a realm role).
  6. Create the scope. Go to Client scopes, click Create client scope, name it orders:read, set its type to Optional and leave Include in token scope on. Then open orders-web, go to its Client scopes tab, click Add client scope and add orders:read as optional. The difference between the two types matters for the example: a default client scope is applied to every token the client gets, while an optional one is applied “only when requested by the scope parameter” (Keycloak docs: linking client scopes).
  7. Set the audience. Keycloak adds an audience through mappers. The Audience Resolve mapper adds, as an audience, the client ID of each client that has at least one client role in the token (Keycloak docs: automatically add audience based on client roles). Our API uses a realm role and has no client roles, and for a service that “relies on realm roles or does not rely on the roles in the token at all” the docs suggest a hardcoded audience, a protocol mapper that adds the service client’s ID to the token’s audience (Keycloak docs: hardcoded audience). The same section says you can put that mapper in the client’s dedicated scope so it applies to every request, so in orders-web, open the Client scopes tab, click the dedicated scope (orders-web-dedicated), choose Add mapper, then By configuration, then Audience, and set Included Client Audience to orders-api. When we skipped this step in our test realm, orders-api was missing from the token’s aud, which fits the mapper behaviour above since orders-api has no client roles, and the audience option in the middleware below then rejected the token with 401 for the wrong reason.
  8. Do not assign the role yet. That is the point of the exercise.

The Keycloak docs title this flow “Resource owner password credentials grant (Direct Access Grants)” (Keycloak docs: direct access grants), and the OAuth security best current practice says that grant “MUST NOT be used” (RFC 9700, section 2.4). Use it only for a local test like this one and turn it off again when you are done.

export KC=https://auth.example.com/realms/shop

TOKEN=$(curl -s -X POST "$KC/protocol/openid-connect/token" \
  -d grant_type=password \
  -d client_id=orders-web \
  -d username=dana \
  -d password='the-test-password' \
  -d 'scope=openid orders:read' | jq -r .access_token)

# Look at the payload (base64url, decoded with Node)
node -e 'console.log(Buffer.from(process.argv[1].split(".")[1], "base64url").toString())' "$TOKEN" | jq .

The token claims, before and after

The two tokens below are the same user, the same client and the same scopes. Apart from their timestamps, they differ in one entry in realm_access.roles, which is what the second half of the example turns on. The other roles and the account audience were in our test realm’s tokens without any of the steps above adding them: Keycloak has default roles that it assigns to a user automatically when the user is created (Keycloak docs: using default roles), and what your realm puts there may differ.

Before the role is granted:

{
  "iss": "https://auth.example.com/realms/shop",
  "aud": ["orders-api", "account"],
  "sub": "f3c1a7e2-...",
  "typ": "Bearer",
  "azp": "orders-web",
  "exp": 1760700300,
  "iat": 1760700000,
  "preferred_username": "dana",
  "scope": "openid email profile orders:read",
  "realm_access": {
    "roles": ["default-roles-shop", "offline_access", "uma_authorization"]
  }
}

After the role is granted:

{
  "iss": "https://auth.example.com/realms/shop",
  "aud": ["orders-api", "account"],
  "sub": "f3c1a7e2-...",
  "typ": "Bearer",
  "azp": "orders-web",
  "exp": 1760701200,
  "iat": 1760700900,
  "preferred_username": "dana",
  "scope": "openid email profile orders:read",
  "realm_access": {
    "roles": ["default-roles-shop", "offline_access", "uma_authorization", "orders-admin"]
  }
}

The claims the middleware checks for authentication (iss, aud and typ) are the same in both, and each token passes while its exp is still in the future. The claim it checks for the delete differs by one string in an array. If you want to look at your own token this way, paste it into the JWT Token Analyzer; it decodes the header and payload in your browser.

The middleware

Save this as server.mjs in an empty folder, run npm install express jose there, and start it with KC_ISSUER set to your realm URL and KC_AUDIENCE set to orders-api:

KC_ISSUER=https://auth.example.com/realms/shop KC_AUDIENCE=orders-api node server.mjs
import express from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';

const ISSUER = process.env.KC_ISSUER;     // e.g. https://auth.example.com/realms/shop
const AUDIENCE = process.env.KC_AUDIENCE; // the API's client ID, e.g. orders-api
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}/protocol/openid-connect/certs`));

const app = express();

// Authentication: who is calling? Every failure here is a 401.
async function authenticate(req, res, next) {
  const [scheme, token] = (req.headers.authorization ?? '').split(' ');
  if (scheme !== 'Bearer' || !token) {
    res.set('WWW-Authenticate', 'Bearer realm="orders-api"');
    return res.status(401).end();
  }
  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: ISSUER,
      audience: AUDIENCE,
      algorithms: ['RS256'],
      requiredClaims: ['exp'],
      clockTolerance: 5,
    });
    if (payload.typ !== 'Bearer') throw new Error('not an access token');
    req.auth = payload;
    return next();
  } catch {
    res.set('WWW-Authenticate', 'Bearer realm="orders-api", error="invalid_token"');
    return res.status(401).json({ error: 'invalid_token' });
  }
}

// Authorization: may this identity do this? Every failure here is a 403.
function requireRealmRole(role) {
  return (req, res, next) => {
    const roles = req.auth.realm_access?.roles ?? [];
    if (!roles.includes(role)) {
      return res.status(403).json({ error: 'insufficient_permissions', required_role: role });
    }
    return next();
  };
}

function requireScope(scope) {
  return (req, res, next) => {
    const granted = (req.auth.scope ?? '').split(' ');
    if (!granted.includes(scope)) {
      res.set('WWW-Authenticate', `Bearer realm="orders-api", error="insufficient_scope", scope="${scope}"`);
      return res.status(403).json({ error: 'insufficient_scope', required_scope: scope });
    }
    return next();
  };
}

app.get('/orders', authenticate, requireScope('orders:read'), (req, res) => {
  res.json({ caller: req.auth.preferred_username, orders: [] });
});

app.delete('/orders/:id', authenticate, requireRealmRole('orders-admin'), (req, res) => {
  res.status(204).end();
});

app.listen(3000, () => console.log('orders-api listening on :3000'));

In that code, the authenticate function has exactly one success path and every exit from it that is not next() is a 401 with a WWW-Authenticate header, because that is what the HTTP spec requires. The routes run requireRealmRole and requireScope after authenticate, so they only see authenticated requests, and every exit from them that is not next() is a 403. The typ check rejects ID tokens: in the Keycloak source, the TokenUtil class declares the constants TOKEN_TYPE_BEARER = "Bearer" and TOKEN_TYPE_ID = "ID" (Keycloak TokenUtil.java), and the sample tokens above carry "typ": "Bearer"; more on why that matters below.

Watch the same request move from 401 to 403 to 204

With the token from the step above, before the role is assigned:

# No token: authentication fails
curl -i http://localhost:3000/orders
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer realm="orders-api"

# Valid token, read scope present: authenticated and authorized
curl -i -H "Authorization: Bearer $TOKEN" http://localhost:3000/orders
# HTTP/1.1 200 OK
# {"caller":"dana","orders":[]}

# Delete with no token: authentication fails
curl -i -X DELETE http://localhost:3000/orders/42
# HTTP/1.1 401 Unauthorized
# WWW-Authenticate: Bearer realm="orders-api"

# Same valid token, delete needs the orders-admin role: authenticated, not authorized
curl -i -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:3000/orders/42
# HTTP/1.1 403 Forbidden
# {"error":"insufficient_permissions","required_role":"orders-admin"}

This post is about that fourth response. The signature verified, the issuer and audience matched and the token has not expired, so the API knows the caller is Dana, but it still refused the delete because Dana’s token does not carry the orders-admin role. Because orders:read is an optional client scope, a token requested without it in the scope parameter would not carry it, and requireScope would answer the GET with 403 and error="insufficient_scope", which is the same lesson from the scope side.

Grant the role in Keycloak and run it again

Follow the docs’ procedure for assigning role mappings (Keycloak docs: assigning role mappings): click Users, open dana, go to the Role mappings tab, click Assign role, select orders-admin in the dialog and click Assign. On a Skycloak cluster the same thing is in the dashboard: select the cluster and realm in the sidebar, open Users under Identity, open the user and use the Role Mappings tab, or put the role in the roles column of a CSV import (Skycloak docs: user management).

Keycloak turns the user’s role mappings into claims when it creates the access token (Keycloak docs: role scope mappings), and this API reads roles only from the token, so the token you already have still lacks orders-admin and keeps getting 403. The same section explains that with Full Scope Allowed on, every access token “contains all roles the authenticated user holds”, and that with role scope mappings instead, the token carries only the roles mapped to the client’s scope; so if Full Scope Allowed is off for orders-web, add orders-admin to that client’s role scope mappings as well (Keycloak docs: role scope mappings). Request a new one and the delete goes through:

TOKEN=$(curl -s -X POST "$KC/protocol/openid-connect/token" \
  -d grant_type=password -d client_id=orders-web \
  -d username=dana -d password='the-test-password' \
  -d 'scope=openid orders:read' | jq -r .access_token)

curl -i -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:3000/orders/42
# HTTP/1.1 204 No Content

If the 403 does not go away after a fresh token, work through our Keycloak 403 troubleshooting guide, and check that you assigned a realm role, since this middleware reads only realm_access.roles.

The common mistakes, and what each one costs

The example above is small, but the four mistakes below show up in real APIs of every size.

Returning 401 for a permission failure. As described above, a 401 tells the client its credentials are bad, so a front end that handles 401 by redirecting to login will send a fully logged-in user back to the login page, or into a refresh loop. Return 401 only when credentials are missing or invalid, and 403 when a valid identity lacks the permission.

Checking only that a token exists. A middleware that decodes the token and reads preferred_username without verifying the signature, issuer, audience and expiry has not authenticated anything, because anyone can mint a JWT with any claims in it. OWASP lists “replaying or tampering with a JSON Web Token (JWT) access control token” among the common access control failures (OWASP Top 10, A01:2021). The jwtVerify call with issuer, audience and algorithms pinned is the minimum, and we cover the rest in how to verify a Keycloak access token on the backend.

Accepting ID tokens at the API. An ID token is “a security token that contains Claims about the Authentication of an End-User”, and its audience “MUST contain the OAuth 2.0 client_id of the Relying Party” (OpenID Connect Core, section 2). Its audience is the client that asked for it, not your API, so an API that accepts it is trusting a token that was never meant for it. The typ check in the example rejects it; for more, see ID token vs access token.

Putting authorization logic in the front end. Hiding the delete button for users without the admin role is good interface design, but it does not stop anyone from calling the API directly. OWASP is direct about it. Its Authorization Cheat Sheet says that “client-side logic is often easy to bypass” and that the checks belong on the server, at the gateway or in a serverless function (OWASP Authorization Cheat Sheet), and the “How to Prevent” list in its Top 10 entry for broken access control includes “Except for public resources, deny by default” (OWASP Top 10, A01:2021). The requireRealmRole middleware runs whether or not the button was visible. If your API is split into several services, see how Keycloak secures Node.js microservices.

Frequently asked questions

Is authentication the same as logging in?

Logging in is one way authentication happens, and the most familiar one. In an OAuth and OpenID Connect setup the user typically logs in at the identity provider, which then issues tokens, and requests to your API are authenticated by verifying one of those tokens rather than by sending the password again. For more on the protocol, see OpenID Connect explained for developers.

Does single sign-on handle authorization?

No. Single sign-on (SSO) lets a user authenticate once and reach many applications, and OAuth lets an application obtain limited access to a service on a user’s behalf (RFC 6749), but the check of whether a token’s scope covers the requested resource is left to the resource server (RFC 6749, section 7), which is your API. For how the two relate, see SSO vs OAuth.

Should I use roles or scopes to authorize an API call?

They answer different questions, so an API can check either or both. A role is assigned to the user (“this person is an orders admin”), and a scope is what the client application requested (“this app may read orders”). In the example the read route checks a scope and the delete route checks a role; a route that needs both can chain both middleware functions.

Can Keycloak make the authorization decision instead of my API?

It can, for cases where you want the policy outside your code. The Authorization Services guide opens by saying that “Keycloak supports fine-grained authorization policies and is able to combine different access control mechanisms”, and its list of those mechanisms includes attribute-based, role-based, user-based, context-based, rule-based and time-based access control (Keycloak Authorization Services Guide); see also fine-grained authorization in Keycloak. When your rules fit in roles and scopes, checking them from the token in middleware, as above, keeps the decision in code you can read and test.

What to do next

Separate the two checks in your own code the way the example does: one function that establishes identity and only ever returns 401, and one or more functions that check permission and only ever return 403. Then read a real token from your realm and confirm that the claims you authorize on are actually present, because a missing audience mapper, for example, turns every call into a 401 before any permission check runs.

If you want to try the walkthrough without standing Keycloak up yourself, a managed Keycloak realm on Skycloak gives you the admin console, the user and role management shown above and a public issuer URL to point KC_ISSUER at. And whenever a 403 surprises you, paste the token into the JWT Token Analyzer first, to see whether the role or scope your API checks is in the token at all.

Sources

Talk through your identity setup with us

Book a demo and we will walk you through Skycloak with the sign-in, SSO and migration questions your team is working on.

Guilliano Molaire
Written by
Founder, Skycloak

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

© 2026 Skycloak. All Rights Reserved. Design by Yasser SolimanKeycloak™ is a trademark of the Linux Foundation. Skycloak is not affiliated with the Keycloak project.