Skip to main content

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 roles or policies, making its permissions explicit, scoped, and auditable.
  • Supports multiple keys for zero-downtime rotation.

Core Concepts​

ConceptDescription
Service AccountThe machine identity. Belongs to one "home" organisation.
KeyA client_id / client_secret credential pair used to authenticate. A service account can hold multiple keys.
RoleA reusable bundle of capabilities assigned to the service account at organisation, fleet, or device scope.
PolicyAn advanced reusable bundle of grant statements attached to a service account.
Home organisationThe organisation the service account belongs to.

Unlike human users, service accounts are not organisation members. They hold affiliate status and receive no implicit access. A service account has only the specific capabilities explicitly granted to it through Roles & Permissions or IAM Policies.

Accessing Service Accounts​

Service accounts are managed under Access > Service accounts (https://app.admrl.co/access/service-accounts).

You must hold the Manage access capability (included in the Administrator and Owner roles) to create, delete, or modify a service account or its keys. Any member of the organisation can view service accounts and their assigned 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
This is shown only once

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 Administrator policy granting organisation-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 access from IAM policies you have already authored.
  • No access yet - Configure access later from the service account's detail page.

To give the service account a Role, open Access > Roles, choose the role, click Assign role, and select the service account. Role assignment is not part of this wizard.

Least privilege

Prefer a narrowly-scoped role or policy over 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
}
FieldDescription
access_tokenA signed JWT. Use it as a bearer token on API requests.
token_typeAlways Bearer.
expires_inLifetime in seconds (typically 3600).
expires_atAbsolute 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 }
}
Organisation context

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​

  1. Open the service account.
  2. Go to the Keys tab and click Add Key.
  3. Optionally set a name and an expiry.
  4. 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:

  1. Create a new key on the service account.
  2. Update your systems to use the new client credentials.
  3. Verify the systems work with the new key.
  4. Revoke the old key.
Last key

If you revoke the only remaining key, the service account can no longer authenticate until a new key is minted.

Key fields​

FieldDescription
NameOptional label to identify the key's purpose.
CreatedWhen the key was minted.
Last usedThe most recent time the key authenticated.
ExpiresWhen the key automatically stops working, or Never.

Viewing Access​

The Access tab on a service account shows a summary of the grants it holds, grouped by scope type.

  • If the service account holds an organisation-wide grant, a banner explains that resource-specific grants are already covered by it and do not need listing.
  • The Advanced section allows a one-off manual grant. Prefer attaching a policy instead, since policies are reusable and auditable.

See Roles & Permissions and Users, Groups & 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.

Deleting a service account requires the Manage access capability (included in the Administrator and Owner roles).

Next Steps​