Keycloak cookie based SSO on real example

By | March 26, 2025

Intro

Many articles have references to SSO capabilities of Keycloak, but they often don’t explain how it works under the hood. In this article we have simple environment in form of Docker Compose with Keycloak and two Angular applications that will help you to understand how cookies based authentication works with Keycloak. We will dive deep into this topic to explore the details and gain a better understanding of the key concepts. I believe this will be helpful for:

  • SSO/Keycloak architects
  • developers who customize Keycloak using custom SPIs
  • companies looking for leveraging SSO functionality

You can find the full GitHub repository, along with instructions to clone and run it, here: https://github.com/TorinKS/sso-mfe-keycloak

Our Docker Compose setup consists of the following:

  • An Angular mfe-a application, hosted on the local domain mfe-a.home.arpa.
  • An Angular mfe-b application, hosted on the local domain mfe-b.home.arpa.
  • Keycloak, hosted on the domain sso.home.arpa.
  • HAProxy, configured to enable working with these domains on localhost using FQDNs.

Before we understand what cookie-based SSO in Keycloak is, let’s take a look at an example architecture of an application that uses cookies for authentication.

Below are the details of how it works:

  • the browser (web client/user agent) sends a GET request to retrieve the authentication form.
  • the web server (framework) generates the form and adds a special random value (“nonce”), which is used to protect against replay attacks. Typically, this “nonce” is returned as a hidden element in the HTML form. Additionally, the server creates a session, which is persisted in a database, file system, cache, etc., and is not yet authenticated. The session is mapped to the “nonce” so that the server can later verify during authentication that the same browser, with the corresponding session cookies, initially received the authentication form.
  • during authentication, the client submits the authentication form along with the “nonce.” If everything is valid, the web server marks the session as authenticated. From this point onward, the web client can interact with the web server (or other backends) that support this session-based authentication mechanism until the cookies expire.

