Over the last few months, the Figma security team has built out a system to securely provide access to internal applications using reusable cloud components and modern security technologies. We think this is a powerful pattern that can help teams across the industry protect their most sensitive internal apps. Here, Security Engineer Max Burkhardt is sharing a look at how we built the system, what we learned, and how this fits into our broader approach to security at Figma.
Figma, like most tech companies, relies on a suite of internally developed applications to make things work behind the scenes. From deploying our software to providing support functionality, the web tools we’ve developed are crucial to employee workflows. They also have stringent security requirements to ensure that only authorized employees can access them.
Attackers and cybercriminals have a history of going after administrative backends as a way to target user data. To protect our users and their data, we set out to build a system that could provide safe access to internal applications.
Our approach maximizes off-the-shelf components for easy maintenance and security network effects, but ties them together in novel ways to ensure that we have the best security practices in place. From the start, we had a few requirements:
In this post, we’ll start with the core technologies and central configurations that provided a solution, and then go into some cool extensions that we built to take advantage of the capabilities of the architecture:
There are a few key pieces of technology that we used in order to create a solution that met all of our requirements. Here is a quick refresher on some things we used to make this work.
At Figma, we use AWS for most of our cloud infrastructure, and we use Okta for our employee authentication and authorization. As a result, our solution focuses on using components provided by those platforms to achieve our goals. AWS Application Load Balancers (ALBs) support authentication of user traffic through a variety of means; often companies configure them with OpenID Connect (OIDC) authentication in situations like this. However, Okta charges extra for OIDC support, so we explored other options. You can also configure an ALB to authenticate users with plain SAML, as long as you pair it with an AWS Cognito user pool—this has some other advantages that we’ll get to later.
To automate the creation of ALB and Cognito resources with the right configuration, our team built Terraform modules in accordance with our infrastructure-as-code philosophy. These modules allow any infrastructure engineer to quickly get an ALB up and running that authenticates users through our Okta deployment. The specific details of the configuration here are too long for this post (drop me a line if you’re looking for some code samples!), but here’s the basic configuration:
email
and profile
attributes, so that these are passed through the SAML assertion into the Cognito identity statement.email
and profile
are specifically included here, again).Once these pieces are in place, users will automatically be redirected to Okta upon trying to visit the ALB. Assuming that they can successfully authenticate and have been assigned the app in Okta, their request will then be passed on to the internal application. Until their session expires, the user will not see any further authentication prompts.
This setup has a few key advantages:
terraform apply
)—for instance, they natively support HTTP desync mitigation, can be monitored with an AWS Web Application Firewall, and they’re patched automatically by AWS.Connecting internal applications to Okta apps via ALBs is a great start along the road to internal user authorization. Using Okta application assignments, we can control which employees can see each application. However, this is a coarse-grained mechanism. We have a number of applications that have broad use cases across the organization, but specific functionality that is best to keep more limited. Take the deployment app, for example: it’s desirable to let any engineer deploy their branch to their personal development environment; only the release manager should be able to deploy to production. Splitting this functionality across two different apps would be challenging from a management and UX perspective, so we built a mechanism to express more Okta authorization context to the underlying application.
This approach relies on the profile
attribute that was mentioned above. With the right combination of configurations, we can send information about which Okta Groups a user belongs to through their SAML assertion, into Cognito, along through the ALB, and finally into the underlying app in the form of a signed JSON Web Token (JWT) in an HTTP header. Here’s how that combination works:
authorization
that specifies a set of Okta groups that should be sent to the underlying app. This specification can be a prefix match or a regex, and allows us to only send groups which might be relevant to the underlying application.authorization
statement into the profile
attribute. Because this attribute is present in the configuration for the Cognito User Pool Client, it will get set when a user logs in through the ALB.profile
attribute will then contain the specified groups in the signed x-amzn-oidc-data
header when sent to the application backend. Amazon describes how to parse this header here, but note that the approach may need to be tweaked depending on the language you are using and how strict its JWT libraries are.If you’re not using Python and are therefore unable to use the sample code provided by Amazon, the following pseudocode describes the operations necessary to safely verify and extract the identity data provided in this JWT. Note that many JWT libraries will fail to validate these tokens because of this bug in Amazon JWT encoding, so you may need to decode and verify the token separately. This is a security-sensitive operation that is tricky to get right, so test your code thoroughly!
x-amzn-oidc-data
header on the .
character, and Base64-decode the first element.kid
value from this JSON object, as well as the region
value from the signer
key.kid
and region
values conform to their expected formats (kid
should be a UUID, and region
should be a valid AWS region).https://public-keys.auth.elb.${region}.amazonaws.com/${kid}
. You will likely want to cache this value.x-amzn-oidc-data
header), using whatever method your JWT library provides. If verification fails for any reason, throw an authentication error. Make sure that the alg
attribute of the JWT is an expected one, and not none
!verify
method from the jws
library in JavaScript to check the token, it will not perform expiry verification on your behalf; you will need to check the expiry of the token manually (via the exp
field) and ensure it is still valid. iss
value in the JWT header is equal to the expected URL of the Cognito User Pool associated with this application. This check ensures that an attacker is not presenting a validly signed JWT that is attached to an unrelated Cognito pool that might be from a different account.email
and profile
fields from the payload of the JWT.With this, applications can check a user’s Okta Groups using a helper function written by the Security team and determine what capabilities to grant a user—all while keeping the management of those permissions within Okta, our centralized employee identity provider. The signed header also includes the user’s email, allowing applications to have trustworthy logging about who is performing what actions.
We went a step further in facilitating this hand-off with some opinionated guidance on how groups should be structured for this use case. It’s tempting to put users in groups based on their team or department in Okta, and then check those in your application code to validate whether an action should be allowed based on a user’s job function. However, this binds application logic to organizational structure, which is likely to change. It also makes it hard to quickly understand what permissions a particular team has been granted. Fortunately, Okta has a feature that allows you to bridge this gap: Group Rules.
Group Rules allow you to assign a user to a group based on a predicate written in the Okta Expression Language. In our case, this means that we can create many groups—one for each permission—and then use rules to automatically assign those groups to users based on the team listed in their Okta profile. This way, applications can check for a specific group related to the permission, eg. #Deploys dev_environments
, instead of checking for a list of team names. This has the added advantage of making it easy to see what permissions a team has: just check the Group Rules in Okta where the team is mentioned, and you’ll see all of the group relationships that that implies.
To ensure that these associations are well-tracked and all changes are safely logged, we create them using the Okta Terraform Provider and check them into our repository along with the rest of our Terraform code.
Note: if an application needs extremely fine-grained permissions, it’s possible that the number of Okta groups sent in the JWT will make the token unreasonably large. If a particular application needs dozens or hundreds of individual permissions, it may be valuable to invest in an independent mapping system between user groups and capabilities.
Secure internal web applications are great, and as a company that builds design software, it’s fun to use Figma to design our own tools. However, some functionality really works best as a command line tool, and in certain cases, it’s the right approach. The Figma infrastructure team has built a wide variety of custom CLI (command line interface) tools that often need to make HTTP API calls to get things done. Soon after we set up the ALB-based authentication system described in this post, we wondered: how hard would it be to make API calls against our own internal services from a command line tool?
At first glance, it’s a challenge: the ALB-to-Okta authentication dance requires multiple redirects and complex browser functionality like WebAuthn. A CLI acting as a browser also wouldn’t be able to reuse the employee’s existing session. To get around these problems, we were inspired by the “browser pop” approach used by the AWS SSO CLI. By calling a library written by the Figma Security team, an internal CLI tool can trigger the user’s browser to open a page that will confirm the user’s intent to authenticate their terminal and forward the user’s ALB authentication cookie back to the CLI app. Because this is enabled by standard Terraform modules and client libraries, an engineer can enable CLI auth for their internal service with just three lines of code!
// In the Terraform configuration for our 'gateway_alb' module: allow_cli_auth = true // In a Typescript CLI tool: import { cliAuthenticate } from '../../share/cli-auth'; const authCookies = await cliAuthenticate('https://deploys.figma.com');
Here is how the magic happens:
open
command to open a special route for the requested service in the user’s default browser.127.0.0.1
, allowing the CLI to receive them and attach them to future API calls. This approach avoids the phishing risks present in some device code authorization flows.There are a few key security features to ensure a safe flow:
Thanks to this feature, our local CLI tools can do things like check deployment state or look up internal information with minimal code changes and a few clicks from the user.
The fact that we are using Cognito User Pools to gate access to our internal ALBs has another advantage: User Pools don’t have to be limited to just SAML users. We’ve run into a few cases where we’d like to expose an internal application to a trusted guest; for instance, when giving access to a private beta. Because our ALB authentication is backed by the full capabilities of Cognito, we can make a new user account that’s unassociated with our Okta environment in the User Pool. Then, we can pass the credentials over to the partner and let them log in without having to provision them a Figma employee account.
Enabling this is as simple as specifying the COGNITO
identity provider alongside your Okta one in the User Pool Client configuration for your ALB. As soon as you do this, the login page will switch to one that allows either Okta/SAML login or Cognito-based username & password login. Notably, this only works if a User Pool is very explicitly configured in this way—we leave this out of the defaults, so most internal apps will only support Okta logins.
Using the infrastructure components described above, we built a modern, highly secure application gateway product for internal use that doesn’t demand extensive, ongoing maintenance from the team—in fact, the security team doesn’t even have to operate any servers in this setup. Instead, they just act as code owners for the Terraform configurations and the CLI authentication Lambda & associated libraries.
This project has leveled up our infrastructure defenses and inspired a number of new projects for the coming months. The team is working to apply similar methodologies to all of our application and infrastructure defenses—from detecting attacks in our corporate network, to identifying authorization vulnerabilities automatically in our primary web application. As we continue to innovate and experiment in this arena, we’ll share what we can with the community.
We are a small team, passionate about modernizing security practices and contributing to the broader community. If that sounds exciting to you, join us!