Machine-to-Machine Authentication with Keycloak

Guilliano Molaire Guilliano Molaire Updated May 27, 2026 9 min read

Last updated: March 2026

Not every authentication scenario involves a human user. Microservices calling other microservices, cron jobs accessing APIs, IoT devices reporting data, CI/CD pipelines deploying infrastructure — these all require machine-to-machine (M2M) authentication. In OAuth 2.0, the Client Credentials Grant is the standard mechanism for this.

This guide covers how to configure Keycloak for M2M authentication, implement client credentials flows in Go, Python, and Node.js, and handle advanced scenarios like audience mapping and token exchange for downstream services.

When to Use Client Credentials

The client credentials grant is appropriate when:

  • No user is involved: A backend service needs to call another backend service autonomously.
  • The service has its own identity: The service authenticates as itself, not on behalf of a user.
  • You need scoped access: The service should only have access to specific resources defined by its client scopes.

Common use cases include:

  • Microservice-to-microservice API calls
  • Batch processing jobs that access secured APIs
  • Monitoring and health check services
  • Data synchronization between systems
  • CI/CD pipelines that interact with protected resources

Keycloak Configuration

Step 1: Create a Confidential Client

  1. Navigate to your realm in the Keycloak Admin Console.
  2. Go to Clients and click Create client.
  3. Configure the client:
    • Client ID: order-service (use a descriptive name for the service)
    • Client Protocol: openid-connect
  4. On the capability config screen:
    • Client Authentication: ON
    • Authorization: OFF (unless you need fine-grained authorization)
    • Standard Flow: OFF (no browser login needed)
    • Direct Access Grants: OFF
    • Service Accounts Roles: ON (this enables the client credentials grant)
  5. Save the client and note the Client Secret from the Credentials tab.

Step 2: Assign Roles to the Service Account

When a client uses the client credentials grant, Keycloak creates a service account user for that client. You assign roles to this service account to control what the service can access.

  1. Go to Clients > order-service > Service Account Roles.
  2. Click Assign role.
  3. Assign the appropriate realm roles or client roles.

For example, if your inventory-service exposes a client role called inventory:read, assign that role to the order-service‘s service account.

For details on designing role hierarchies, see RBAC in Keycloak and our deep dive into fine-grained authorization in Keycloak.

Step 3: Configure Client Scopes

Client scopes control which claims appear in the access token. For M2M scenarios, create dedicated scopes that map to specific API permissions.

  1. Go to Client Scopes and click Create client scope.
  2. Name it inventory-api and set the protocol to openid-connect.
  3. Under the scope’s Mappers tab, add the mappers you need (e.g., audience, roles).
  4. Go back to Clients > order-service > Client Scopes.
  5. Add inventory-api as a Default or Optional scope.

Step 4: Configure Audience Mapping

By default, the access token’s aud (audience) claim contains only the issuing client’s ID. If your resource server validates the audience, you need to add an audience mapper.

  1. Go to the client scope you created (e.g., inventory-api).
  2. Under Mappers, click Add mapper > By configuration.
  3. Select Audience.
  4. Set:
    • Name: inventory-service-audience
    • Included Client Audience: inventory-service
    • Add to access token: ON

Now, when order-service requests a token with the inventory-api scope, the access token will include inventory-service in its aud claim.

Obtaining a Token

The client credentials flow is a single HTTP POST request:

curl -X POST "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "grant_type=client_credentials" 
  -d "client_id=order-service" 
  -d "client_secret=your-client-secret" 
  -d "scope=inventory-api"

The response contains an access token (no refresh token is issued for client credentials):

{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "expires_in": 300,
  "token_type": "Bearer",
  "not-before-policy": 0,
  "scope": "inventory-api"
}

You can inspect the resulting token with the JWT Token Analyzer to verify the audience, roles, and scopes are correct.

Implementation Examples

Go Client

package main

import (
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "strings"
    "sync"
    "time"
)

type M2MClient struct {
    tokenURL     string
    clientID     string
    clientSecret string
    scopes       []string

    mu        sync.Mutex
    token     string
    expiresAt time.Time
}

type tokenResponse struct {
    AccessToken string `json:"access_token"`
    ExpiresIn   int    `json:"expires_in"`
    TokenType   string `json:"token_type"`
}

func NewM2MClient(keycloakURL, realm, clientID, clientSecret string, scopes []string) *M2MClient {
    return &M2MClient{
        tokenURL:     fmt.Sprintf("%s/realms/%s/protocol/openid-connect/token", keycloakURL, realm),
        clientID:     clientID,
        clientSecret: clientSecret,
        scopes:       scopes,
    }
}

