Create an access key
Creates an access key and hands it back together with its secret access key, ready for an S3 client or another program. The new key always stays within the key you call with: the same permissions or fewer, buckets among its buckets, and an object prefix inside its prefix. That makes it safe to give a program a key that mints keys - whatever it creates can never reach further than it can itself.
An Idempotency-Key header makes the request safe to retry: a retry hands back the key the first
request created, and no second key is made. Idempotency has the details.
When you'd use it
- A CI pipeline minting a key per deploy. The pipeline holds one key with Manage access keys through the API, creates a key limited to the release bucket for the job, and deletes it when the job is done.
- Onboarding a new service. A provisioning script gives each new service its own key, limited to its own bucket and prefix, so that no two services share a secret.
- An agent or tool that sets itself up. A program can ask for a narrower key for one task - read-only, say, on one bucket - instead of carrying its own broad key into that task.
- Rotating a key. A new key with the same permissions comes first, the application moves over to it, and then the old one is deleted.
Request
POST /v1/access-keys HTTP/1.1
Host: api.strg.com
Authorization: Bearer <access key id>:<secret>
Content-Type: application/json
Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324
{
"label": "ci-uploads",
"permissions": ["objects:list", "objects:read", "objects:write"],
"bucketScope": ["my-bucket"],
"keyPrefix": "builds/"
}
Request headers
| Name | Required | Description |
|---|---|---|
Authorization | Yes | Bearer <access key id>:<secret> of an access key with the permission Manage access keys through the API (api:access-management). Authentication in the overview explains it |
Content-Type | Yes | application/json. A parameter such as ; charset=utf-8 is fine. Any other type is answered 415 unsupported_media_type |
Idempotency-Key | No | 1 to 255 printable ASCII characters - a UUID works well. It can be sent bare or as a quoted string ("8e03978e-…"); the quotes are not part of the key. Idempotency explains what it does |
Request body
A JSON object of at most 64 KiB, with the fields below and no others - a field not listed here is
answered 400 invalid_request.
| Name | Type | Required | Description |
|---|---|---|---|
label | string | Yes | A name for the key, at most 64 characters. Leading and trailing spaces are removed. It cannot be empty, and cannot be STRG Dashboard or STRG Assistant in any capitalization - those are the names of keys the dashboard creates for itself |
permissions | array of strings | Yes | The permission names, such as objects:read - Permissions lists every one. At least one, none twice, and at least one storage permission: a key holding only mcp:* or api:* permissions would have nothing to reach |
bucketScope | array of strings or null | No | The names of the buckets the key is limited to. Omitted or null: every bucket in your account, including buckets created later |
keyPrefix | string or null | No | The object key prefix the key is limited to, such as builds/ - matched literally and case-sensitively. Omitted or null: no prefix |
These rules decide what a key may hold:
| Rule | Detail |
|---|---|
| Always included | buckets:list and buckets:read-config are part of every key, so they are left out of permissions - they are not among the names it takes, and sending one is answered 400 invalid_request |
| Permissions that need another | object-retention:bypass-governance needs objects:delete-versions or object-retention:write; bucket-object-lock:write needs bucket-object-lock:read; mcp:access-management and mcp:skip-confirmation need mcp:enabled |
| Permissions that depend on the scope | buckets:write and buckets:delete need a key for every bucket (bucketScope omitted or null); buckets:list-all needs a key limited to some buckets |
bucketScope | 1 to 100 valid bucket names, none twice. Each is a bucket you own at the time of the request: a name that does not exist yet is answered 422 unknown_bucket |
keyPrefix | Not empty - leaving the field out is how to ask for no prefix - at most 1,024 bytes, and no control characters |
A key limited to some buckets reaches them by the bucket itself, not just its name: a bucket deleted and re-created under the same name is a new bucket, and the key no longer reaches it. Scope explains this.
And this is how the key you call with shapes the new one:
| The calling key | The new key |
|---|---|
| Holds a set of permissions | Holds only permissions from that set |
| Is limited to some buckets | Is limited to some of those buckets - not to every bucket |
| Is limited to an object prefix | Has a prefix that starts with it |
A request outside these is answered 403 exceeds_parent. The calling key always holds
api:access-management, so the new key may hold it too.
Response
201 Created, with the new key and its secret access key.
| Header | Value |
|---|---|
Content-Type | application/json |
Location | /v1/access-keys/<id> - the key's own URL, for Get an access key |
Idempotent-Replayed | true when an earlier request with the same Idempotency-Key created the key. Absent otherwise |
Cache-Control | no-store |
{
"id": "STRGEXAMPLEKEYID0001",
"label": "ci-uploads",
"bucketScope": ["my-bucket"],
"keyPrefix": "builds/",
"permissions": ["objects:list", "objects:read", "objects:write"],
"origin": "api",
"createdAt": "2026-10-18T09:30:00.000Z",
"regions": {},
"secretAccessKey": "<secret access key>"
}
| Field | Type | Description |
|---|---|---|
id | string | The access key ID - what an S3 client sends as AWS_ACCESS_KEY_ID |
label | string | The name, with leading and trailing spaces removed |
bucketScope | array of strings or null | The buckets the key reaches, by name - null for every bucket |
keyPrefix | string or null | The object key prefix the key is limited to, or null |
permissions | array of strings | The permissions, as sent |
origin | string | Where the key was created: api here. The dashboard's Access Keys page tags it Created through the API |
createdAt | string | When the key was created - ISO 8601, in UTC |
regions | object | Each region where the key is set up: pending until it works there, then active. Empty in the answer to a new create; a replay shows how far the key has got by then |
secretAccessKey | string | The secret access key - what an S3 client sends as AWS_SECRET_ACCESS_KEY |
- This response is the moment to store the secret access key. Only this response carries it - and, within the idempotency window, a replay of it. List access keys and Get an access key never return it. If a secret ever goes missing, a new key takes only a moment to create, and deleting the old one makes sure nobody else can use it.
- A new key takes a moment to get ready. It works in each region where you have a bucket once
it is
activethere. Get an access key showsregionsas it changes, and When a key starts working describes what the S3 API answers meanwhile. - Keys stay exactly as they were created, which makes them easy to reason about: a key that worked yesterday does the same today. Different permissions, buckets or prefix simply means a new key, and the old one can then be deleted.
Idempotency
A create that ends in a timeout, a dropped connection or a 500 may or may not have made the key.
An Idempotency-Key takes the guesswork out of that: a request carrying one is remembered for
24 hours, for the key you call with, so sending it again is always safe.
Within 24 hours, from the same calling key, with the same Idempotency-Key | Answer |
|---|---|
| The same body | 201 with the key the first request created - the same id and secretAccessKey - and Idempotent-Replayed: true. Nothing new is created |
| The same body, while the first request is still running | Waits for it, then answers as above |
| A different body | 422 idempotency_key_mismatch |
| The same body, after the key it created was deleted | 409 idempotent_key_revoked |
- A retry with the same
Idempotency-Keyafter a timeout, a lost connection or a500creates at most one key, however often it is sent. - A create that was turned down is not remembered. After a
402or a422 unknown_bucket, once the cause is sorted - a card added, the bucket created - the sameIdempotency-Keycreates the key. - The same body means the same values: the same
labelandkeyPrefix, exactly as sent, the same entries inpermissionsandbucketScopein any order, andnullthe same as an omitted field. Spacing and the order of the fields make no difference. - The same
Idempotency-Keyfrom another access key, or after 24 hours, is a new request.
Errors
This table lists every error the operation can answer, so a client can tell them apart by code.
Each is an application/problem+json body - Errors shows its shape.
| Status | code | When |
|---|---|---|
400 | invalid_request | The body is not valid JSON, has a missing, unknown or wrongly typed field, or breaks a rule of Request body. Or Idempotency-Key is not 1 to 255 printable ASCII characters. detail names the problem |
401 | unauthenticated | No valid Authorization header, or the key is unknown, deleted, has another secret, or lacks Manage access keys through the API. Every case gets the same answer, with WWW-Authenticate: Bearer realm="strg.com" |
402 | payment_method_required | Your account has no verified card yet. Adding one is something only a person can do, on the dashboard's Billing page - A card is required explains why |
403 | exceeds_parent | The new key would have more access than the key you call with: a permission it lacks, a bucket outside its buckets, every bucket when it is limited to some, or a prefix outside its prefix |
403 | account_closed | The account has been closed |
409 | idempotent_key_revoked | The key an earlier request with this Idempotency-Key created has been deleted since |
413 | payload_too_large | The body is larger than 64 KiB |
415 | unsupported_media_type | Content-Type is not application/json |
422 | unknown_bucket | bucketScope names a bucket you do not have. detail lists them |
422 | idempotency_key_mismatch | This Idempotency-Key was already used for a different body |
429 | rate_limited | Too many requests for this key or from this address. Retry-After gives the seconds until the next attempt is welcome |
500 | internal_error | Something went wrong on our side. A retry with the same Idempotency-Key is safe |
503 | buckets_unavailable | Your buckets could not be checked just now, so bucketScope could not be confirmed. Retry-After gives the seconds until a retry |
Examples
Each example creates a key limited to my-bucket and the prefix builds/ that can list, download
and upload, calling with the key in AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY. Each one also
retries a failed attempt with the same Idempotency-Key, so it never creates two keys.
- curl
- Python (requests)
- JavaScript (fetch)
IDEMPOTENCY_KEY=$(uuidgen)
curl -sS --retry 3 https://api.strg.com/v1/access-keys \
-H "Authorization: Bearer $AWS_ACCESS_KEY_ID:$AWS_SECRET_ACCESS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{
"label": "ci-uploads",
"permissions": ["objects:list", "objects:read", "objects:write"],
"bucketScope": ["my-bucket"],
"keyPrefix": "builds/"
}'
--retry resends the same Idempotency-Key after a timeout, a 429, a 500 or a 503, waiting
as long as Retry-After says. Running the curl command again with the same $IDEMPOTENCY_KEY
gets the same key back.
import os
import uuid
import requests
key_id = os.environ["AWS_ACCESS_KEY_ID"]
secret = os.environ["AWS_SECRET_ACCESS_KEY"]
body = {
"label": "ci-uploads",
"permissions": ["objects:list", "objects:read", "objects:write"],
"bucketScope": ["my-bucket"],
"keyPrefix": "builds/",
}
# One idempotency key for this new access key, reused on every attempt.
idempotency_key = str(uuid.uuid4())
response = None
for attempt in range(3):
try:
response = requests.post(
"https://api.strg.com/v1/access-keys",
headers={
"Authorization": f"Bearer {key_id}:{secret}",
"Idempotency-Key": idempotency_key,
},
json=body,
timeout=30,
)
except (requests.ConnectionError, requests.Timeout):
continue
if response.status_code < 500:
break
if response is None:
raise SystemExit("No answer from the API")
result = response.json()
if not response.ok:
raise SystemExit(f"{result['status']} {result['code']}: {result['detail']}")
# The secret is in this response only, so this is the moment to store it.
print(result["id"])
print(result["secretAccessKey"])
import { randomUUID } from "node:crypto";
const authorization = `Bearer ${process.env.AWS_ACCESS_KEY_ID}:${process.env.AWS_SECRET_ACCESS_KEY}`;
const body = JSON.stringify({
label: "ci-uploads",
permissions: ["objects:list", "objects:read", "objects:write"],
bucketScope: ["my-bucket"],
keyPrefix: "builds/",
});
// One idempotency key for this new access key, reused on every attempt.
const idempotencyKey = randomUUID();
let response;
for (let attempt = 0; attempt < 3; attempt++) {
try {
response = await fetch("https://api.strg.com/v1/access-keys", {
method: "POST",
headers: {
Authorization: authorization,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body,
});
} catch {
continue;
}
if (response.status < 500) break;
}
if (response === undefined) throw new Error("No answer from the API");
const result = await response.json();
if (!response.ok) throw new Error(`${result.status} ${result.code}: ${result.detail}`);
// The secret is in this response only, so this is the moment to store it.
console.log(result.id);
console.log(result.secretAccessKey);
fetch sends a string body as text/plain unless Content-Type is set, which is why the example
sets it - without it, this operation answers 415.
Related
- List access keys
- Get an access key
- Delete an access key
- REST API overview - authentication, errors, rate limits and the OpenAPI document
- Access keys - scope, object prefixes and every permission