We can say that this cookie-based authentication allows us to avoid repeating the process of entering a login and password each time we want to access a web server page. This is how authentication cookies work in Spring Security (https://docs.spring.io/spring-security/reference/servlet/authentication/session-management.html) and other similar frameworks in different stacks.

Additionally, cookies are typically HTTP-only and tied to a particular domain, which mitigates an attacker’s ability to manipulate these cookies in the browser.

Now imagine that in your organization you have a hundred of application each of them use its own cookies, even with the same name, for authentication. Basically, you would end up with the session cookies for different domain names, such as:

  • app1.company.com
  • jira.company.com
  • gitlab.company.com, and so on.

This is where a centralized authentication solution with SSO capabilities (like Keycloak) enters the scene. The industry trend is to shift from separate authentication on each service to a centralized solution that authenticates all users. This provides several significant benefits for us:

  • having one service for authentication, we can federate users to this service from other services like AD, OpenLDAP.
  • we can apply security policies (MFA, strong password policies, etc.) in one place.
  • we can eliminate the need to write authentication logic in other services by shifting this responsibility to one centralized service and so on.
  • we can set authentication cookies on our centralized authentication service and reuse them for authentication on other services, leveraging cookie-based SSO capabilities.

From a service integration perspective, implementing SSO-enabled authentication requires just two key steps:

  1. Integrate an OAuth/OIDC client library appropriate for your application framework
  2. Register your application as an OIDC client within Keycloak (your centralized authentication service)

When a user attempts to access a protected resource, the OIDC client library automatically initiates the authorization code flow (recommended in most cases flow), redirecting the user’s browser to Keycloak’s authentication endpoints. Upon successful authentication, Keycloak establishes persistent SSO sessions through secure cookies and issues standard OAuth tokens to the requesting application.

The true power of this architecture becomes evident in subsequent authentication attempts. When a user navigates to another application, the existing Keycloak SSO session cookies are automatically included in the request. Keycloak recognizes these valid session cookies, bypassing the username/password challenge and seamlessly issuing appropriate tokens to the requesting application.

Moreover, the same approach works on mobile platforms, where mobile applications can reuse external user agents and their capabilities to work with cookies, just like browsers, to implement SSO for multiple mobile applications on the same device. See https://datatracker.ietf.org/doc/html/rfc8252 for additional details. When it comes to desktop applications, the situation is similar, you just reuse desktop browser.

So, let’s take a look at Keycloak cookie based authentiation. But before we start, take a look at simplified Authorization Code (the authentication flow typically used by default for browser-based application – https://datatracker.ietf.org/doc/html/rfc6749) flow UML sequence diagram

There are 3 steps in this flow:

  1. the client application generates a code verifier and its derived code challenge
  2. the web client authenticates and receives an authorization code in a 302 HTTP redirect, which is then reused for exchanging it for access token, refresh and id token
  3. web client redeems the authorization code for a token.

This flow is slightly simplified here, so in another article, we will be able to go into more details.

Code example

Our Docker compose https://github.com/TorinKS/sso-mfe-keycloak/blob/main/docker-compose.yml has these components:

  • haproxy
  • angular app mfe-a on mfe-a.home.arpa domain
  • angular app mfe-b on mfe-b.home.arpa domain
  • Keycloak
  • PostgreSQL

The https://github.com/TorinKS/sso-mfe-keycloak/blob/main/haproxy/haproxy.cfg HAProxy configuration file defines how incoming HTTP requests are routed to different backend services based on the requested hostname. You can find the breakdown of the main part of the configuration below.

The “frontend section” defines how incoming requests are handled and routed to backends:

  • frontend http-in: defines a frontend named http-in.
  • mode http: specifies that this frontend operates in HTTP mode.
  • bind *:80: binds the frontend to port 80 on all available network interfaces.
  • use_backend mfe-a.home.arpa if { req.hdr(host) -i mfe-a.home.arpa }: routes requests to the mfe-a.home.arpa backend if the host header matches mfe-a.home.arpa (case-insensitive).
  • use_backend mfe-b.home.arpa if { req.hdr(host) -i mfe-b.home.arpa }: routes requests to the mfe-b.home.arpa backend if the host header matches mfe-b.home.arpa.
  • use_backend sso.home.arpa if { req.hdr(host) -i sso.home.arpa }: routes requests to the sso.home.arpa backend if the host header matches sso.home.arpa.

The "backend sections” define how requests are forwarded to specific servers:

  • backend mfe-a.home.arpa: defines a backend named mfe-a.home.arpa.
  • mode http: specifies that this backend operates in HTTP mode.
  • server mfe-a.home.arpa ${MFE_A_IP}:${MFE_A_EXPOSED_PORT} check: forwards requests to the server at ${MFE_A_IP}:${MFE_A_EXPOSED_PORT} and performs health checks.

The same applies to the mfe-b.home.arpa backend. Both configurations are similar, as each points to an Angular application hosted on a Node.js web server. The key difference is that the two applications use different OIDC client configurations in keycloak-init.factory.ts: one application is configured with the ‘mfe-a’ OIDC client, while the other uses ‘mfe-b’. This was done specially to allow you to see how SSO works in Keycloak for the same user and different OIDC clients (applications), and this can be scaled unlimited, one user and multiple sessions and tokens for differrent OIDC clients.

We also have a special script here that creates all the needed configuration in Keycloak to avoid manual work – https://github.com/TorinKS/sso-mfe-keycloak/blob/main/configure.sh :

  • realm “company-external”
  • OIDC clients with their configuration (we use public OIDC clients, which are recommended for frontend SPA, mobile applications)
  • user test-user with the same password

Each Angular application uses keycloak-angular lib and the configuration as shown below

const KC_OPTIONS: KeycloakOptions = {
  config: {
    url: 'http://sso.home.arpa/auth',
    realm: 'company-external',
    clientId: 'mfe-a',
  },
  initOptions: {    
    onLoad: 'login-required',    
    flow: 'standard',
    checkLoginIframe: false,
    enableLogging: true,
    useNonce: true,
    adapter: 'default',
    pkceMethod: 'S256',
    
  },
  loadUserProfileAtStartUp: true
};

This is an OIDC configuration for the Angular Library, which was mentioned above as a needed part to enable SSO authentication in your application. Basically, these few line of code is everything that you need to make centralized authentication work and to support cookies based SSO.

Our Keycloak configuration has:

  • two public OIDC clients mfe-a, mfe-b
  • for each of OIDC client, we configure Valid Redirect URIs and Web origins

A valid Redirect URI parameter is needed so Keycloak can redirect the authorization code to the proper location, and web origins parameters configure CORS for the frontends properly.

Keycloak / configuration Valide redirect URIs and Web origins

Another aspect is that for browser-based authentication, Keycloak uses predefined steps involved in the authentication process used by Browser Built-in flow

Keycloak / authentication flows configuration

That flow includes Cookie based authentication as the first step in the flow.

Keycloak authentication flows / cookie based authentication

The “Alternative” parameter tells Keycloak to use one of the available authentication:

  • Cookie
  • Identity Provider Redirector
  • Forms

If authentication cookies are available in the request to /auth endpoints the authentication will be done by this “Cookie” step.

Just clone, configure, and start the project with Docker Compose. Open http://mfe-a.home.arpa address and Dev Tools in Chrome and in Network tab check “Preserve log” and “Disable cache” checkboxes, you will see that Angular application redirects browser to sso.home.arpa

Angular SPA authentication in Keycloak / first request to /auth endpoint

This is the first request to /auth endpoint: http://sso.home.arpa/auth/realms/company-external/protocol/openid-connect/auth

During authentication with ‘test-user’ login and ‘test-user’ password, you will reach 2-nd /authenticate endpoints as shown below

Angular app authentication, request to /authenticate endpoint
http://sso.home.arpa/auth/realms/company-external/login-actions/authenticate?client_id=mfe-a&tab_id=7vH0Q8mRL44&client_data=eyJydSI6Imh0dHA6Ly9tZmUtYS5ob21lLmFycGEvYXNzZXRzLyIsInJ0IjoiY29kZSIsInJtIjoiZnJhZ21lbnQiLCJzdCI6IjQ4OTE3NWQ2LTk1NjYtNGMwZS1hYTQwLTFlZGQ3YjhlYmQ5NyJ9

and you get back 302 Redirect with Authorizatin Code in the Location field (code parameter) as in the example below

http://mfe-a.home.arpa/assets/#state=2427e05f-269a-488c-a1d9-8c3a8ae5fa6c&session_state=94b63071-cc84-4c08-a1ec-4e6e18d59b1e&iss=http%3A%2F%2Fsso.home.arpa%2Fauth%2Frealms%2Fcompany-external&code=43dae82f-8f83-4e6b-8203-84e0575cf329.94b63071-cc84-4c08-a1ec-4e6e18d59b1e.acd095ce-53c4-47fc-8886-545b2890b684

And the last request is the POST request to /token endpoint

http://sso.home.arpa/auth/realms/company-external/protocol/openid-connect/token

and finally, we get back our tokens

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJHbXZ1Sjh2ZmdVd1BCSDZFS2FaQTB4M1djMWNFT1BxR01yc2d2WmNIWGRvIn0.eyJleHAiOjE3NDI5MzA2NzMsImlhdCI6MTc0MjkzMDM3MiwiYXV0aF90aW1lIjoxNzQyOTMwMzU3LCJqdGkiOiIwYzlkZjE0NS0xYjQ3LTQyZjctYWNmNS0zYmMzNDZjMjkyOGIiLCJpc3MiOiJodHRwOi8vc3NvLmhvbWUuYXJwYS9hdXRoL3JlYWxtcy9jb21wYW55LWV4dGVybmFsIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6ImVmM2ZmMWVjLWY0NjctNGFhZC05OWIyLTMwNDY2ZjdkNmQ0MCIsInR5cCI6IkJlYXJlciIsImF6cCI6Im1mZS1hIiwic2lkIjoiOTRiNjMwNzEtY2M4NC00YzA4LWExZWMtNGU2ZTE4ZDU5YjFlIiwiYWNyIjoiMSIsImFsbG93ZWQtb3JpZ2lucyI6WyJodHRwOi8vbWZlLWEuaG9tZS5hcnBhIl0sInJlYWxtX2FjY2VzcyI6eyJyb2xlcyI6WyJkZWZhdWx0LXJvbGVzLWNvbXBhbnktZXh0ZXJuYWwiLCJvZmZsaW5lX2FjY2VzcyIsInVtYV9hdXRob3JpemF0aW9uIl19LCJyZXNvdXJjZV9hY2Nlc3MiOnsiYWNjb3VudCI6eyJyb2xlcyI6WyJtYW5hZ2UtYWNjb3VudCIsIm1hbmFnZS1hY2NvdW50LWxpbmtzIiwidmlldy1wcm9maWxlIl19fSwic2NvcGUiOiJvcGVuaWQgZW1haWwgcHJvZmlsZSIsImVtYWlsX3ZlcmlmaWVkIjpmYWxzZSwicHJlZmVycmVkX3VzZXJuYW1lIjoidGVzdC11c2VyIn0.J_HIqMzYEAD7WOXaFnog55R6Q-0uRQVIWIjjHW6gpOKMqdSwJD1mJMCZaO7n9D_Kz2dMxdvifw8Pi4Twgscdapt0ZKXfDuY7zaR4g6NN-w-Y3rxKPu1XrB_OSgr4HNr4B-LhuDra1elRMCnHYFM5CWdEHNADBKYqPTb2ndTh6jQfpVW7ExsLLORWgCC4Dj1EJvQLf9U6ygXEf_XM3ZwAsqFaWhVEPTVLxhYCrtwV9TEBItEwJmRQvbicrqUt-gNVLnsN7kQRAemNovWP6pWx_UbahmXsLFQXIeQ7C6TS9eTLyMk1Bo7kXHAH1p-mqICA9gYZdQ8l0wfn1KksCdIBoQ",
    "expires_in": 300,
    "refresh_expires_in": 1800,
    "refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICI4Mzk3OGVmYy00NmZlLTQ0MjgtOWY0MS1jYTlkOTE0NWEzNGYifQ.eyJleHAiOjE3NDI5MzIxNzMsImlhdCI6MTc0MjkzMDM3MywianRpIjoiY2Y5MGMyMzYtNWRiZS00NzAxLWFmZmQtZTUwMDJhMmVkODk0IiwiaXNzIjoiaHR0cDovL3Nzby5ob21lLmFycGEvYXV0aC9yZWFsbXMvY29tcGFueS1leHRlcm5hbCIsImF1ZCI6Imh0dHA6Ly9zc28uaG9tZS5hcnBhL2F1dGgvcmVhbG1zL2NvbXBhbnktZXh0ZXJuYWwiLCJzdWIiOiJlZjNmZjFlYy1mNDY3LTRhYWQtOTliMi0zMDQ2NmY3ZDZkNDAiLCJ0eXAiOiJSZWZyZXNoIiwiYXpwIjoibWZlLWEiLCJzaWQiOiI5NGI2MzA3MS1jYzg0LTRjMDgtYTFlYy00ZTZlMThkNTliMWUiLCJzY29wZSI6Im9wZW5pZCBhY3Igcm9sZXMgd2ViLW9yaWdpbnMgYmFzaWMgZW1haWwgcHJvZmlsZSJ9.xeVL_uJTAQxIc59sUXtc4Ogn1uT8KCTmvIwOmmBgcXnYzxpR3aaEPe59ppP7QwASu5iW-_IIVuis8FYzGok-4Q",
    "token_type": "Bearer",
    "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJHbXZ1Sjh2ZmdVd1BCSDZFS2FaQTB4M1djMWNFT1BxR01yc2d2WmNIWGRvIn0.eyJleHAiOjE3NDI5MzA2NzMsImlhdCI6MTc0MjkzMDM3MywiYXV0aF90aW1lIjoxNzQyOTMwMzU3LCJqdGkiOiI4ZjQ1MWJkZC00NTc0LTRjMjctODJkMC1kNGM5OWQ3NjE2ODkiLCJpc3MiOiJodHRwOi8vc3NvLmhvbWUuYXJwYS9hdXRoL3JlYWxtcy9jb21wYW55LWV4dGVybmFsIiwiYXVkIjoibWZlLWEiLCJzdWIiOiJlZjNmZjFlYy1mNDY3LTRhYWQtOTliMi0zMDQ2NmY3ZDZkNDAiLCJ0eXAiOiJJRCIsImF6cCI6Im1mZS1hIiwibm9uY2UiOiIyN2ZkM2UyYS03ZjcwLTRkOWYtODJiYi01MzQyNGJmMzA2Y2EiLCJzaWQiOiI5NGI2MzA3MS1jYzg0LTRjMDgtYTFlYy00ZTZlMThkNTliMWUiLCJhdF9oYXNoIjoiVUY5WnhnblFlUWhSZ0UyUGxMZDhhUSIsImFjciI6IjEiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInByZWZlcnJlZF91c2VybmFtZSI6InRlc3QtdXNlciJ9.Gdy3EZCGgidI2fdachUu6WGiFKT3ZFLliaRvlrvFlzNVUBkPXWmecYHLawte9xo134kNCEzCsASpxQYiQVXYmth5_qCmoY93g6KJ4UX2BVCWGFFrxIO2i_R3NwTJ0PLw5FLu_2vpahmd9yqw3QVFEaVojEtSY5NH8jH_nbcOtNMFKWLcNB0ycwE7r8Uu-QyfUeDMUY76HAu9_aMaKgm5gBunVaR3mp1btu_3W6vuhuUNZRI2iGpQhWGZejg4xBgXAT7pLYUC83G80aNc_Gf_-eIpjIM570asWbmxf8fxsaOyH1X3Lqyh6rqoljOQlYBQ2UFfqFNmBawSc4MRpyEw7w",
    "not-before-policy": 0,
    "session_state": "94b63071-cc84-4c08-a1ec-4e6e18d59b1e",
    "scope": "openid email profile"
}

Here we are successfully authenticated in our first Angular applciation and get back tokens. Keycloak also created the session for the user ‘test-user’ and OIDC client ‘mfe-a’

Keycloak authentication session for one mfe-a OIDC client

The JWT token is issued to the user test-user (preffered_username claim) and OIDC client mfe-a (azp claim), as you can see, according to the JWT token decoded payload below

{
  "exp": 1742930673,
  "iat": 1742930372,
  "auth_time": 1742930357,
  "jti": "0c9df145-1b47-42f7-acf5-3bc346c2928b",
  "iss": "http://sso.home.arpa/auth/realms/company-external",
  "aud": "account",
  "sub": "ef3ff1ec-f467-4aad-99b2-30466f7d6d40",
  "typ": "Bearer",
  "azp": "mfe-a",
  "sid": "94b63071-cc84-4c08-a1ec-4e6e18d59b1e",
  "acr": "1",
  "allowed-origins": [
    "http://mfe-a.home.arpa"
  ],
  "realm_access": {
    "roles": [
      "default-roles-company-external",
      "offline_access",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "openid email profile",
  "email_verified": false,
  "preferred_username": "test-user"
}

The other side of this authentication is that we got SSO cookies from sso.home.arpa:

  • AUTH_SESSION_ID
  • KEYCLOAK_IDENTITY
  • KEYCLOAK_SESSION
Keycloak, cookies based authentication

Now, when we open mfe-b.home.arpa in the browser OIDC library redirects us to /auth endpoint

http://sso.home.arpa/auth/realms/company-external/protocol/openid-connect/auth?client_id=mfe-b&redirect_uri=http%3A%2F%2Fmfe-b.home.arpa%2Fassets%2F&state=7b6a9cb5-f715-4674-b674-a222fd7850a1&response_mode=fragment&response_type=code&scope=openid&nonce=a0301b05-ba5d-4ebd-94f3-84a261416538&code_challenge=bIH090-u76HEZm_2HH5U2OEgbEr98TqPd0CWCh4Daio&code_challenge_method=S256

and browser automatically adds SSO cookies to the request, as shown below

Keycloak, stored SSO cookies

and Keycloak during this request will authenticate the user by SSO cookies set previously during authentication on mfe-a.home.arpa.

If we check the sessions of the our user in Keycloak under admin, we will see that there are two sessions now, and this is the nutshell of cookie based SSO authentication.

Keycloak, authentication session for two OIDC clients

Having as many applications and corresponding OIDC clients as you need, we can configure just integrations and delegate the whole authentication to Keycloak.

Here’s how the previously mentioned authorization code flow looks now with SSO cookies authentication:

I think that’s all I’d like to share with you in this article. I hope it was interesting and feel free to ask any questions in the comments.

Leave a Reply