Machine-to-Machine Authentication with Keycloak
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
- Navigate to your realm in the Keycloak Admin Console.
- Go to Clients and click Create client.
- Configure the client:
- Client ID:
order-service(use a descriptive name for the service) - Client Protocol:
openid-connect
- Client ID:
- 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)
- 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.
- Go to Clients > order-service > Service Account Roles.
- Click Assign role.
- 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.
- Go to Client Scopes and click Create client scope.
- Name it
inventory-apiand set the protocol toopenid-connect. - Under the scope’s Mappers tab, add the mappers you need (e.g., audience, roles).
- Go back to Clients > order-service > Client Scopes.
- Add
inventory-apias 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.
- Go to the client scope you created (e.g.,
inventory-api). - Under Mappers, click Add mapper > By configuration.
- Select Audience.
- Set:
- Name:
inventory-service-audience - Included Client Audience:
inventory-service - Add to access token: ON
- Name:
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:
- In the Keycloak Admin Console, go to Realm Settings > General.
- Ensure the Token Exchange feature is enabled (it is a preview feature and must be enabled via the
--features=token-exchangeserver 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
-
Rotate client secrets regularly. Use Keycloak’s Admin API to programmatically rotate secrets without downtime.
-
Use short-lived access tokens. For M2M, a 5-minute access token lifespan is sufficient. Services should cache and refresh tokens proactively.
-
Scope access narrowly. Create specific client scopes for each API surface. Do not give a service more access than it needs.
-
Use mutual TLS (mTLS) for sensitive environments. Keycloak supports client certificate-bound access tokens, adding a layer of transport security.
-
Monitor service account activity. Enable audit logging to track which service accounts are requesting tokens and how often.
-
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.
Ready to simplify your authentication?
Deploy production-ready Keycloak in minutes. Unlimited users, flat pricing, no SSO tax.