2026-05-27 – One Login, Two Outcomes: AWS Credentials and OpenVPN Bootstrap via Okta SSO

At a past company I worked at, one of the ongoing tasks on the security and platform side was making developer access both easy and safe. That combination is harder than it sounds. The common failure mode is picking one at the expense of the other — either security is tight and devs hate the friction, or it is frictionless and your keys end up on GitHub.

The solution we landed on was a CLI tool that bridged Okta identity with two separate infrastructure systems: AWS and OpenVPN. One login, two outcomes. This post breaks that down in plain terms for anyone who has never touched these APIs before.

The Problem Worth Solving

Developers on most engineering teams need to do two things many times a day:

  1. Run cloud commands: deploy services, query databases, access S3, debug infrastructure.
  2. Connect to private networks: reach internal services that are not exposed to the internet.

Without automation, this workflow looks like: open browser, navigate to SSO portal, click the app, copy the credentials, paste them into a terminal, open another tab for VPN, download a profile, import it. Repeat tomorrow. Repeat when the token expires. Repeat after onboarding a new engineer.

It is tedious, error-prone, and inconsistent. The CLI approach solves this by making identity the only source of truth and automatically deriving access from it.

The Big Picture

Before diving in, here is the mental model. Think of this as a pipeline:

Identity goes in. Usable infrastructure access comes out.

Figure 1: End-to-End User Flow

Part 1: How Okta Works From a Code Perspective

If you have never called the Okta API directly, it can feel a little mysterious. Here is what is actually happening.

Primary Authentication

Okta exposes a REST API at /api/v1/authn. You POST a JSON body with username and password, and Okta responds with a status field telling you what to do next.

POST https://<your-org>.okta.com/api/v1/authn
{
“username”: “alice@example.com”,
“password”: “hunter2”
}

The response status can be one of several things:

  1. SUCCESS — user is authenticated, here is a session token.
  2. MFA_REQUIRED — credentials are valid but a second factor is required before issuing a token.
  3. MFA_CHALLENGE — a factor challenge has been initiated, poll for completion.
  4. MFA_ENROLL — this user has not enrolled any MFA factors yet.

The SUCCESS response includes a sessionToken field. That token is short-lived and can be exchanged for a session or used to hit app embed links.

The State Token and MFA Loop

When Okta returns MFA_REQUIRED, it also returns a stateToken. This is Okta’s way of tracking the in-progress authentication context. You need to include it in every subsequent call during the MFA phase.

The response also includes a list of enrolled factors. Each factor has a type (like push, token:software:totp, webauthn) and an ID. Your code picks a factor, sends a verify request with the factor ID and state token, and then either gets a result or enters a polling loop waiting for the user to respond.

Figure 2: Okta Authentication State Machine

diagram

What the Session Token Is Used For

Once you have a sessionToken, you do not use it like a bearer token in API headers. Instead, you pass it as a query parameter to an Okta app embed URL. That URL looks like this:

GET https://<your-org>.okta.com/home/<app-type>/<app-id>?onetimetoken=<sessionToken>

Okta responds with an HTML page containing a hidden form. Inside that form is a field called SAMLResponse. That base64-encoded blob is the SAML assertion — a signed XML document that proves who you are and what roles you should have.

This is where Okta hands off to the downstream systems.

Part 2: What SAML Actually Is

SAML (Security Assertion Markup Language) is the glue between identity systems and applications that need to trust those identities. If you have never seen a SAML assertion, think of it as a cryptographically signed letter that says:

“I, Okta, certify that Alice is authenticated and belongs to these groups/roles. Valid until 14:00 UTC. Signed with our private key.”

Any system that trusts Okta’s public key can verify that letter without calling Okta again. That is the whole point. Authentication happened once. The assertion travels downstream and each system validates it independently.

What Is Inside the SAML XML

When you decode the base64 SAMLResponse, you get an XML document. For AWS integration, the important part is an Attribute element with the name https://aws.amazon.com/SAML/Attributes/Role. It contains one or more values that look like this:

arn:aws:iam::123456789012:role/EngineerRole,arn:aws:iam::123456789012:saml-provider/OktaProvider

