What is OpenID Connect Authentication? A Practical Guide - Developer Friendly Blog
Skip to content

What is OpenID Connect Authentication? A Practical Guide

OpenID Connect is the modern-day solution to an ever-lasting problem: to authenticate users when the identity provider and the service provider are different entities.

Introduction

AWS (and other providers) highly recommend against storing long-lived credentials outside your AWS account1.

The rationale is that those secrets can be compromised and put your account at risk. Even on non-extreme cases, you are advised to rotate your credentials and having to update every credential can be cumbersome when working at scale.

That's where OpenID Connect (OIDC) comes in.

What is OpenID Connect?

Let's provide a formal definition first2:

OpenID Connect is an interoperable authentication protocol based on the OAuth 2.0 framework of specifications (IETF RFC 6749 and 6750). It simplifies the way to verify the identity of users based on the authentication performed by an Authorization Server and to obtain user profile information in an interoperable and REST-like manner.

Another great definition is provided by the Mozilla3 (corrections and typo highlighted):

OpenID Connect (OIDC) is a protocol that allows web applications (also called relying parties, or RP) to authenticate users with an external server called the OpenID Connect Provider (OP). This server typically gets user information from an identity provider (IdP), which is a database of user credentials and attribute information.

The communication with the OpenID Connect Provider (OP) is done using tokens. An ID token is provided to the web application (RP) by the OpenID Connect Provider (OP) once the user has authenticated. It contains a JSON document which informs the web application (RP) about how, and when the user has authenticated, various attributes, and for how long the user session can be trusted. This token can be re-newed as often as necessary by the web application (RP) to ensure that the user and its attributes are both valid and up to date.

Best of them all, is the definition in the abstract of the RFC itself4: (1)

  1. If you've never read any RFC before, I highly recommend starting with the RFC 6749. Reading RFCs is a great way to understand the protocols and a great eye-opening experience. All those tools and technologies you take for granted everyday take their specifications from these documents and it's great to know the inner workings of them.

The OAuth 2.0 authorization framework enables a third-party application to obtain limited access to an HTTP service, either on behalf of a resource owner by orchestrating an approval interaction between the resource owner and the HTTP service, or by allowing the third-party application to obtain access on its own behalf.

Now, at least the first definition is too formal for my non-native English speaker brain. Let's simplify it a bit to understand what it means.

Simplified Explanation With an Example

In this guide, we will cover a practical example. Let's break down the requirements of this scenario:

  • The requirement for this scenario is to allow GitHub Actions' job(s) running in a specific repository to access AWS Parameter Store (1) securely. This may be the repository of your infrastructure or your applications trying to read the secure secrets from AWS Parameter Store and passing them to the runner.

    1. Parameter Store, a capability of AWS Systems Manager, provides secure, hierarchical storage for configuration data management and secrets management. You can store data such as passwords, database strings, Amazon Machine Image (AMI) IDs, and license codes as parameter values. You can store values as plain text or encrypted data. You can reference Systems Manager parameters in your scripts, commands, SSM documents, and configuration and automation workflows by using the unique name that you specified when you created the parameter.5

  • The hard requirement for our scenario is to avoid storing any credentials in GitHub Secrets6 and use OIDC mechanism to authenticate the runner jobs to the AWS services instead.

The following image is an over simplified diagram of the scenario we are trying to achieve:

GitHub Actions OIDC
GitHub Actions runner talking to AWS Parameter Store

Let's break down what's happening in the diagram above:

  1. The CI runner authenticates itself to GitHub Actions identity server and fetches an access token.
  2. The runner will use the access token for the next step to talk to AWS API.
  3. The AWS IAM will verify the access token to make sure it has been issued by the trusted identity provider. If so, the request will be granted access to the AWS SSM.

With that in mind, let's simplify the definitions above with our example.

  • The client (1) is the entity trying to access a service, e.g., a runner job in GitHub Actions trying to access the services of AWS.

    1. Do not mistake user and client here. However, they may be used interchangeably in some contexts. The client can be a user, a service, or a machine trying to access the service.

      The user is considered the resource owner, the human being who owns the data, service, or account.

  • The service provider is the service the user is trying to access, e.g., AWS Parameter Store.

  • The OpenID Connect provider and identity provider is the service that authenticates the user, e.g., GitHub Actions (1). The OIDC provider can be a separate entity from the Identity Provider, but in our case, it is the same.

    1. GitHub Actions plays the role of both identity provider and OIDC provider in our scenario.

