Design a protected system
Authentication, sessions, cookies, OAuth 2.0, authorization code flow, and so on. This page explains how to make sense of the complexity.
The modern system
In the early days, we wrote a single application that did it all. The gorgeous monolith! It did everything: handled user requests, authenticated users, rendered UIs, queried data directly from the database, served files, managed user sessions… everything. This could have been an application built with Rails, Spring, Node.js, but that’s no longer a representation of a "modern system".
We now live in a world where "monolith" is a bad word. Everything has been split out into microservices, SPAs (single-page web app), PWAs (Progressive Web App), native mobile apps, with other functionality delegated to a FaaS, PaaS, or SaaS (Functions, Platform or Software as a Service).
This new design has given us a greater sense of organization and tooling to focus on solving the unique, novel problems independently of the common ones. Experts can now be responsible for their relative domains within their own repository or project. If a company does not employ an expert of a required domain, it can now "outsource" it to be managed by another company.
Unfortunately, this new paradigm comes with its own set of problems. Architecture diagrams now illustrate a complex web of distributed components that are simple in isolation, but hard to reason about when viewed holistically. Due to this distributed nature, the system now comes with more surface area to protect from unwanted access.
In a world where everything is a tap or click-of-the-finger away, it’s more important than ever to ensure the right fingers have access to the right data. Knowing the basics of a protected system is no longer optional. Developers, product managers, IT professionals, all need to have a good grasp of the fundamentals.
Let’s cover the basics to ensure we keep our data convenient but private and our users happy but safe.
What is a protected system?
In most modern, enterprise cases, "the system" will consist of a diverse collection of entities, but let’s start with the most simple use case (not quite a system, but bear with me): the monolith.
Single, "full-stack", server-side application
This single application was responsible for everything, including identity and access management. These were applications common around the turn of the century. Though these "systems" still exist, they are becoming much less common as they are very hard to manage and engineer at large scale.
To take some baby steps, let’s consider one step up from this monolith, and separate out access management from the monolith.
In this design, you have two entities:
-
Protected, full-stack, server-side application: An application managing the resources you want protected.
-
Access management application: An application managing all identity and access concerns.
The beauty of this system is how it scales. If you decide to add another protected application to the system, you just delegate the access related needs to the access management application. (There are other great benefits to this, but let’s save that for another article.) The new application introduced to the system could be a web app, mobile app, REST API service app, GraphQL app… anything that potentially serves up a protected resource.
In an effort to avoid having to rebuild such a vital function over and over with each new app, you "connect them" to your access management app. This dramatically reduces the surface area of risk in complex systems.
What’s more, this serves the users better. It means they log in once, and have access to everything their role or privileges allow. With me so far? Okay, let’s go a bit further.
What’s a common system design?
In modern system architectures, it’s quite common to split the full-stack application into a backend with multiple, client-side apps, often one per platform: iOS, Android, Web. In these situations, it’s advantageous to keep all data related concerns of our protected app within a central API server—often referred to as a "service". Each client app requests data via an API. This prevents business logic duplication across multiple applications and simplifies client-side development.
Let’s add these multiple client-side apps as a generic entity to our system from above. We now have three distinct entity types as our "protected app" has been split into two application types:
-
Protected client-side applications (Mobile and Web)
-
Protected server-side, resource API service
-
Access management application
The main access responsibility of the protected client apps and the API service is to distinguish authenticated users from unauthenticated ones. This ensures those without access get denied, and converted to users with access by directing them to the access management app.
Let’s break down the responsibility of each.
Client-side apps
The role of a protected client-side app is to not only distinguish between authenticated and unauthenticated users, but to assist in converting unauthenticated users into authenticated with as little friction as necessary.
An app will typically have both public and private portions. The simplest way to protect the private portion is by route, page or view. The protected routes will often have a reusable function that’s run before any response is given, often referred to as "middleware". This function checks if the user has access by sending the access artifact, like a session cookie, to the access management app for validation. If the validation succeeds, the app continues processing the request; if not, the app will redirect the user to the login page.
This can be something as simple as this:
// Using a common client-side, middleware-style pattern (session-based example)
async function isAuthenticated(context, next) {
const authResponse = await request(sessionValidateEndpoint);
if (authResponse.valid) {
next(); // continue with processing request
} else {
redirect(authenticationUrl); // send user to login
}
}
routes('accounts/balances', isAuthenticated, (context) => {
render(changePasswordForm);
});
It’s important to know that protected client-side apps are not truly secure, and should not have embedded within them protected resources, secrets or private keys. They are inherently vulnerable as the entire codebase is sent to the user agent—a device outside of your control—to be executed, so all code is subject to manipulation. |
Even though this client-side application cannot guarantee access protection, the implementation of such protection on the client increases user-experience and performance. It also reduces unnecessary requests to the underlying services.
Resource API services
The role of a protected resource API service is to be the final arbiter for protecting access to resources within the system. Since we can’t fully trust our client-side applications, our resource API will need to duplicate the same check for authentication.
It will use the authentication artifact sent from the client with every request to validate the access to the requested resource. If validation passes, process the request. If it fails, send a 401 error message, and let the client-side app appropriately handle the issue:
// Using a Node.js middleware-style pattern (session-based example)
async function isAuthenticated(req, res, next) {
const authResponse = await request(sessionValidateEndpoint);
if (authResponse.valid) {
next(); // continue with processing request
} else {
res.status(401).send(); // respond with 401 unauthorized
}
}
routes.get('accounts/balances', isAuthenticated, (req, res) => {
const balances = db.query('balances');
res.json(balances);
});
The above is route-level protection, which may not be granular enough for your system. Object-level protection, an increase in access control precision, may be required for your system but is outside the scope of this article. |
Access management application
The access management (generic) application has the most important role in a protected system. It manages users, login flows, sessions, authorization, password management, and so on, all of which are vital functions.
At the simplest level, here are the main responsibilities of the application:
-
Handles redirection from client-side apps for login flows, redirecting users back to the respective application upon completion.
-
Provides an API for session/artifact validation.
-
Provides an API for termination of session or artifact.
In situations where the above responsibilities exceed your level of comfort or skill set, it’s often a good idea to delegate these responsibilities to a platform service provider, like ForgeRock. Our services and products allow you to focus on the novel aspects of your application development, and delegate the complexities of identity management (users, things, devices, and more), and access management (what those identities can do) to us.
Let’s see how adopting ForgeRock for our access management changes our system.
Integrate ForgeRock into a protected system
ForgeRock provides a powerful, configurable Identity and Access Management solution out of the box. Whether it’s the Identity Cloud platform; self-hosted, cloud-ready container; or individual on-premise products, ForgeRock can provide a great solution for nearly any system. For simplicity, let’s go with the Identity Cloud solution for the rest of this article.
Identity Cloud comes with its own login flow, registration and self-service flows, as well as all the APIs needed for validation, refreshing, termination, authorization and more. This all-in-one solution works perfect for internal solutions or get-up-and-running quickly situations. But eventually, most companies want their user-facing flows to be fully customizable to suit their branding requirements.
If I’m redirecting my users to ForgeRock’s platform, how do I provide a fully branded experience?
In ForgeRock’s Identity Cloud, you can choose how much control you want over your UIs. You can use it as-is, "theme" the provided UIs, or build your own UI using the underlying APIs and our open-source SDKs.
Build a branded UX with Identity Cloud and the SDKs
A fully branded experience means moving the responsibility of rendering the user flows from Identity Cloud to an app that you will build. To facilitate this, we provide SDKs for native Android and iOS apps, and an SDK for JavaScript application development. This allows you to easily integrate the ForgeRock APIs into a new or existing app.
There are two choices for fully customizing the user experience:
-
Move the user flows into each protected app, providing a native UX.
-
Move the user flows into a single web app to centralize the login experience.
In both cases, our SDKs will help in developing these experiences. But, before we move on, it’s important to know that your overall system has a significant impact on what choice suits you best.
What’s your intended system design?
There are a few important points to consider when choosing how to protect your system:
-
How many client-side apps will need protecting?
-
Are all your apps and services served from a single domain?
-
Will there be any third-party apps or services that will need protection?
Let’s dive into each concern and how it impacts your system.
How many client-side apps need protecting?
Say you have one app for each major platform: a web app, an iOS app, and an Android app. If the number of apps will not increase, you may want to develop the user flows for login, registration, and so on, within each app. This ensures that each app has full control over the best user experience for that platform.
By using our SDKs, you can more efficiently develop a dynamically responsive UI, handling each step within an authentication journey. This just slightly changes our client-side app’s responsibilities.
Rather than redirecting unauthenticated users away from our application, we now just internally route the user to our native login flows. But, we will still continue to validate the user’s session upon each navigation of our app.
But, we have dozens/hundreds of client-side applications! We don’t have the resources to update all of them.
Now, if you have many apps, and each app needs to have within it a login (not to mention registration) flow, that’s a lot of duplication. This will inevitably become a maintainability challenge, and a security liability as it increases your attack surface. Within this context, we need to go one step further.
To deal with this challenge, it’s often recommended to extract the login (and possibly registration, self-service) related responsibilities out of the client-side apps, and build a single web app exclusively around this functionality. All front-end applications (mobile and web) can now redirect to this one, central application. This reduces your surface area for security liabilities as well as reduces duplication across your system.
Let’s take a look at the system now:
-
Protected client-side apps (mobile & web)
-
Protected resource API services
-
Authentication (login, registration & self-service) web app
-
ForgeRock Identity Cloud
With this design, we are now starting to organize the system components by scope of responsibility. For mobile applications, they’ll have the availability of using the browser to authenticate, being redirected back to the native app when complete. Web apps will do a full redirect to the authentication app and a redirect back when done. Single sign-on functionality is provided out-of-the-box, as the browser is the shared platform for authentication between all apps, native or otherwise, on the user’s device.
This provides a more scalable system that’s optimized with apps having a more focused set of responsibilities while still providing full control over your brand and UX. Now that we have the core system design out of the way, let’s discuss how all of these components will be hosted.
How are you hosting all these applications?
Simply put, are all the applications in the above system on the same host? For example:
-
mydomain.com/auth
-
mydomain.com/app
-
mydomain.com/api
Another example would be the use of unique subdomains all on the same parent domain:
-
auth.mydomain.com
-
app.mydomain.com
-
api.mydomain.com
If using either of the two patterns, a session-based system may work well for you. Sessions are frequently based on browser cookies, which are fundamentally restricted by the host or parent domain.
On the other hand, you may be using different hosts across your apps:
-
auth-server.com
-
web-app.com
-
rest-server.com
This will constrain your options as session-based auth (driven by cookies) will be a challenge with apps on multiple hosts. An OAuth-based system is well-designed for this particular environment as it uses access tokens as the artifact passed around in the system, rather than a cookie.
But, before we dive into OAuth 2.0, let’s discuss one more aspect of our system.
Any third-party companies involved?
Do you intend to extend access of your protected system to any third-party companies? For example, you may want to allow an application or service from an external company to interact with your protected system. For this, you likely want to restrict the scope of capabilities for these external entities, making an OAuth-based system a better choice.
What’s OAuth and why is it better than session-based access with diverse hosting environments and third-party entities? Let’s differentiate these two models.
Let’s talk about access models (session v. OAuth)
To keep things simple, let’s focus on two of the most common models of access: session-based and OAuth-based. Your system design, discussed above, should strongly influence the type of access model you want to implement, but it’s not the only factor in making the choice.
Additional factors that can influence your access modeling are a bit more advanced and out of scope for this article, but they include:
-
Transaction authorization (aka policy enforcement)
-
Finer control over expiry times and access lifetimes
-
Finer control over scope of access or privileges
Look out for more information about these factors in a future article. For the rest of this article, let’s talk a bit more about the basics of two foundational access models.
Session-based (cookies) access
The session-based model traditionally uses the HTTP cookie as its artifact. It’s one of the oldest models for the web as the cookie was invented around the mid 1990’s (though not originally for authentication). The HTTP cookie is a relatively simple way to persist data (a simple string of text) within a Web browser. This small piece of information is stored natively in the browser, and is tightly bound to the domain of the HTTP request the browser made to the server.
Let’s use a simple example:
There’s a web app running on https://dashboard.example.com
,
and an access management application running on https://auth.example.com
.
After making a request to the access management app to login, a "session cookie" gets added to the browser.
This cookie is written because the server sent back a Set-Cookie
header,
so the cookie gets written to the full domain of the server, auth.example.com
, or the parent domain, example.com
.
Example of browser cookie storage:
-------------------- -------------------- --------------------
COOKIE NAME VALUE DOMAIN
session_id AJi4QfFBCMzK3QFm... .example.com
-------------------- -------------------- --------------------
Now that we have this cookie, all requests from that browser to example.com
(even subdomains that share the same parent) will contain a cookie
header with its value.
It’s worth noting that this "Just Works" as it’s a seamless, almost invisible, mechanism of the browser.
Example of request with cookie:
GET https://auth.example.com/sessions/validate
HEADERS
content-type: application/json
cookie: session_id=AJi4QfFBCMzK3Qc...s9dg7f6hyGHD
origin: https://dashboard.example.com
This means you can have multiple apps running on multiple subdomains. As long the same parent or root domain is used, this session cookie will be sent automatically.
For example:
-
www.example.com
-
accounts.example.com
-
profile.example.com
-
tasks.example.com
With servers running on:
-
auth.example.com
-
data.example.com
As long as all apps, both client and server, are running on the same parent domain (example.com
),
you can configure cookies to work with this setup.
In this case, we would configure the cookie to be written to the parent domain,
example.com
for the highest amount of flexibility.
Your applications can then have their own subdomains and will still receive the cookie
(many browsers store this as .example.com
).
The downside to this model is the tight coupling of cookies with their respective domains. |
If you have apps running on different domains, say auth.example.com
and data.userbase.com
,
this model unfortunately does not work.
The cookies written for auth.example.com
would not be sent to our data.userbase.com
server.
In this case, OAuth provides better support.
Third-party cookies: it’s worth noting that there’s still a nuance with cookies being written when browser-based apps (SPAs) are running on a different domain than the servers. These cookies are considered "Third-Party Cookies", and have been an important function of how the Web worked for years. Unfortunately, most browsers will disable this functionality within the next few years, so relying on it will be risky. |
OAuth 2.0-based access
OAuth is an industry standard for handling authorization and has been around since the late 2000’s. OAuth 2.0 is the most recent specification of the protocol and is a large rework from the original. In this writing, any reference to OAuth will always refer to the 2.0 specification.
OAuth is a complex specification and has many variations and nuances. The details of which are beyond the scope of this article, so we will focus only on the basics.
The core artifact of OAuth is the access token, and like the value stored in a cookie for sessions, it is frequently just a simple string of text (sometimes called a JWT). But, unlike the cookie, the browser does not have a native concept of an access token, so obtaining and managing an Access Token doesn’t automatically happen within a browser.
There are other tokens frequently mentioned in texts about OAuth that are beyond the scope of this article, like refresh tokens and ID tokens. These tokens will not be covered in order to keep this article more introductory. |
There are some choices about how to store and send the access tokens within a system.
For the Web, as a simple example, sessionStorage
or localStorage
can often be used to store the token.
Access Tokens are also not automatically sent along with all HTTP requests,
so how one writes this token to HTTP requests is also something to be considered.
Luckily, the industry has already standardized around best practices.
So, why is OAuth 2.0 better than session-based cookies in certain circumstances?
OAuth is often mentioned in situations where you have third-party applications and services,
or a multi-host setup with varying domains.
This is because of its granularity of permissions (for security/privacy) and complete decoupling from domains.
This provides more control over how it behaves.
At the end of the day, an access token is just an opaque string that’s passed around the system,
frequently called a "bearer token", and written to the Authorization
header of requests.
Example of request with authorization header:
GET https://rest.resource.com/activity
HEADERS
content-type: application/json
authorization: Bearer 3QcIFmU6r0q43U...LJKf807
origin: https://dashboard.example.com
Using OAuth doesn’t dramatically change your system design. The basic principles of how it’s used doesn’t significantly diverge from the session-based model. You are still obtaining an access artifact from a server, passing it to APIs, and validating it where necessary. The additional responsibilities with Access Tokens are storing it and removing it as needed.
For example, here are some minor changes to the middleware example from above:
// Using Node.js middleware-style pattern (oauth-based example)
async function isAuthorized(req, res, next) {
const authResponse = await request(oauthIntrospectionEndpoint);
if (authResponse.access) {
next(); // continue with processing request
} else {
res.redirect(authorizationUrl); // send to authorization
}
}
routes.get('accounts/balances', isAuthorized, (req, res) => {
res.render(changePasswordForm);
});
Validating access tokens can also be done without a network request. We refer to these as "stateless" tokens. They can be introspected with a JWT decoding library for validation. |
The only remaining difference between the OAuth and session-based model is the fact that an OAuth token has to be specially obtained from your access management application. The most common flow for attaining an access token is called the authorization code flow, and involves an additional interaction with the server after the user successfully authenticates.
The good thing is you do not have to reinvent the wheel to implement OAuth within your applications. Identity Cloud and SDKs abstract away the need for requesting, storing, sharing, and revoking the access token, leaving you with more time to build the novel aspects of your applications.
What’s the best design to protect my system?
The answer is… well, it depends. As discussed above, there are quite a few important aspects to the kind of system we are discussing and the future plans for your products. Hopefully, after reading through the basics articulated above, you have a better, foundational understanding of what it means to design a protected system.
If things are still a bit fuzzy, don’t worry. The good news is that ForgeRock can help by providing the best tools and guidance to ensure you have the right information to make the best choice for you.