Each value is a comma-separated pair: the IAM role ARN and the SAML provider ARN. The code parses these out and presents them to the user when there is more than one option.

Figure 3: Inside a SAML Assertion

diagram

Part 3: AWS STS and How Federated Login Works

AWS STS (Security Token Service) is the service that converts the SAML assertion into actual AWS credentials. The call is AssumeRoleWithSAML, and it is worth understanding because it is where a lot of engineers get confused.

What You Send

sts.assume_role_with_saml(
RoleArn=”arn:aws:iam::123456789012:role/EngineerRole”,
PrincipalArn=”arn:aws:iam::123456789012:saml-provider/OktaProvider”,
SAMLAssertion=”<base64 encoded SAML assertion>”
)

Three things go in: the role you want to assume, the identity provider that vouches for you, and the signed SAML assertion. AWS verifies the SAML signature against the certificate it has on file for that provider, then checks whether the role’s trust policy allows SAML-based assumption.

What You Get Back

{
“Credentials”: {
“AccessKeyId”: “ASIA…”,
“SecretAccessKey”: “wJalrXUtnFEMI…”,
“SessionToken”: “AQoXnyc4lcK4…”,
“Expiration”: “2026-05-27T15:00:00Z”
}
}

These credentials work exactly like regular AWS credentials but they expire. The default maximum is one hour. The code writes these to ~/.aws/credentials under a named profile so you can use them with the AWS CLI or any AWS SDK without changing anything else.

The Re-Up Loop

One of the practical engineering challenges is that one hour is not very long for an active workday. The CLI solves this with an optional re-up mode. It stays running, checks the expiration of the current credentials, and refreshes them ten minutes before they would expire by repeating the SAML and STS flow. No human intervention required.

Figure 4: AWS STS Sequence

Part 4: The Shared-Key Problem and Why It Matters

If you are new to cloud security, this is the most important concept to internalize.

The traditional way to give a developer AWS access is to go into the IAM console, create an access key, and hand them the key ID and secret. Simple. But those keys have no expiration by default, and this is where the trouble starts.

The Lifecycle of a Compromised Static Key

diagram

The fundamental issue is that static keys do not expire on their own. They exist until someone actively deletes or rotates them. And in practice, rotation is often delayed, incomplete, or forgotten.

What Federated Credentials Change

diagram

Even if someone gets hold of your temporary credentials, they expire. The blast radius is bounded in time.

Part 5: OpenVPN — The Most Interesting Part

This is the piece I found the most fun to figure out. AWS and Okta have well-documented integration patterns. OpenVPN Access Server’s SAML SSO API is far less documented at the programmatic level. Most guides assume you are clicking through a browser. We needed to automate it.

Background: How OpenVPN Access Server Does SSO

OpenVPN Access Server (the commercial product, not the open-source version) supports SAML-based authentication. When a user browses to the VPN web UI and clicks login, it redirects them to Okta. Okta authenticates them and sends a SAML assertion back to OpenVPN’s ACS (Assertion Consumer Service) endpoint. The server validates the assertion, creates a session, and issues a session cookie.

That session cookie is the key to everything. Once you have it, you are a recognized, authenticated session to the VPN server and can make API calls against it.

The Problem With Automating This

Browsers handle all of this transparently. From a CLI, you have to manually replicate every step that a browser would normally handle for you:

  1. Maintaining a cookie jar across requests.
  2. POSTing the SAML assertion to the right endpoint with the right headers.
  3. Recognizing which cookie in the jar is the VPN session cookie.
  4. Using that cookie on subsequent calls to protected endpoints.

Step-by-Step: What the Logic Actually Does

Step 1 — Reuse the authenticated Okta session

The CLI already has an authenticated requests.Session() object from the Okta login step. That session has cookies from Okta baked in. We use the same session to make the call to the VPN server so the cookie jar persists.

Step 2 — POST the SAML assertion to the ACS endpoint

POST https://<vpn-host>/saml/acs
Content-Type: application/x-www-form-urlencoded
SAMLResponse=<base64 assertion>&RelayState=profile