OIDC Compatibility

A hard requirement on implementing and adopting OIDC as an authentication mechanism is that both the service provider and the identity provider must support OIDC protocol. In other words, they should be OIDC compatible and implement the corresponding RFCs7.

SAML vs. OIDC

SAML (Security Assertion Markup Language) is another protocol that is used for authentication. The main difference between SAML and OIDC is that SAML was initially designed for single sign-on (SSO) and is XML-based, whereas OIDC is JSON-based and is more modern and flexible with more focus on authentication for modern [mobile] applications 8.

OIDC is built on top of OAuth2 and is more modern and flexible than SAML.

On a personal note, I would rather the JSON-based OIDC than the XML-based SAML. But, since that protocol is usually the reponsibility of my upstream services, I rarely care how they talk to each other and just use the proper tool to address the problem at hand.

When the runner job tries to access AWS Parameter Store, it needs to authenticate itself to GitHub Actions. After the authentication, it will have an access token, using which it can access AWS Parameter Store that is protected behind AWS IAM.

Presenting that access token to AWS IAM, and the verification of that token is the crux of the OIDC mechanism.

In essence, the IAM will verify the access token before allowing the runner job to access the AWS Parameter Store. The verfication of the access token happens behind the scenes with the public key provided at the /.well-known/openid-configuration endpoint of the identity provider.

To elaborate further, the AWS IAM will fetch the public key from the URL in the GitHub Action's /.well-known/openid-configuration JWK endpoint and using that public key, verifies the signature of the access token. If the signature is valid, the IAM will allow the runner job to access the AWS Parameter Store.

If we try it locally, we will get the following output:

curl -s \
  https://token.actions.githubusercontent.com/.well-known/openid-configuration \
  | tee github-actions-oidc-endpoint.json
