diff --git a/administration/authentication/api_key_rotation.md b/administration/authentication/api_key_rotation.md
index 5e46d20..ee69b17 100644
--- a/administration/authentication/api_key_rotation.md
+++ b/administration/authentication/api_key_rotation.md
@@ -11,11 +11,16 @@ Rotating API keys regularly limits the blast radius of a leaked credential. Kosl
When you rotate a service account API key, Kosli:
1. Generates a new API key and returns its value once.
-2. Keeps the old key valid for a configurable grace period (default: **24 hours**).
-3. Automatically revokes the old key when the grace period expires.
+2. Sets the new key's expiry to the **rotated key's current expiry** unless you pass `--expires-at` (CLI) or `expires_at` (API), bounded by the server-side **maximum lifetime of 365 days from creation**.
+3. Keeps the old key valid for a configurable grace period (default: **24 hours**).
+4. Automatically revokes the old key when the grace period expires.
Choose a grace period that fits your deployment cadence — long enough to roll the new key out to every consumer, short enough to limit exposure.
+
+Rotation on its own does not extend the credential. If the rotated key was already close to its expiry, the new key expires at the same moment unless you pass an explicit `--expires-at` (up to the 365-day cap).
+
+
## Where next
- [Rotating API keys (tutorial)](/tutorials/rotating_api_keys) — step-by-step walkthrough in the web app and via the API.
diff --git a/terraform-reference/resources/service_account_api_key.mdx b/terraform-reference/resources/service_account_api_key.mdx
index 7058966..2efc0fb 100644
--- a/terraform-reference/resources/service_account_api_key.mdx
+++ b/terraform-reference/resources/service_account_api_key.mdx
@@ -32,17 +32,17 @@ resource "kosli_service_account" "ci" {
privilege = "member"
}
-# A non-expiring API key
+# API key with the server-side maximum lifetime (365 days from creation)
resource "kosli_service_account_api_key" "ci_key" {
service_account_name = kosli_service_account.ci.name
description = "Production CI key"
}
-# An API key that expires (RFC3339 timestamp)
+# API key with an explicit expiry (RFC3339 timestamp)
resource "kosli_service_account_api_key" "ci_key_expiring" {
service_account_name = kosli_service_account.ci.name
description = "Temporary CI key"
- expires_at = "2100-01-01T00:00:00Z"
+ expires_at = "2026-12-31T00:00:00Z"
}
# The raw key is only available on creation and is sensitive
@@ -54,12 +54,21 @@ output "ci_api_key" {
## Expiry
-The `expires_at` attribute is an RFC3339 timestamp, e.g. `2100-01-01T00:00:00Z` (offsets such as `+01:00` are accepted and normalized to UTC). Omit it for a key that never expires. The timestamp must not be in the past.
+Every API key expires. The server caps each key's lifetime at **365 days from creation** and enforces the cap regardless of the client.
+
+The `expires_at` attribute is an RFC3339 timestamp, e.g. `2026-12-31T00:00:00Z` (offsets such as `+01:00` are accepted and normalized to UTC). Behavior:
+
+- **Omitted** → the server applies the maximum 365-day expiry.
+- **Set within 365 days** → honored as-is.
+- **Set beyond 365 days** → silently shortened to the 365-day cap. Terraform then reports the result as an inconsistent result after apply and marks the resource tainted so the next apply revokes and recreates it. Choose an `expires_at` within the 365-day window to avoid this.
+- **In the past** → rejected server-side at apply time.
+
+Keys that never expire can no longer be created.
To derive dates dynamically, use Terraform's built-in functions, e.g. `timeadd("2026-01-01T00:00:00Z", "8760h")`.
-All timestamps (`expires_at`, `created_at`, `last_used_at`) are RFC3339 UTC strings. `last_used_at` is null for a key that has never been used; `expires_at` is null for a key that never expires.
+All timestamps (`expires_at`, `created_at`, `last_used_at`) are RFC3339 UTC strings. `last_used_at` is null for a key that has never been used.
## Import
@@ -84,7 +93,7 @@ Because the raw key value is only returned at creation time, the `key` attribute
### Optional
-- `expires_at` (String) RFC3339 timestamp at which the key expires, e.g. `2100-01-01T00:00:00Z` (offsets allowed; whole seconds only). Omit for a key that never expires. Must not be in the past (validated server-side at apply time). Changing this forces creation of a new key. Removing a previously set value from configuration leaves the existing expiry unchanged; to get a non-expiring key again, the key must be recreated (e.g. via `terraform taint` or by changing another argument).
+- `expires_at` (String) RFC3339 timestamp at which the key expires, e.g. `2026-12-31T00:00:00Z` (offsets allowed; whole seconds only). Must not be in the past (validated server-side at apply time) and must be no more than 365 days out: the server caps every key's lifetime and silently shortens a longer expiry, which Terraform then reports as an inconsistent result after apply. Omit to let the server apply the maximum 365-day expiry; keys that never expire can no longer be created. Changing this forces creation of a new key. Removing a previously set value from configuration leaves the existing expiry unchanged.
### Read-only
diff --git a/tutorials/rotating_api_keys.mdx b/tutorials/rotating_api_keys.mdx
index 5166058..45a1a77 100644
--- a/tutorials/rotating_api_keys.mdx
+++ b/tutorials/rotating_api_keys.mdx
@@ -20,11 +20,16 @@ Kosli never stores your API token in plain text. Only a cryptographic hash of th
When you rotate a service account API key, Kosli:
1. Generates a **new** API key immediately and returns its value **once**.
-2. Keeps the **old** key valid for a configurable grace period (default: **24 hours**).
-3. Automatically revokes the old key when the grace period expires.
+2. Sets the new key's expiry to the **rotated key's current expiry** unless you pass `--expires-at` (CLI) or `expires_at` (API), bounded by the server-side **maximum lifetime of 365 days from creation**.
+3. Keeps the **old** key valid for a configurable grace period (default: **24 hours**).
+4. Automatically revokes the old key when the grace period expires.
The grace period lets you roll the new key out to all consumers without an interruption in service. Choose a window that matches your deployment cadence — short enough to limit exposure, long enough to update every dependent system.
+
+Rotation on its own does not extend the credential. If the rotated key is already close to its expiry (for example, most of the way through the 365-day cap), the new key inherits that expiry and dies at the same moment — the exact failure rotation is supposed to prevent. Pass `--expires-at` to reset the clock, up to the 365-day cap.
+
+
## Rotate a key
Choose the interface that best fits your workflow. All three trigger the same rotation flow described above.
@@ -46,10 +51,13 @@ Choose the interface that best fits your workflow. All three trigger the same ro
kosli rotate api-key <> \
--service-account <> \
--grace-period-hours 24 \
+ --expires-at 2026-12-31 \
--api-token "$KOSLI_ADMIN_TOKEN" \
--org "$ORG"
```
+ `--expires-at` accepts an epoch timestamp, `YYYY-MM-DD`, `YYYY-MM-DD HH:MM:SS`, or an RFC3339 timestamp, and is capped at 365 days from creation. Omit it to inherit the rotated key's expiry — combine that with a rotation cadence well inside the 365-day cap, or pass `--expires-at` to reset the clock.
+
Rotate multiple keys for the same service account in one call by passing additional key IDs. When `--grace-period-hours` is omitted, the server-side default grace period applies:
```shell
@@ -78,10 +86,12 @@ Choose the interface that best fits your workflow. All three trigger the same ro
curl -X POST \
-H "Authorization: Bearer <>" \
-H "Content-Type: application/json" \
- -d '{"grace_period_hours": 24}' \
+ -d '{"grace_period_hours": 24, "expires_at": 1798761600}' \
https://app.kosli.com/api/v2/service-accounts/<>/<>/api-keys/<>/rotate
```
+ `expires_at` is an epoch timestamp for the new key's expiry. Omit it to inherit the rotated key's expiry. The value is capped at 365 days from creation.
+
The response contains the new API key value. Capture it directly into your secrets store:
```shell