The RelayState field tells the VPN server what the user was trying to do before they were redirected to the IdP. Passing profile here signals that we want to get a profile back, not just a browser session.

Step 3 — Harvest the VPN session cookie

After the ACS POST, the VPN server sets a cookie with a name like openvpn_sess_<randomstring>. This is the authenticated session identifier. You filter your cookie jar for that prefix and hold onto it.

Step 4 — Call the profile import endpoint

POST https://<vpn-host>/import_profile
Cookie: openvpn_sess_<token>=<value>
profile_type=userlocked

userlocked means the profile is tied to this specific authenticated user, not a generic shared profile. The response body is a URL in the form:

openvpn://import-profile/https://<vpn-host>/rest/GetProfileViaToken?token=<one-time-token>

This is an OpenVPN Connect deep link. It is designed to be opened by the OpenVPN Connect desktop client to auto-import. But programmatically, we just extract the HTTPS URL from it.

Step 5 — Fetch the profile using the one-time token

GET https://<vpn-host>/rest/GetProfileViaToken?token=<one-time-token>

This returns the raw .ovpn file content. The token is single-use and short-lived. We write the response bytes directly to a profile.ovpn file on disk.

Figure 5: OpenVPN SSO Bootstrap Sequence (Detailed)

Figure 6: Cookie Lifecycle During the VPN Flow

Why the Headers Matter

One subtle thing when calling OpenVPN’s programmatic endpoints is that the server checks for specific headers to decide whether a request is coming from the web client UI versus a raw browser versus a programmatic caller. Getting these wrong can cause the server to return unexpected responses or errors.

Key headers to get right:

  1. X-OpenVPN: 1 — signals that the request is from an OpenVPN-aware client.
  2. X-CWS-Proto-Ver: 2 — identifies the Client Web Service protocol version.
  3. X-Requested-With: XMLHttpRequest — tells the server this is an AJAX-style call, not a browser navigation.

Without these, the VPN server may respond with HTML redirect flows intended for browsers rather than JSON or raw profile data intended for clients.

Part 6: Putting It All Together

Here is the final view showing how every piece connects, from a single CLI command to two forms of usable infrastructure access.

Figure 7: Complete System Map

Key Takeaways for New Engineers

If you are reading this because you are about to tackle a similar problem, here are the non-obvious lessons:

On Okta: The session token from /authn is single-use and short-lived. Combine it with a persistent requests.Session() so the long-lived session cookie persists across your calls. That is how re-up mode keeps working without re-prompting for credentials.

On SAML: The assertion is XML, base64-encoded. Decode it, parse the XML, and look for the Role attribute. AWS expects a very specific format. Any mismatch between the role ARN and the trust policy configuration causes a silent failure at the STS call.

On AWS STS: Both the RoleArn and PrincipalArn must be present and correct. The PrincipalArn is your SAML provider resource in IAM, not a user. Missing or wrong values here give confusing AccessDenied errors.

On OpenVPN: The most important thing is that the requests.Session() cookie jar must carry cookies through both the Okta and VPN requests. If you use separate session objects, the VPN server will not see the right state. Keep one session, let it accumulate cookies, then filter for the openvpn_sess_ prefix after the ACS POST.

Implementation Checklist

If you want to build something similar from scratch:

  1. Register a SAML app in your IdP and configure it for the AWS app template.
  2. Set up IAM SAML identity provider in AWS and create roles with SAML trust policies.
  3. Implement the Okta /authn flow with MFA factor support.
  4. Parse the SAMLResponse HTML form field from the embed URL response.
  5. Extract IAM roles from the decoded SAML XML.
  6. Call AssumeRoleWithSAML and write credentials to the local credentials file.
  7. Add an expiry check loop for the re-up mode.
  8. Configure OpenVPN Access Server for SAML and identify your ACS endpoint.
  9. POST SAML to ACS using the same session, harvest the session cookie.
  10. Call /import_profile and parse the tokenized URL.
  11. Fetch the profile via token and write it to disk.
  12. Handle headers carefully so the VPN server treats your client as a known API caller.