github-actions-oidc-endpoint.json
{
  "claims_supported": [
    "sub",
    "aud",
    "exp",
    "iat",
    "iss",
    "jti",
    "nbf",
    "ref",
    "sha",
    "repository",
    "repository_id",
    "repository_owner",
    "repository_owner_id",
    "enterprise",
    "enterprise_id",
    "run_id",
    "run_number",
    "run_attempt",
    "actor",
    "actor_id",
    "workflow",
    "workflow_ref",
    "workflow_sha",
    "head_ref",
    "base_ref",
    "event_name",
    "ref_type",
    "ref_protected",
    "environment",
    "environment_node_id",
    "job_workflow_ref",
    "job_workflow_sha",
    "repository_visibility",
    "runner_environment",
    "issuer_scope"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "issuer": "https://token.actions.githubusercontent.com",
  "jwks_uri": "https://token.actions.githubusercontent.com/.well-known/jwks",
  "response_types_supported": [
    "id_token"
  ],
  "scopes_supported": [
    "openid"
  ],
  "subject_types_supported": [
    "public",
    "pairwise"
  ]
}
curl -s \
  https://token.actions.githubusercontent.com/.well-known/jwks \
  | tee github-actions-oidc-jwks.json
github-actions-oidc-jwks.json
{
  "keys": [
    {
      "alg": "RS256",
      "e": "AQAB",
      "kid": "cc413527-173f-5a05-976e-9c52b1d7b431",
      "kty": "RSA",
      "n": "w4M936N3ZxNaEblcUoBm-xu0-V9JxNx5S7TmF0M3SBK-2bmDyAeDdeIOTcIVZHG-ZX9N9W0u1yWafgWewHrsz66BkxXq3bscvQUTAw7W3s6TEeYY7o9shPkFfOiU3x_KYgOo06SpiFdymwJflRs9cnbaU88i5fZJmUepUHVllP2tpPWTi-7UA3AdP3cdcCs5bnFfTRKzH2W0xqKsY_jIG95aQJRBDpbiesefjuyxcQnOv88j9tCKWzHpJzRKYjAUM6OPgN4HYnaSWrPJj1v41eEkFM1kORuj-GSH2qMVD02VklcqaerhQHIqM-RjeHsN7G05YtwYzomE5G-fZuwgvQ",
      "use": "sig"
    },
    {
      "alg": "RS256",
      "e": "AQAB",
      "kid": "38826b17-6a30-5f9b-b169-8beb8202f723",
      "kty": "RSA",
      "n": "5Manmy-zwsk3wEftXNdKFZec4rSWENW4jTGevlvAcU9z3bgLBogQVvqYLtu9baVm2B3rfe5onadobq8po5UakJ0YsTiiEfXWdST7YI2Sdkvv-hOYMcZKYZ4dFvuSO1vQ2DgEkw_OZNiYI1S518MWEcNxnPU5u67zkawAGsLlmXNbOylgVfBRJrG8gj6scr-sBs4LaCa3kg5IuaCHe1pB-nSYHovGV_z0egE83C098FfwO1dNZBWeo4Obhb5Z-ZYFLJcZfngMY0zJnCVNmpHQWOgxfGikh3cwi4MYrFrbB4NTlxbrQ3bL-rGKR5X318veyDlo8Dyz2KWMobT4wB9U1Q",
      "use": "sig",
      "x5c": [
        "MIIDKzCCAhOgAwIBAgIUDnwm6eRIqGFA3o/P1oBrChvx/nowDQYJKoZIhvcNAQELBQAwJTEjMCEGA1UEAwwaYWN0aW9ucy5zZWxmLXNpZ25lZC5naXRodWIwHhcNMjQwMTIzMTUyNTM2WhcNMzQwMTIwMTUyNTM2WjAlMSMwIQYDVQQDDBphY3Rpb25zLnNlbGYtc2lnbmVkLmdpdGh1YjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOTGp5svs8LJN8BH7VzXShWXnOK0lhDVuI0xnr5bwHFPc924CwaIEFb6mC7bvW2lZtgd633uaJ2naG6vKaOVGpCdGLE4ohH11nUk+2CNknZL7/oTmDHGSmGeHRb7kjtb0Ng4BJMPzmTYmCNUudfDFhHDcZz1Obuu85GsABrC5ZlzWzspYFXwUSaxvII+rHK/rAbOC2gmt5IOSLmgh3taQfp0mB6Lxlf89HoBPNwtPfBX8DtXTWQVnqODm4W+WfmWBSyXGX54DGNMyZwlTZqR0FjoMXxopId3MIuDGKxa2weDU5cW60N2y/qxikeV99fL3sg5aPA8s9iljKG0+MAfVNUCAwEAAaNTMFEwHQYDVR0OBBYEFIPALo5VanJ6E1B9eLQgGO+uGV65MB8GA1UdIwQYMBaAFIPALo5VanJ6E1B9eLQgGO+uGV65MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAGS0hZE+DqKIRi49Z2KDOMOaSZnAYgqq6ws9HJHT09MXWlMHB8E/apvy2ZuFrcSu14ZLweJid+PrrooXEXEO6azEakzCjeUb9G1QwlzP4CkTcMGCw1Snh3jWZIuKaw21f7mp2rQ+YNltgHVDKY2s8AD273E8musEsWxJl80/MNvMie8Hfh4n4/Xl2r6t1YPmUJMoXAXdTBb0hkPy1fUu3r2T+1oi7Rw6kuVDfAZjaHupNHzJeDOg2KxUoK/GF2/M2qpVrd19Pv/JXNkQXRE4DFbErMmA7tXpp1tkXJRPhFui/Pv5H9cPgObEf9x6W4KnCXzT3ReeeRDKF8SqGTPELsc="
      ],
      "x5t": "ykNaY4qM_ta4k2TgZOCEYLkcYlA"
    },
    {
      "alg": "RS256",
      "e": "AQAB",
      "kid": "1F2AB83404C08EC9EA0BB99DAED02186B091DBF4",
      "kty": "RSA",
      "n": "u8zSYn5JR_O5yywSeOhmWWd7OMoLblh4iGTeIhTOVon-5e54RK30YQDeUCjpb9u3vdHTO7XS7i6EzkwLbsUOir27uhqoFGGWXSAZrPocOobSFoLC5l0NvSKRqVtpoADOHcAh59vLbr8dz3xtEEGx_qlLTzfFfWiCIYWiy15C2oo1eNPxzQfOvdu7Yet6Of4musV0Es5_mNETpeHOVEri8PWfxzw485UHIj3socl4Lk_I3iDyHfgpT49tIJYhHE5NImLNdwMha1cBCIbJMy1dJCfdoK827Hi9qKyBmftNQPhezGVRsOjsf2BfUGzGP5pCGrFBjEOcLhj_3j-TJebgvQ",
      "use": "sig",
      "x5c": [
        "MIIDrDCCApSgAwIBAgIQAP4blP36Q3WmMOhWf0RBMzANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAyNDE0NTI1NVoXDTI1MTAyNDE1MDI1NVowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALvM0mJ+SUfzucssEnjoZllnezjKC25YeIhk3iIUzlaJ/uXueESt9GEA3lAo6W/bt73R0zu10u4uhM5MC27FDoq9u7oaqBRhll0gGaz6HDqG0haCwuZdDb0ikalbaaAAzh3AIefby26/Hc98bRBBsf6pS083xX1ogiGFosteQtqKNXjT8c0Hzr3bu2Hrejn+JrrFdBLOf5jRE6XhzlRK4vD1n8c8OPOVByI97KHJeC5PyN4g8h34KU+PbSCWIRxOTSJizXcDIWtXAQiGyTMtXSQn3aCvNux4vaisgZn7TUD4XsxlUbDo7H9gX1Bsxj+aQhqxQYxDnC4Y/94/kyXm4L0CAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBSmWMP5CXuaSzoLKwcLXYZnoeCJmDAdBgNVHQ4EFgQUpljD+Ql7mks6CysHC12GZ6HgiZgwDQYJKoZIhvcNAQELBQADggEBAINwybFwYpXJkvauL5QbtrykIDYeP8oFdVIeVY8YI9MGfx7OwWDsNBVXv2B62zAZ49hK5G87++NmFI/FHnGOCISDYoJkRSCy2Nbeyr7Nx2VykWzUQqHLZfvr5KqW4Gj1OFHUqTl8lP3FWDd/P+lil3JobaSiICQshgF0GnX2a8ji8mfXpJSP20gzrLw84brmtmheAvJ9X/sLbM/RBkkT6g4NV2QbTMqo6k601qBNQBsH+lTDDWPCkRoAlW6a0z9bWIhGHWJ2lcR70zagcxIVl5/Fq35770/aMGroSrIx3JayOEqsvgIthYBKHzpT2VFwUz1VpBpNVJg9/u6jCwLY7QA="
      ],
      "x5t": "Hyq4NATAjsnqC7mdrtAhhrCR2_Q"
    },
    {
      "alg": "RS256",
      "e": "AQAB",
      "kid": "001DDCD014A848E8824577B3E4F3AEDB3BCF5FFD",
      "kty": "RSA",
      "n": "sI_r4iOwvRxksSovyZN8da5u-dh07fdcqh7FjyKKZCOVr7da898xk0TG9eZ7lfA1CmBTH4sX5evg4Yg2xdFDxYK4xmLZcwMyQZIDiZcdIujnttaqplrMv_v-YyAapHFmudbBO8NVuOH3gmGaJ02G8u1Vdf8C3PdNK13ch4wpNvyoxwqaIWGPSzudA6mGPGovRLhu5dEOOJSJtsLzExNvNmHnhPJZk06r7FePkBWSQ1CCHXAzpB-aUWEZC1FKMSiq2dvfOCyiJttEdyj8O_5yqb0wLAPb-8NdzkppbRal2WGowoU-AejqoWImhfDzlOBQStnhuAluKpA6sH0ifKlQsQ",
      "use": "sig",
      "x5c": [
        "MIIDrDCCApSgAwIBAgIQKiyRrA01T5qtxdzvZ/ErzjANBgkqhkiG9w0BAQsFADA2MTQwMgYDVQQDEyt2c3RzLXZzdHNnaHJ0LWdoLXZzby1vYXV0aC52aXN1YWxzdHVkaW8uY29tMB4XDTIzMTAxODE1MDExOFoXDTI1MTAxODE1MTExOFowNjE0MDIGA1UEAxMrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALCP6+IjsL0cZLEqL8mTfHWubvnYdO33XKoexY8iimQjla+3WvPfMZNExvXme5XwNQpgUx+LF+Xr4OGINsXRQ8WCuMZi2XMDMkGSA4mXHSLo57bWqqZazL/7/mMgGqRxZrnWwTvDVbjh94JhmidNhvLtVXX/Atz3TStd3IeMKTb8qMcKmiFhj0s7nQOphjxqL0S4buXRDjiUibbC8xMTbzZh54TyWZNOq+xXj5AVkkNQgh1wM6QfmlFhGQtRSjEoqtnb3zgsoibbRHco/Dv+cqm9MCwD2/vDXc5KaW0WpdlhqMKFPgHo6qFiJoXw85TgUErZ4bgJbiqQOrB9InypULECAwEAAaOBtTCBsjAOBgNVHQ8BAf8EBAMCBaAwCQYDVR0TBAIwADAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwNgYDVR0RBC8wLYIrdnN0cy12c3RzZ2hydC1naC12c28tb2F1dGgudmlzdWFsc3R1ZGlvLmNvbTAfBgNVHSMEGDAWgBQ45rBfvl4JJ7vg3WgLjQTfhDihvzAdBgNVHQ4EFgQUOOawX75eCSe74N1oC40E34Q4ob8wDQYJKoZIhvcNAQELBQADggEBABdN6HPheRdzwvJgi4xGHnf9pvlUC8981kAtgHnPT0VEYXh/dCMnKJSvCDJADpdmkuKxLxAfACeZR2CUHkQ0eO1ek/ihLvPqywDhLENq6Lvzu3qlhvUPBkGYjydpLtXQ1bBXUQ1FzT5/L1U19P2rJso9mC4ltu2OHJ9NLCKG0zffBItAJqhAiXtKbCUg4c9RbQxi9T2/xr9R72di4Qygfnmr3QleAqmjRG918cm5/uJ0s5EaK3QI7GQy7+tc44o3H3AI5eFtrHwIV0zoY4A9YIsaRmMHq9soHFBEO1HDKKRUOl/4tjpx8zHpp5Clz0wiZMgvSIdBa3/fTeUJ3flUYMo="
      ],
      "x5t": "AB3c0BSoSOiCRXez5POu2zvPX_0"
    }
  ]
}

This process can numb your brain if you're new to OIDC. But the idea is straightforward: some other services keep the username-password (GitHub Actions) and will generate access token for it to authenticate to other services (AWS IAM).

If you're interested in how the OIDC access token verification works, I recommend giving the RFC 6749 & 6750 a read. I also recommend this blog post that provided a practical example as well.

Identity Provider vs. OpenID Connect Provider vs. Service Provider 🤓

The terms Identity Provider and OpenID Connect Provider are used interchangeably in our blog post. The identity provider is the service holding the credentials and authenticating the client. The OpenID Connect Provider is the service that implements the OIDC protocol and provides the client with the access token.

That access token, in turn, will be sent to the service provider to access the services.

GitHub OIDC, in our case, acts as both the identity provider and the OIDC provider. AWS SSM, however, acts as the service provider. AWS IAM will verify the access token issued by GitHub OIDC before granting access to the AWS SSM.

Why use OpenID Connect?

Among countless obvious and non-obvious reasons, here are a few, and by no means exhaustive:

  • You can use one identity provider for all your services and not creating multiple accounts for each service in every environment.
  • You never have to store long-lived credentials and take the overhead of rotating them (1).

    1. Have you seen the following before? It works, but it's wrong. Don't do it!

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-2
      
  • You can have a centralized place to manage your users and their permissions. This works better at scale.

OpenID Connect in Practice

Based on the foundations we laid above, let's see how we can use OpenID Connect in a practical scenario. This gives a good chance to fully understand the concepts we discussed so far.

In the rest of this guide, we will implement the scenario we have specified above and try to grant GitHub Actions runners access to AWS Parameter Store without passing any access-key and secret-key to the runner jobs.

OIDC Provider

To start with, you need to create an OIDC identity provider in your AWS account9.

Directory Structure
.
├── main.tf
└── versions.tf
versions.tf
terraform {
  required_version = "< 2"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.44"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
}
main.tf
data "tls_certificate" "this" {
  url = "https://token.actions.githubusercontent.com"
}

resource "aws_iam_openid_connect_provider" "this" {
  url = "https://token.actions.githubusercontent.com"

  # also known as the "audience"
  client_id_list = [
    "sts.amazonaws.com",
  ]

  thumbprint_list = [
    data.tls_certificate.this.certificates.0.sha1_fingerprint,
  ]
}

Applying this TF code is quite simple:

export AWS_PROFILE="PLACEHOLDER"
tofu plan -out tfplan
tofu apply tfplan

IAM Role

We have created the Identity Provider in AWS. That is the service that will authenticate the runner jobs and issues the access token to them.

However, within the AWS IAM, we need to authorize the identities of the aforementioned identity provider to access the AWS SSM. That is done using the AWS IAM role and IAM policy.

What is AWS IAM Role?

IAM Role is a really powerful concept in identity and access management within AWS. It allows you to delegate access to specific roles and restrict the access of who can assume that role, that is, the trusted entity/entities.

This will allow for a more granular access control and better security posture.

The credentials of IAM role are temporary and rotated automatically by AWS. In essence, the risk of having long-lived credentials is mitigated and you can still access the AWS services securely.

AWS recommends using IAM roles over long-lived credentials whenever possible10.

To create the IAM role, we use the following TF code:

Directory Structure
.
├── iam.tf
├── main.tf
├── variables.tf
└── versions.tf
variables.tf
variable "github_owner" {
  description = "The account username or organization name"
  default     = "developer-friendly"
}

variable "github_repository" {
  description = "The repository name"
  default     = "oidc-github-aws"
}
iam.tf
locals {
  repository_name   = "${var.github_owner}/${var.github_repository}"
  repository_branch = "refs/heads/main"
}

data "aws_iam_policy_document" "this" {
  statement {
    actions = [
      "sts:AssumeRoleWithWebIdentity",
    ]
    effect = "Allow"
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.this.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "${aws_iam_openid_connect_provider.this.url}:aud"
      values   = ["sts.amazonaws.com"]
    }

    condition {
      test     = "StringEquals"
      variable = "${aws_iam_openid_connect_provider.this.url}:sub"
      values   = ["repo:${local.repository_name}:ref:${local.repository_branch}"]
    }
  }
}

