I needed to push 20 billion API requests through OCI Object Storage over a couple of weeks. From Rust. Without baking long-lived API keys into anything that runs unattended.
I also needed to use OCI Queue from a Rust service running in Kubernetes.
There is no official Oracle Rust SDK. There’s no public discussion of one. The community fork I’d been working with (digital-divas/oci-rust-sdk) only supported config-file auth, which is fine for local dev and a non-starter for either of those workloads.
Three options. Run a Go sidecar to broker the OCI calls. Stand up some FFI shim around the Java or Python SDK. Or write the SDK in Rust myself, with heavy AI assistance on the boilerplate. I picked option three. The result is at github.com/GEverding/oci-rust-sdk, MIT-licensed. It’s a work in progress and nowhere near full coverage of the OCI API surface. It does what I needed it to do, and it implements both of the authentication mechanisms a real workload on OCI actually wants: instance principals (for VMs) and OKE workload identity (for pods).
This post is part one of two on those mechanisms. Part one is instance principals. Part two will be OKE workload identity. Both follow the same shape: bootstrap a key, federate with the Auth service, sign requests with what comes back. The bootstrap step is where each one earns its name.
I’ll work bottom-up from the wire. Three sequence diagrams cover the dance; the rest is annotations on what surprised me, with code from the SDK to make it concrete. Opinions and numbers are mine, the grammar is not.
Why instance principals exist (skip if you know)
An OCI compute instance has its own identity. You add the instance to a dynamic group, you write a policy granting that group permissions, and the instance can call OCI services as itself with no API key.
The mechanism is the interesting part. Oracle could have done this AWS-style: slap a role on the VM, drop short-lived bearer credentials at IMDS, call it a day. Instead OCI uses X.509-based federation. The instance has a leaf certificate; it exchanges that cert for a session token; it signs every API request with HTTP Signatures using a freshly generated keypair. More moving pieces than IAM roles, but the session private key is generated locally and never leaves the instance. That property is going to matter.
The dance, in three movements
┌──────────┐ ┌──────────────┐ ┌─────────────┐
│ IMDS │───▶│ Auth │───▶│ Any OCI │
│ 169.254 │ │ /v1/x509 │ │ service │
│ │ │ │ │ │
└──────────┘ └──────────────┘ └─────────────┘
leaf cert JWT security signed call
+ leaf key token bound (session key,
to session ST$-prefixed
public key token as keyId)
- Bootstrap. Pull the leaf cert and key from the instance metadata service.
- Federate. Trade the leaf cert for a security token from the Auth service, registering a fresh session public key in the process.
- Sign. Use the session key plus the security token to sign actual service calls.
Sequence diagrams and code below, one movement at a time.
Movement 1: bootstrap from IMDS
sequenceDiagram
autonumber
participant App as Your Rust process
participant IMDS as IMDS<br/>169.254.169.254
App->>IMDS: GET /opc/v2/identity/cert.pem<br/>Authorization: Bearer Oracle
IMDS-->>App: leaf certificate (PEM)
App->>IMDS: GET /opc/v2/identity/key.pem
IMDS-->>App: leaf private key (PEM)
App->>IMDS: GET /opc/v2/identity/intermediate.pem
IMDS-->>App: intermediate cert (PEM)
The leaf cert and key come from Oracle’s instance metadata service at 169.254.169.254. Same address AWS uses, different paths, and a different auth header: OCI requires Authorization: Bearer Oracle on every metadata request. It’s their version of AWS’s IMDSv2 token dance, just simpler (the literal string Oracle is the token).
async fn fetch_certificate_and_key(&self) -> Result<(String, String, String), AuthError> {
let cert = self
.imds_get_with_retry("identity/cert.pem", "certificate")
.await?
.text().await?;
let private_key = self
.imds_get_with_retry("identity/key.pem", "private key")
.await?
.text().await?;
// Optional: intermediate cert. Log and continue on failure.
let intermediate_cert = match self
.imds_get_with_retry("identity/intermediate.pem", "intermediate certificate")
.await
{
Ok(resp) => resp.text().await?,
Err(e) => {
warn!(error = %e, "Failed to fetch intermediate cert; continuing without it");
String::new()
}
};
Ok((cert, intermediate_cert, private_key))
}
Three files matter. cert.pem is the leaf cert, signed by Oracle’s intermediate CA. key.pem is the leaf private key, which exists at the IMDS endpoint and which you just GET over plain HTTP (the metadata service is link-local and only addressable from the instance itself, but it’s still worth pausing on). intermediate.pem is the chain. The intermediate is only required if Auth doesn’t already trust the leaf signer directly, which mostly works in practice; I send it anyway and treat the fetch as best-effort.
Sidebar: where the tenancy OCID hides
You’d think the tenancy OCID would be in the leaf cert subject as O= or OU=. It isn’t. It’s encoded as a custom prefix in one of the subject attributes, and the prefix has changed over time. My code tries three:
for attr in cert.subject().iter_attributes() {
if let Ok(value) = attr.as_str() {
if let Some(tenancy) = value.strip_prefix("opc-tenant:") {
return Ok(tenancy.trim().to_string());
}
if let Some(tenancy) = value.strip_prefix("opc-identity:") {
return Ok(tenancy.trim().to_string());
}
// Fallback: bare OCID (legacy)
if value.starts_with("ocid1.tenancy.") {
return Ok(value.trim().to_string());
}
}
}
opc-tenant: is current, opc-identity: is older, and a bare ocid1.tenancy. is older still. Hitting two of three in production across a fleet was educational.
Movement 2: federation handshake
sequenceDiagram
autonumber
participant App as Your Rust process
participant Auth as Auth service<br/>auth.{region}.oraclecloud.com
Note over App: Generate fresh RSA-2048 session keypair
Note over App: Wrap session pubkey in SPKI DER
Note over App: Build body: { certificate,<br/>publicKey: session_pub,<br/>intermediateCertificates,<br/>purpose: "DEFAULT" }
Note over App: Sign body with LEAF key,<br/>keyId = "{tenancy}/fed-x509/{sha1(cert)}"
App->>Auth: POST /v1/x509<br/>Authorization: Signature ...
Auth-->>App: { "token": "<JWT>" }
Note over App: Cache (session_keypair, token)<br/>refresh at iat/exp midpoint
This is where most of the surprises live. You POST what you got from IMDS to https://auth.{region}.oraclecloud.com/v1/x509. The request body is JSON:
let body = serde_json::json!({
"certificate": cert_sanitized,
"publicKey": session_public_sanitized, // session key, NOT leaf
"intermediateCertificates": if intermediate_cert.is_empty() {
Vec::<String>::new()
} else {
vec![sanitize_pem(intermediate_cert)]
},
"purpose": "DEFAULT"
});
Notice what’s in publicKey. It is not the public key of the leaf cert. It’s a brand-new RSA-2048 keypair you generate locally for this exchange. The leaf key signs the federation request (proving you control the leaf cert). The fresh session keypair is what you’ll use for everything afterward.
Read that again. The instance’s identity certificate, the thing the metadata service handed you, is used exactly once — to sign one POST to Auth — and then it’s done. From here on out, you authenticate as the instance using a key Oracle has never seen before this request and will never see in raw form. The leaf private key never has to leave its short lifecycle window. Oracle rotates the IMDS-mounted leaf cert on its own schedule; the session key you generate has its own lifetime tied to the security token. Once federation returns, you can drop the leaf key entirely.
Sidebar: SHA-1, in 2026
The keyId for the federation request includes a fingerprint of the leaf cert. Every other fingerprint OCI uses (HTTP signature digests, content hashes) is SHA-256. The fed-x509 keyId fingerprint is SHA-1. I burned the better part of an evening on this one.
fn compute_cert_fingerprint(cert_pem: &str) -> Result<String, AuthError> {
let pem = ::pem::parse(cert_pem)
.map_err(|e| AuthError::InvalidKeyFormat(format!("Invalid cert PEM: {}", e)))?;
let mut hasher = Sha1::new(); // not Sha256
hasher.update(pem.contents());
let result = hasher.finalize();
// Colon-separated uppercase hex
let hex: Vec<String> = result.iter().map(|b| format!("{:02X}", b)).collect();
Ok(hex.join(":"))
}
I lost time to this. Auth doesn’t tell you the fingerprint is wrong; it just says signature verification failed, which can mean the signature is wrong, the key is wrong, the headers are wrong, or, as in this case, the keyId doesn’t resolve to a known cert. SHA-1 is defensible here (collision resistance isn’t security-critical for an identifier) but it’s still a surprise in 2026.
Sidebar: SPKI by hand
I picked aws-lc-rs for the crypto (more on that in a second). Its RsaKeyPair::public_key() returns PKCS#1 format: bare modulus and exponent. OCI’s federation endpoint wants the public key in SubjectPublicKeyInfo (SPKI) format: PKCS#1 wrapped in an algorithm identifier. There is no to_spki() method on RsaKeyPair.
I could have pulled in a heavier crate. The SPKI envelope is twelve bytes of constant prefix and a length-encoded BIT STRING wrapper, though, so hand-writing the DER is shorter than writing the dependency justification:
// AlgorithmIdentifier for RSA: SEQUENCE { OID, NULL }
// OID 1.2.840.113549.1.1.1 = rsaEncryption
let algorithm_identifier: &[u8] = &[
0x30, 0x0D, // SEQUENCE, length 13
0x06, 0x09, // OBJECT IDENTIFIER, length 9
0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01,
0x05, 0x00, // NULL
];
// BIT STRING wrapping the PKCS#1 public key
let bit_string_len = 1 + pkcs1_der.len();
let mut bit_string = Vec::with_capacity(3 + bit_string_len);
bit_string.push(0x03); // BIT STRING tag
// ... length encoding (long form if >127) ...
bit_string.push(0x00); // unused bits
bit_string.extend_from_slice(pkcs1_der);
// Outer SEQUENCE
let mut spki_der = Vec::with_capacity(4 + content_len);
spki_der.push(0x30); // SEQUENCE tag
spki_der.push(0x82); // long-form length
spki_der.push((content_len >> 8) as u8);
spki_der.push((content_len & 0xFF) as u8);
spki_der.extend_from_slice(algorithm_identifier);
spki_der.extend_from_slice(&bit_string);
Once you’ve seen the structure, it’s almost satisfying.
Sidebar: aws-lc-rs vs ring vs openssl
The initial commit used openssl. I migrated to aws-lc-rs for three reasons. First, I didn’t want a libssl build dependency for what is fundamentally a small set of RSA primitives. Second, plain ring doesn’t expose RSA keypair generation through its public API (it can sign with a keypair you parsed in, not generate fresh ones), which I need for the session key on every refresh. Third, aws-lc-rs keeps a path open for FIPS-oriented deployments without dragging libssl into the build, which matters in some compliance contexts. The tradeoff is the manual SPKI wrapping above. Worth it.
What you get back
A single field: { "token": "<JWT>" }. The JWT’s iat and exp claims drive my refresh logic, which is out of scope for this wire-level post.
Movement 3: signed service call
sequenceDiagram
autonumber
participant App as Your Rust process
participant Svc as OCI service<br/>(e.g. objectstorage)
Note over App: Build canonical signing string:<br/>date, (request-target), host,<br/>content-length, content-type,<br/>x-content-sha256
Note over App: Sign with SESSION key,<br/>keyId = "ST${token}"
App->>Svc: GET /n/{ns}/b/{bucket}/o<br/>Authorization: Signature ...
Svc-->>App: 200 OK
OCI uses the IETF “Cavage” HTTP Signatures spec (the precursor to RFC 9421). The signature covers a canonical signing string built from request headers:
let mut data = format!(
"date: {}\n(request-target): {} {}\nhost: {}",
date, method, path, host
);
let mut headers_list = String::from("date (request-target) host");
if let Some(content_length) = headers.get("content-length") { /* append */ }
if let Some(content_type) = headers.get("content-type") { /* append */ }
if let Some(content_sha256) = headers.get("x-content-sha256") { /* append */ }
let mut signature = vec![0u8; key_pair.public_modulus_len()];
key_pair.sign(&RSA_PKCS1_SHA256, &rng, data.as_bytes(), &mut signature)?;
Ok(format!(
"Signature algorithm=\"rsa-sha256\",headers=\"{}\",keyId=\"{}\",\
signature=\"{}\",version=\"1\"",
headers_list, key_id, BASE64.encode(&signature)
))
Read requests sign date (request-target) host. Write requests add content-length, content-type, and x-content-sha256 (a base64-encoded SHA-256 of the body). Order matters; the headers-list string in the Authorization header must match the order they appear in the signing string.
Sidebar: the ST$ prefix
The keyId for service calls is ST${security_token}. Literally that: the two characters S, T, then $, then the JWT.
let key_id = format!("ST${}", creds.security_token);
let authorization = sign_request_with_key(
&creds.session_key_pair, // session key, not leaf
&key_id,
headers, method, path, host,
)?;
I do not know why. The OCI signing spec mentions it briefly; the official SDKs encode it as a special case. Get it wrong and the service rejects the request with a 401 and no useful error message. Easy to miss because in any documentation snippet that doesn’t escape it, ST$ looks like shell variable interpolation.
Note the signing key here is the session keypair, not the leaf key. Once federation is done, the leaf cert and key are off the critical path. The session key plus the ST$-prefixed token is the unit of authentication for the rest of the token’s lifetime.
What I left out
The interesting operational stuff doesn’t fit the diagrams: refreshing tokens at the iat/exp midpoint instead of trying to predict expiry, double-checked locking around the IMDS refresh to avoid thundering-herd bursts on cold starts, scrubbing Debug impls so security tokens don’t leak into logs, and the cross-region warning that fires when you point a region-scoped auth provider at the wrong endpoint. Each of those is its own small lesson; if anyone wants the writeup, that’s another post.
Up next
Part two is OKE workload identity. Same dance, different bootstrap: instead of pulling a leaf cert from IMDS, you read a Kubernetes-projected service account JWT and trade it through an in-cluster proxy on port 12250. The proxy returns a session token in one of three wire formats (yes, three) and a 403 means your cluster isn’t enhanced. We’ll get there.
Code for everything above is in github.com/GEverding/oci-rust-sdk under src/auth.rs. Issues and forks welcome.
Most cloud auth is short-lived bearer tokens. OCI’s is short-lived signed RSA, with the credential never crossing the wire in raw form. More moving pieces, sharper edges, better property. Sometimes the more complicated answer is the right one.
This is the third post in a series about building real infrastructure on OCI. The first post, The Cloud Egress Tax, covers why I think the hyperscalers’ networking pricing is broken. The second, DRGs: Dual-Hub, Dual-Home Networking, is on OCI’s transit routing model.