func (c *M2MClient) GetToken() (string, error) {
    c.mu.Lock()
    defer c.mu.Unlock()

    // Return cached token if still valid (with 30s buffer)
    if c.token != "" && time.Now().Before(c.expiresAt.Add(-30*time.Second)) {
        return c.token, nil
    }

    data := url.Values{
        "grant_type":    {"client_credentials"},
        "client_id":     {c.clientID},
        "client_secret": {c.clientSecret},
    }
    if len(c.scopes) > 0 {
        data.Set("scope", strings.Join(c.scopes, " "))
    }

    resp, err := http.Post(c.tokenURL, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
    if err != nil {
        return "", fmt.Errorf("token request failed: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return "", fmt.Errorf("token request returned %d: %s", resp.StatusCode, body)
    }

    var tokenResp tokenResponse
    if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
        return "", fmt.Errorf("failed to decode token response: %w", err)
    }

    c.token = tokenResp.AccessToken
    c.expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)

    return c.token, nil
}

// AuthenticatedRequest makes an HTTP request with the M2M token
func (c *M2MClient) AuthenticatedRequest(method, url string, body io.Reader) (*http.Response, error) {
    token, err := c.GetToken()
    if err != nil {
        return nil, err
    }

    req, err := http.NewRequest(method, url, body)
    if err != nil {
        return nil, err
    }
    req.Header.Set("Authorization", "Bearer "+token)
    req.Header.Set("Content-Type", "application/json")

    return http.DefaultClient.Do(req)
}

func main() {
    client := NewM2MClient(
        "https://keycloak.example.com",
        "my-realm",
        "order-service",
        "your-client-secret",
        []string{"inventory-api"},
    )

    resp, err := client.AuthenticatedRequest("GET", "https://api.example.com/inventory/items", nil)
    if err != nil {
        fmt.Printf("Request failed: %vn", err)
        return
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)
    fmt.Printf("Response (%d): %sn", resp.StatusCode, body)
}

Python Client

import requests
import time
import threading

class M2MClient:
    def __init__(self, keycloak_url, realm, client_id, client_secret, scopes=None):
        self.token_url = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
        self.client_id = client_id
        self.client_secret = client_secret
        self.scopes = scopes or []
        self._token = None
        self._expires_at = 0
        self._lock = threading.Lock()

    def get_token(self):
        with self._lock:
            if self._token and time.time() < self._expires_at - 30:
                return self._token

            data = {
                "grant_type": "client_credentials",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
            }
            if self.scopes:
                data["scope"] = " ".join(self.scopes)

            response = requests.post(self.token_url, data=data)
            response.raise_for_status()

            token_data = response.json()
            self._token = token_data["access_token"]
            self._expires_at = time.time() + token_data["expires_in"]

            return self._token

    def authenticated_request(self, method, url, **kwargs):
        token = self.get_token()
        headers = kwargs.pop("headers", {})
        headers["Authorization"] = f"Bearer {token}"
        return requests.request(method, url, headers=headers, **kwargs)

# Usage
client = M2MClient(
    keycloak_url="https://keycloak.example.com",
    realm="my-realm",
    client_id="order-service",
    client_secret="your-client-secret",
    scopes=["inventory-api"],
)

response = client.authenticated_request("GET", "https://api.example.com/inventory/items")
print(response.json())

Node.js Client

import axios from 'axios';

class M2MClient {
  #token = null;
  #expiresAt = 0;

  constructor({ keycloakUrl, realm, clientId, clientSecret, scopes = [] }) {
    this.tokenUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.scopes = scopes;
  }

  async getToken() {
    if (this.#token && Date.now() < this.#expiresAt - 30_000) {
      return this.#token;
    }

    const params = new URLSearchParams({
      grant_type: 'client_credentials',
      client_id: this.clientId,
      client_secret: this.clientSecret,
    });

    if (this.scopes.length > 0) {
      params.set('scope', this.scopes.join(' '));
    }

    const response = await axios.post(this.tokenUrl, params, {
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    });

    this.#token = response.data.access_token;
    this.#expiresAt = Date.now() + response.data.expires_in * 1000;

    return this.#token;
  }