resource "aws_iam_role" "this" {
  name               = "github-actions-oidc-role"
  assume_role_policy = data.aws_iam_policy_document.this.json
  managed_policy_arns = [
    "arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess",
  ]
}

Applying this code is as before. But let's explain the highlights of this TF code.

  1. The audience of the IAM role ensures that the runner job's token is only valid if talking to the sts.amazonaws.com service. There are other services within AWS and restricting it will enhance the security posture.
  2. The conditional for subject on line 24 ensures that only the runner jobs in the specified repository are allowed to assume the role and none other.
  3. The attached managed policy (line 34) is tailored to our scenario. Your requiments may vary; you can also attach custom policies to the IAM role.

The final IAM role will have a trusted policy similar to this9:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456123456:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:octo-org/octo-repo:*"
        },
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        }
      }
    }
  ]
}

Before we move on to the GitHub side, let's also create a sample secret in the AWS Parameter Store so that we can later fetch that value for testing.

We would also create the necessary GitHub Variables11 to be used inside the CI workflow definition later on.

Directory Structure
.
├── github.tf
├── iam.tf
├── main.tf
├── providers.tf
├── ssm.tf
├── variables.tf
└── versions.tf
versions.tf
terraform {
  required_version = "< 2"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.45"
    }
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
    github = {
      source  = "integrations/github"
      version = "~> 6.2"
    }
  }
}
variables.tf
variable "github_owner" {
  description = "The account username or organization name"
  default     = "developer-friendly"
}

