Skip to main content

API authentication

The admin API is served under /admin_api/v1 and authenticates every request individually — there are no sessions and no login call. You can authenticate in two ways:

  • API secrets — an access key ID and a secret key issued by OSIE, used to sign each request with AWS Signature Version 4. Available on every installation with nothing to configure, and each key can be restricted to the endpoints it needs.
  • Bearer tokens — an access token from an external OpenID Connect provider. This requires an OpenID provider to be configured for the admin API first, and the token grants unrestricted access.

Use API secrets unless you already operate an OpenID provider whose lifecycle you want the integration to follow.

Create an API secret

Issue one key per integration. Keys are independent, so you can scope a CI pipeline to project management only, and revoke it later without affecting anything else that calls the API.

In the admin dashboard, go to Settings → Access & Security → API Secrets.

The API Secrets tab under Access & Security

Select Create API Secret, describe what the key is for, and tick the permissions it needs. Ticking a group name grants the whole group — the wildcard shown beneath it, such as admin:project:*.

Creating an API secret with the project permissions selected

OSIE generates the access key ID and the secret key, and displays the secret key once.

The generated access key ID and secret key

warning

The secret key is encrypted at rest and is never displayed again. Copy it before you close the panel. If it is lost, use Rotate secret on the key to issue a new one — this invalidates the previous secret immediately, so update your integration in the same change.

Creating, rotating and deleting API secrets requires the admin:hmac_key:manage permission.

Scope a key to the endpoints it needs

Each admin API endpoint declares the permission it requires. A request is rejected when the signing key does not hold a permission that matches it:

{"error":{"code":"FORBIDDEN","message":"API secret lacks required permission: admin:price_plan:read"}}

Permissions are matched by pattern, so admin:project:* covers admin:project:read, admin:project:create and every other permission in the project group, and * covers all of them. A key created with no permissions at all has full access, which is how keys issued before per-key scoping behave.

Sign a request

Signing is Signature Version 4 with the standard Authorization header, so any AWS SigV4 library signs OSIE requests without modification. Point it at the region us-east-1 and the service name osie.

curl 7.75 and later signs the request for you:

curl --aws-sigv4 "aws:amz:us-east-1:osie" \
--user "<access-key-id>:<secret-key>" \
"https://osie.mycompany.com/admin_api/v1/projects?limit=10"

The request body is part of the signature, so it must be passed to the signer as well as to the server:

curl -X POST --aws-sigv4 "aws:amz:us-east-1:osie" \
--user "<access-key-id>:<secret-key>" \
-H "Content-Type: application/json" \
-d '{"name":"Acme Corp"}' \
"https://osie.mycompany.com/admin_api/v1/organizations"

From code

Signing with botocore, which ships with boto3:

import json
from urllib.parse import urlencode

import requests
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
from botocore.credentials import Credentials

ACCESS_KEY_ID = "<access-key-id>"
SECRET_KEY = "<secret-key>"
BASE_URL = "https://osie.mycompany.com"

SIGNER = SigV4Auth(Credentials(ACCESS_KEY_ID, SECRET_KEY), "osie", "us-east-1")


def call(method, path, params=None, body=None):
"""Signs a request with SigV4 and sends it."""
url = f"{BASE_URL}{path}"
if params:
url = f"{url}?{urlencode(params)}"
headers = {"Content-Type": "application/json"} if body is not None else {}
payload = json.dumps(body) if body is not None else None

request = AWSRequest(method=method, url=url, data=payload, headers=headers)
SIGNER.add_auth(request)
return requests.request(method, url, headers=dict(request.headers), data=payload)


projects = call("GET", "/admin_api/v1/projects", params={"limit": "10"})
print(projects.json()["data"])

call("POST", "/admin_api/v1/organizations", body={"name": "Acme Corp"})

What OSIE verifies

A signed request carries an Authorization header of this shape, plus the X-Amz-Date header it was signed with:

Authorization: AWS4-HMAC-SHA256 Credential=<access-key-id>/<yyyymmdd>/us-east-1/osie/aws4_request, SignedHeaders=host;x-amz-date, Signature=<hex>
X-Amz-Date: 20260730T151905Z

OSIE looks up the access key ID, re-signs the request with the stored secret, and compares the result against the signature you sent. Four things decide whether that comparison succeeds:

  • The host header must be signed, and must be the hostname the client actually called. OSIE reconstructs the request URL from the signed host header rather than from what reached the application, so signing a request for one hostname and sending it to another fails even when a reverse proxy forwards it correctly.
  • X-Amz-Date must be within 5 minutes of the server's clock. A larger difference is rejected before the signature is checked.
  • The body is hashed into the signature. Sign the exact bytes you send, including on POST and PUT.
  • The region and service name are part of the signing key. OSIE does not require particular values — it re-signs with whatever the credential scope declares — so any consistent pair works. Use us-east-1 and osie so that your requests match these examples.

Troubleshooting

SymptomCauseFix
401 with Unknown access keyThe access key ID is not on this installationCheck the key exists under Settings → Access & Security → API Secrets, and that you are calling the right environment
401 with Signature mismatchThe secret is wrong, or the signed request differs from the one sentConfirm the secret, and that the body and query string are signed exactly as sent
401 with Date skew too largeThe client clock is more than 5 minutes from the server'sSynchronise the client clock; do not reuse a signature across requests
403 with API secret lacks required permissionThe key is scoped and does not cover this endpointAdd the permission named in the message to the key
401 on every call, no signing errorThe Authorization header never reached the APICheck that intermediate proxies forward Authorization and X-Amz-Date unchanged

Bearer token authentication

An access token issued by an external OpenID Connect provider is accepted as an alternative, for installations that already centralise machine credentials there. Unlike API secrets, a bearer token is not scoped by OSIE — it grants access to the whole admin API.

Point the admin API at your provider in values.yaml:

adminApi:
oauth2:
clientId: "osie-admin-api" # the client_id used to obtain the access token
issuerUri: https://your-openid-provider.com/

See the default values.yaml of the Helm Chart. issuerUri is empty by default, so this method is unavailable until it is set. Choosing and connecting a provider is covered in Identity management.

Obtain a token from your provider with the client credentials grant:

curl --location 'https://your-openid-provider.com/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=osie-admin-api' \
--data-urlencode 'client_secret=<client-secret>' \
--data-urlencode 'grant_type=client_credentials'

Then pass it on every call:

curl --location 'https://osie.mycompany.com/admin_api/v1/projects?limit=10' \
--header 'Authorization: Bearer <access-token>'

Access tokens expire. Once a token is past its expires_in, calls return 401 Unauthorized until you obtain a new one.