Service Accounts
Service Accounts are first-class machine identities in Admiral. They are the recommended way to give automated systems (CI/CD pipelines, scripts, monitoring, integrations) access to the platform.
Unlike Personal API Tokens, a service account:
- Is independent of any human user, so it survives employee turnover.
- Authenticates using the industry-standard OAuth2 client credentials flow.
- Has access granted through policies, making its permissions explicit, scoped, and auditable.
- Supports multiple keys for zero-downtime rotation.
Core Concepts
| Concept | Description |
|---|---|
| Service Account | The machine identity. Belongs to one "home" organisation. |
| Key | A client_id / client_secret credential pair used to authenticate. A service account can hold multiple keys. |
| Grant | A single (relation, scope) permission, e.g. admin on a fleet. |
| Policy | A reusable bundle of grant statements you attach to a service account. |
| Home organisation | The organisation the service account lives in. It is always a member of this organisation. |
Every service account is automatically a member of its home organisation. This membership cannot be revoked while the service account exists.
Accessing Service Accounts
Service accounts are managed under IAM > Service Accounts.
You must be an admin, root, or owner of the organisation to create, delete, or modify a service account or its keys. Any member of the organisation can view service accounts and their granted access.
Creating a Service Account
The creation wizard walks through three steps.
Step 1 - Details
Provide:
- Name (required) - e.g.
CI/CD Pipeline - Description (optional) - e.g.
Used by GitHub Actions - Key name (optional) - a label for the first key
Step 2 - Credentials
On creation, Admiral mints the first key and returns:
- Client ID - e.g.
sa_... - Client Secret
The client secret is displayed a single time. Copy it, use "Download .env", or store it in your secret manager immediately. If lost, you must mint a new key.
Step 3 - Access
Choose how the service account gets its permissions:
- Full access (organisation admin) - Creates and attaches an
Administratorpolicy granting org-wide admin. The key can manage all fleets, configurations, rollouts, and devices. Use this only when you genuinely need a do-everything key. - Attach existing policies - Grant scoped, least-privilege access from policies you have already authored.
- No access yet - Configure access later from the service account's detail page.
Prefer attaching a narrowly-scoped policy over granting full access. You can always widen access later, but a tightly-scoped key limits the blast radius if it leaks.
Authentication
Service accounts use the OAuth2 client credentials grant. Your workload
exchanges its client_id and client_secret for a short-lived JWT access
token, then uses that token as a bearer token on API requests.
Most API requests also require an X-Organization-ID header to set the
organisation context for the call. Use the service account's home organisation
ID (or a nested organisation it has access to).
1. Exchange credentials for an access token
curl -X POST https://api.admrl.co/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET"
Response:
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InNhLWtleS0x...",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": 1780637469
}
| Field | Description |
|---|---|
access_token | A signed JWT. Use it as a bearer token on API requests. |
token_type | Always Bearer. |
expires_in | Lifetime in seconds (typically 3600). |
expires_at | Absolute Unix expiry timestamp. |
The access token is short-lived. Your long-lived secret is the key, which should be stored securely and never committed to source control. When the token expires, exchange the credentials again.
2. Call the API with the access token
Pass the token as a bearer token and set the organisation context:
curl -sS https://api.admrl.co/v1/devices \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "X-Organization-ID: $ORG_ID"
{
"code": 200,
"counts": { "offline": 0, "online": 0, "total": 0 },
"data": [],
"msg": "Success",
"pagination": { "limit": 10, "page": 1, "total": 0, "totalPages": 0 }
}
The X-Organization-ID header determines which organisation the request
operates against. A service account can only act in organisations it holds
access to. Its home organisation ID is also embedded in the token's home_org
claim.
Go Example
A minimal, idiomatic Go integration using
golang.org/x/oauth2/clientcredentials. The clientcredentials config handles
fetching and refreshing access tokens automatically. We wrap the HTTP client's
transport so the required X-Organization-ID header is added to every request.
package main
import (
"context"
"io"
"log"
"net/http"
"os"
"golang.org/x/oauth2"
"golang.org/x/oauth2/clientcredentials"
)
// orgHeaderTransport injects the organisation context header on every request.
type orgHeaderTransport struct {
orgID string
base http.RoundTripper
}
func (t *orgHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("X-Organization-ID", t.orgID)
return t.base.RoundTrip(req)
}
func main() {
baseURL := "https://api.admrl.co/v1"
orgID := os.Getenv("ORG_ID")
cfg := &clientcredentials.Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
TokenURL: baseURL + "/oauth/token",
}
// cfg.Client handles fetching and refreshing access tokens automatically.
// We wrap the underlying HTTP client's transport to also send the org header.
httpClient := &http.Client{
Transport: &orgHeaderTransport{orgID: orgID, base: http.DefaultTransport},
}
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, httpClient)
client := cfg.Client(ctx)
resp, err := client.Get(baseURL + "/devices")
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status %d: %s", resp.StatusCode, body)
}
log.Printf("Devices: %s", body)
}
Initialise the module, pull dependencies, set the environment variables, and run:
go mod init authtest
go mod tidy
export CLIENT_ID="sa_..."
export CLIENT_SECRET="..."
export ORG_ID="96c73a45-a70e-4ebd-b4a1-2b2892090088"
go run authtest.go
Alternative: only send the org header on API calls
In the example above, the oauth2.HTTPClient in the context is used for both
the token exchange and the API requests, so the X-Organization-ID header is
also sent on the token-fetch call. That is harmless, since the token endpoint
ignores it.
If you prefer the org header to appear only on API calls and not the token exchange, wrap the transport around the oauth2 transport instead:
func main() {
baseURL := "https://api.admrl.co/v1"
orgID := os.Getenv("ORG_ID")
cfg := &clientcredentials.Config{
ClientID: os.Getenv("CLIENT_ID"),
ClientSecret: os.Getenv("CLIENT_SECRET"),
TokenURL: baseURL + "/oauth/token",
}
// Token source handles fetch/refresh of access tokens.
ts := cfg.TokenSource(context.Background())
// oauth2.Transport adds the bearer token; our transport adds the org header.
client := &http.Client{
Transport: &orgHeaderTransport{
orgID: orgID,
base: &oauth2.Transport{Source: ts, Base: http.DefaultTransport},
},
}
resp, err := client.Get(baseURL + "/devices")
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
log.Fatalf("unexpected status %d: %s", resp.StatusCode, body)
}
log.Printf("Devices: %s", body)
}
Manual token exchange in Go
If you would rather not pull in the oauth2 library, exchange the token yourself and set both headers on each request:
package main
import (
"context"
"encoding/json"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type tokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
ExpiresAt int64 `json:"expires_at"`
}
const baseURL = "https://api.admrl.co/v1"
func fetchToken(ctx context.Context) (*tokenResponse, error) {
form := url.Values{}
form.Set("grant_type", "client_credentials")
form.Set("client_id", os.Getenv("CLIENT_ID"))
form.Set("client_secret", os.Getenv("CLIENT_SECRET"))
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
baseURL+"/oauth/token", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var tr tokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
return nil, err
}
return &tr, nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tok, err := fetchToken(ctx)
if err != nil {
log.Fatalf("token exchange failed: %v", err)
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/devices", nil)
req.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken)
req.Header.Set("X-Organization-ID", os.Getenv("ORG_ID"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
log.Printf("status: %s", resp.Status)
}
Managing Keys
Keys are managed on the service account's detail page under the Keys tab.
Minting a key
- Open the service account.
- Go to the Keys tab and click Add Key.
- Optionally set a name and an expiry.
- Copy the credentials shown - the secret is shown only once.
Zero-downtime rotation
A service account can hold multiple keys at once, which makes rotation safe:
- Create a new key on the service account.
- Update your systems to use the new client credentials.
- Verify the systems work with the new key.
- Revoke the old key.
If you revoke the only remaining key, the service account can no longer authenticate until a new key is minted.
Key fields
| Field | Description |
|---|---|
| Name | Optional label to identify the key's purpose. |
| Created | When the key was minted. |
| Last used | The most recent time the key authenticated. |
| Expires | When the key automatically stops working, or Never. |
Viewing Access
The Access tab on a service account shows a summary of every grant it holds, grouped by scope type.
- If the service account holds an organisation-wide role, a banner explains that resource-specific grants are already covered by it and do not need listing.
- The Advanced section allows adding a one-off manual grant. Prefer attaching a policy instead, since policies are reusable and auditable.
See IAM, Policies & Access for the full access model.
Deleting a Service Account
Deleting a service account is permanent and immediate. All of its keys and all of its access are removed. Any system using its credentials will stop working at once.
Only admins, roots, or owners of the home organisation can delete a service account.
Next Steps
- IAM, Policies & Access - author and attach policies
- Access Explorer - audit who can do what
- Webhooks - real-time notifications