variable "github_repository" {
  description = "The repository name"
  default     = "oidc-github-aws"
}

variable "ssm_demo_parameter" {
  description = "The SSM parameter name"
  default     = "/some/parameter/in/aws/ssm"
}
providers.tf
provider "github" {
  owner = var.github_owner
}
ssm.tf
resource "aws_ssm_parameter" "this" {
  name  = var.ssm_demo_parameter
  type  = "String"
  value = "This is not a secret, nor secure!"
}
github.tf
data "aws_region" "current" {}
data "aws_caller_identity" "this" {}

resource "github_actions_variable" "this" {
  for_each = {
    AWS_REGION         = data.aws_region.current.name
    AWS_ROLE_ARN       = aws_iam_role.this.arn
    SSM_DEMO_PARAMETER = var.ssm_demo_parameter
  }

  repository    = var.github_repository
  variable_name = each.key
  value         = each.value
}

resource "github_actions_secret" "this" {
  repository      = var.github_repository
  secret_name     = "AWS_ACCOUNT_ID"
  plaintext_value = data.aws_caller_identity.this.account_id
}

Applying the above TF files will require you to have the GitHub CLI installed and authenticated12.

If you don't want to install the extra binary on your system, you can also pass a GitHub Personal Access Token (PAT) as specified in the docs13.