  async request(method, url, options = {}) {
    const token = await this.getToken();
    return axios({
      method,
      url,
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${token}`,
      },
    });
  }
}

// Usage
const client = new M2MClient({
  keycloakUrl: 'https://keycloak.example.com',
  realm: 'my-realm',
  clientId: 'order-service',
  clientSecret: 'your-client-secret',
  scopes: ['inventory-api'],
});

const response = await client.request('GET', 'https://api.example.com/inventory/items');
console.log(response.data);

Token Exchange for Downstream Services

In a microservices architecture, a service might need to call another service on behalf of itself but with a token scoped to the downstream service’s audience. Keycloak supports Token Exchange for this scenario.

Enable token exchange in your realm:

  1. In the Keycloak Admin Console, go to Realm Settings > General.
  2. Ensure the Token Exchange feature is enabled (it is a preview feature and must be enabled via the --features=token-exchange server flag).

Exchange a token for a different audience:

curl -X POST "https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token" 
  -H "Content-Type: application/x-www-form-urlencoded" 
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" 
  -d "client_id=order-service" 
  -d "client_secret=your-client-secret" 
  -d "subject_token=$ACCESS_TOKEN" 
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" 
  -d "audience=payment-service" 
  -d "requested_token_type=urn:ietf:params:oauth:token-type:access_token"

This returns a new access token with payment-service as the audience, allowing fine-grained access control between services.

Validating M2M Tokens on the Resource Server

The resource server (the service receiving M2M calls) must validate the access token. Here is a minimal Express.js middleware example:

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

const client = jwksClient({
  jwksUri: 'https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs',
  cache: true,
  rateLimit: true,
});

function getKey(header, callback) {
  client.getSigningKey(header.kid, (err, key) => {
    if (err) return callback(err);
    callback(null, key.getPublicKey());
  });
}

function requireAuth(requiredRoles = []) {
  return (req, res, next) => {
    const authHeader = req.headers.authorization;
    if (!authHeader?.startsWith('Bearer ')) {
      return res.status(401).json({ error: 'Missing token' });
    }

    const token = authHeader.split(' ')[1];

    jwt.verify(
      token,
      getKey,
      {
        algorithms: ['RS256'],
        issuer: 'https://keycloak.example.com/realms/my-realm',
        audience: 'inventory-service',
      },
      (err, decoded) => {
        if (err) {
          return res.status(401).json({ error: 'Invalid token' });
        }

        // Check roles
        const tokenRoles = decoded.resource_access?.['inventory-service']?.roles || [];
        const hasAllRoles = requiredRoles.every((role) => tokenRoles.includes(role));

        if (!hasAllRoles) {
          return res.status(403).json({ error: 'Insufficient permissions' });
        }

        req.serviceAccount = decoded;
        next();
      }
    );
  };
}

// Usage in routes
app.get('/inventory/items', requireAuth(['inventory:read']), (req, res) => {
  // Handle the request
});

Security Best Practices for M2M Authentication

  1. Rotate client secrets regularly. Use Keycloak’s Admin API to programmatically rotate secrets without downtime.

  2. Use short-lived access tokens. For M2M, a 5-minute access token lifespan is sufficient. Services should cache and refresh tokens proactively.

  3. Scope access narrowly. Create specific client scopes for each API surface. Do not give a service more access than it needs.

  4. Use mutual TLS (mTLS) for sensitive environments. Keycloak supports client certificate-bound access tokens, adding a layer of transport security.

  5. Monitor service account activity. Enable audit logging to track which service accounts are requesting tokens and how often.

  6. Never embed secrets in code. Use environment variables, Kubernetes secrets, or a vault service.

For more on securing microservices architectures, see our guide on how Keycloak secures Node.js microservices.

M2M Authentication with SCIM

If your services need to provision or synchronize users across identity boundaries, M2M tokens are commonly used with SCIM endpoints. Keycloak’s service accounts can authenticate to SCIM APIs to automate user lifecycle management.

For more on SCIM integration, see SCIM provisioning with Keycloak and try the SCIM Endpoint Tester to validate your SCIM implementation.

Infrastructure Considerations

When deploying M2M authentication at scale:

  • Token caching is critical. Without caching, every API call triggers a token request, adding latency and load to Keycloak.
  • Connection pooling: Use HTTP connection pooling in your M2M clients to reduce TLS handshake overhead.
  • Circuit breakers: If Keycloak is temporarily unavailable, use cached tokens and implement circuit breakers to avoid cascading failures.
  • Observability: Track token request latency, cache hit rates, and refresh failures. These metrics surface authentication infrastructure issues before they affect users. See Insights for monitoring capabilities.

For managed Keycloak that handles high-throughput M2M workloads with built-in high availability, check out Skycloak’s hosting plans and pricing.

Wrapping Up

Machine-to-machine authentication with Keycloak is straightforward once you understand the client credentials grant flow. The key decisions are around client scope design, audience mapping, and token caching strategy.

All three implementation examples above follow the same pattern: obtain a token, cache it, refresh before expiry, and attach it to outgoing requests. This pattern works regardless of the language or framework you use.

If managing Keycloak infrastructure alongside your M2M authentication needs feels like overhead, Skycloak provides fully managed Keycloak hosting with automated scaling, monitoring, and enterprise SLAs — so your services stay authenticated without operational burden.

Guilliano Molaire
Written by Guilliano Molaire Founder

Guilliano is the founder of Skycloak and a cloud infrastructure specialist with deep expertise in product development and scaling SaaS products. He discovered Keycloak while consulting on enterprise IAM and built Skycloak to make managed Keycloak accessible to teams of every size.

Ready to simplify your authentication?

Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.

© 2026 Skycloak. All Rights Reserved. Design by Yasser Soliman