GitHub Actions Workflow

We have prepared everything from AWS & GitHub side. Now, it's time to trigger a workflow in the said repository and test if it is able to read the secrets from AWS Parameter Store.

.github/workflows/ci.yml
name: ci

on:
  push:
    branches:
      - main

env:
  AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }}

jobs:
  demo:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-region: ${{ vars.AWS_REGION }}
          role-to-assume: ${{ vars.AWS_ROLE_ARN }}
      - name: Whoami
        run: aws sts get-caller-identity
      - name: Print the demo parameter
        run: |
          aws ssm get-parameters \
            --names ${{ vars.SSM_DEMO_PARAMETER }} \
            --with-decryption

What's important here is that we are not passing in any credentials to the workflow; there is no AWS access-key and secret-key anywhere, nor do we store such values in GitHub Secrets.

It takes away the maintenance of secret rotation and also allows us not to worry about the authentication process as that is happening by the two services, i.e., the GitHub Actions as the OIDC provider and the AWS as the service provider.

This, in effect, means that any runner job inside the said repository will get the access token from GitHub Actions and use that to authenticate to AWS IAM. Which also means that no matter which disposable environment the runner job is in and how many times it has run, it will always be able to access the AWS SSM without any manual intervention.

Another very important highlight is that the runner job identity might change because of the dynamic nature of the GitHub Actions runners; another disposable runner might pick up the job and run it to completion and that will mess up with all our authentication system if we were to assign IAM Policy to a specific user of the said identity provider (see the screenshot below).

That is the true power of adopting OIDC as an authentication mechanism.

The successful CI job run will look like this:

Successful CI run
Successful GitHub Actions CI run

You will notice that the caller ID ARN has an assumed-role in it. This is what it means to grab the temporary credentials from the IAM role and use them to access the AWS services14.

You can also notice the UserId which is the name assigned to the runner job by the GitHub Actions identity provider. For the record, AWS has no username representing that identifier, but only because it trusts the recently added identity provider, it acknowledges the request as being valid and grants access.

GitHub CLI watch CI from terminal

A neat technique I use quite often in my day to day job is to use my terminal for more than the required tasks. I tend to avoid the browser to reduce the context switching, keep my focus on the terminal and have less mental load and distractions.

One of the powerful commands I use quite often is the watch command that allows me to track the CI job from the terminal itself.

gh run watch \
  $(gh run list -b $(git branch --show-current) \
    --limit 1 --json databaseId --jq .[].databaseId)

Bonus: CloudTrail Logs

We have done all the required steps to authenticate and grant access to the runner job in GitHub Actions to access the AWS Parameter Store.

Let's take a look at the CloudTrail logs to see the successful request and response.

CloudTrail TF Code

There is a minimal reproducible example TF code available if you haven't setup your CloudTrail Logs yet15.

Click to expand
{
  "awsRegion": "eu-central-1",
  "eventCategory": "Management",
  "eventID": "59d1c8df-5c1b-460b-bba3-9f13e94fa9ac",
  "eventName": "GetParameters",
  "eventSource": "ssm.amazonaws.com",
  "eventTime": "2024-04-11T13:30:29Z",
  "eventType": "AwsApiCall",
  "eventVersion": "1.08",
  "managementEvent": true,
  "readOnly": true,
  "recipientAccountId": "XXXXXXXXXXXX",
  "requestID": "1fd24d3a-cbc7-463d-bf4c-fc48ebe7765f",
  "requestParameters": {
    "names": [
      "/some/parameter/in/aws/ssm"
    ],
    "withDecryption": true
  },
  "resources": [
    {
      "ARN": "arn:aws:ssm:eu-central-1:XXXXXXXXXXXX:parameter/some/parameter/in/aws/ssm",
      "accountId": "XXXXXXXXXXXX"
    }
  ],
  "responseElements": null,
  "sourceIPAddress": "172.183.82.224",
  "tlsDetails": {
    "cipherSuite": "ECDHE-RSA-AES128-GCM-SHA256",
    "clientProvidedHostHeader": "ssm.eu-central-1.amazonaws.com",
    "tlsVersion": "TLSv1.2"
  },
  "userAgent": "aws-cli/2.15.36 Python/3.11.8 Linux/6.5.0-1017-azure exe/x86_64.ubuntu.22 prompt/off command/ssm.get-parameters",
  "userIdentity": {
    "accessKeyId": "XXXXXXXXXXXXXXXXXXXX",
    "accountId": "XXXXXXXXXXXX",
    "arn": "arn:aws:sts::XXXXXXXXXXXX:assumed-role/github-actions-oidc-role/GitHubActions",
    "principalId": "AROA6AMOBUU5GFII4ZDZU:GitHubActions",
    "sessionContext": {
      "attributes": {
        "creationDate": "2024-04-11T13:30:26Z",
        "mfaAuthenticated": "false"
      },
      "sessionIssuer": {
        "accountId": "XXXXXXXXXXXX",
        "arn": "arn:aws:iam::XXXXXXXXXXXX:role/github-actions-oidc-role",
        "principalId": "AROA6AMOBUU5GFII4ZDZU",
        "type": "Role",
        "userName": "github-actions-oidc-role"
      },
      "webIdFederationData": {
        "attributes": {},
        "federatedProvider": "arn:aws:iam::XXXXXXXXXXXX:oidc-provider/token.actions.githubusercontent.com"
      }
    },
    "type": "AssumedRole"
  }
}

Notice the principal ID and the assumed role ARN in the log is the same as what we saw in the CI job screenshot. This confirms that the authentication has been successful and the identity provider and the service provider were able to work together to grant access to the runner job.

Conclusion

That concludes are tutorial on OpenID Connect and how to use it to authenticate GitHub Actions runner jobs to access AWS services securely.

On a day to day operations job, you will find yourself needing to grant access from one service to another. OpenID Connect (OIDC) is the modern-day solution to this problem. It has a neat approach to handle authentication that won't require any long-lived credentials, yet still be flexible enough for you to define a granular access control on the service provider side.

I am guilty of passing long-lived credentials in the past, but I am glad that with this new finding, I can alter my past and current workflows for a more secure and robust and yet less overhead approach.

I hope you too can find spots in your workflows where you can adopt OIDC and make your services more secure and resilient.

Thanks for reading thus far, ciao, and till next time! 🫡

OIDC future blogs

There will be at least two more blog posts on the OIDC topic. One will be to authenticate Kubernetes in-cluster ServiceAccounts with the AWS so that the pods can access the AWS services.

The other will be mainly focused on Ory Hydra, a great opensource project that implements the OIDC protocol & can give you customization and pluggable architecture over your auth setup.

Stay tuned for more goodies! 🎉