Back to Blog
Security

Keycloak Signed JWT Authentication for Machine-to-Machine Communication

October 22, 2025·11 min read
KeycloakSecurityMicroservicesJWT
Keycloak Signed JWT Authentication for Machine-to-Machine Communication

Keycloak Signed JWT Authentication for Machine-to-Machine Communication

If you've ever dealt with service-to-service authentication in a microservices architecture, you know the pain of managing shared secrets, rotating passwords, and worrying about credentials being intercepted. There's a better way: signed JSON Web Tokens (sJWTs).

While there's an increase in setup complexity compared to simple username/password auth, sJWTs offer some serious advantages for machine-to-machine (M2M) communication. Instead of transmitting credentials with every request, signed JWTs use asymmetric cryptography where the client proves it has a private key without ever transmitting it. This eliminates the risk of credential interception during transit and reduces the attack surface by avoiding shared secrets.

What makes JWTs particularly powerful is that they're stateless and self-contained. Each token carries claims about the client's identity and permissions that any service can verify independently using just the public key. This means no centralized session storage, better scalability, and services that can authenticate requests without constantly pinging your auth server. Plus, you get fine-grained access control through scopes and claims, built-in expiration and easy key rotation without the nightmare of coordinating password resets across your entire infrastructure.

In this guide, we'll walk through setting up Keycloak (an open-source identity and access management platform) to issue access tokens using sJWT authentication. We'll cover the complete flow with practical examples in both Bash and Node.js, so you can see exactly how it works in practice.

What You'll Need

Before we dive in, make sure you have:

  • Docker and Docker Compose installed on your machine
  • OpenSSL for generating certificates (usually pre-installed on macOS/Linux)
  • Node.js (if you want to follow the Node.js examples)
  • jq command-line JSON processor (for Bash examples)

How It Works: The Big Picture

Before we get into the weeds of configuration, let's understand what's happening here. The authentication flow has three main players:

  1. Machine A (Client): Needs to call an API on Machine B
  2. Keycloak: Acts as the identity provider and token issuer
  3. Machine B (Server): Hosts the protected API

Setting Up Keycloak

We'll be using HTTP for this guide to keep things simple, but in production you'd definitely want HTTPS. If you're curious about the HTTPS setup (including mutual TLS), check out the https directory in the GitHub repo.

Generating Certificates

Signing Certificates

First things first: we need to create public/private key pairs for our machines. Think of these like a lock and key—the public certificate (lock) gets shared with Keycloak so it can verify tokens, while the private key stays secret on the client machine and is used to sign the JWTs.

We'll create certificates for both Machine A and Machine B. Note that we're setting these to expire in 5 days (-days 5)—in production, you'd want a much longer expiration period.

openssl req -x509 -sha256 -nodes  \
  -newkey rsa:2048                \
  -keyout machine-a-signing.key   \
  -out machine-a-signing.crt      \
  -days 5                         \
  -subj '/CN=machine-a'

openssl req -x509 -sha256 -nodes  \
  -newkey rsa:2048                \
  -keyout machine-b-signing.key   \
  -out machine-b-signing.crt      \
  -days 5                         \
  -subj '/CN=machine-b'

Keycloak Configuration

Keycloak lets us import a realm configuration on startup, which is perfect for getting everything set up automatically. Below is our realm config for sjwt-example with two clients: machine-a and machine-b.

A few things to note about this configuration:

  • Service accounts are enabled: This allows the clients to authenticate as themselves (not on behalf of a user)
  • Client authentication type is client-jwt: This is the sJWT authentication we're setting up
  • All other auth flows are disabled: We only want sJWT authentication, nothing else
  • The {{CERT-CLIENT-<A|B>}} placeholders: These will be replaced with the actual certificates by our init container.
{
  "realm": "sjwt-example",
  "enabled" : true,
  "notBefore" : 0,
  "roles": {},
  "clients": [
    {
      "clientId" : "machine-a",
      "clientAuthenticatorType" : "client-jwt",
      "protocol" : "openid-connect",
      "standardFlowEnabled" : false,
      "implicitFlowEnabled" : false,
      "directAccessGrantsEnabled" : false,
      "serviceAccountsEnabled" : true,
      "publicClient" : false,
      "attributes" : {
        "token.endpoint.auth.signing.alg" : "RS256",
        "jwt.credential.certificate" : "{{CERT-CLIENT-A}}"
      },
      "defaultClientScopes" : [  "service_account", "profile", "basic", "email" ],
      "optionalClientScopes" : [ "address" ]
    },
    {
      "clientId" : "machine-b",
      "clientAuthenticatorType" : "client-jwt",
      "protocol" : "openid-connect",
      "standardFlowEnabled" : false,
      "implicitFlowEnabled" : false,
      "directAccessGrantsEnabled" : false,
      "serviceAccountsEnabled" : true,
      "publicClient" : false,
      "attributes" : {
        "token.endpoint.auth.signing.alg" : "RS256",
        "jwt.credential.certificate" : "{{CERT-CLIENT-B}}"
      },
      "defaultClientScopes" : [  "service_account", "profile", "basic", "email" ],
      "optionalClientScopes" : [ "address" ]
    }
  ]
}

Deploying Keycloak

We need to inject our public certificates into the Keycloak configuration before it starts up. We'll use an init container pattern (borrowed from Kubernetes) where a simple Alpine container runs first, does some setup work, and then exits before Keycloak starts.

Create a configure.sh file in the kc directory. This script extracts the base64-encoded certificate content (the part between the BEGIN and END markers) and injects it into our realm configuration:

#!/usr/bin/env sh

CERT_CLIENT_A_B64=$(awk                                                         \
  '/-----BEGIN CERTIFICATE-----/{f=1;next} /-----END CERTIFICATE-----/{f=0} f'  \
  /certs/machine-a-signing.crt |                                                \
  tr -d '\n')
CERT_CLIENT_B_B64=$(awk                                                         \
  '/-----BEGIN CERTIFICATE-----/{f=1;next} /-----END CERTIFICATE-----/{f=0} f'  \
  /certs/machine-b-signing.crt |                                                \
  tr -d '\n')

sed                                               \
  -e "s@{{CERT-CLIENT-A}}@${CERT_CLIENT_A_B64}@g" \
  -e "s@{{CERT-CLIENT-B}}@${CERT_CLIENT_B_B64}@g" \
  /config/sjwt-example.json                       \
  > /output/sjwt-example.json

Now let's set up the directory structure:

  1. Create a kc directory and save the realm config from above as sjwt-example.json
  2. Create a certs directory and move all the certificates we generated earlier into it
  3. Create your docker-compose.yml with the configuration below

The magic here is in the depends_on configuration—Keycloak won't start until the init container successfully completes. The init container populates the config file and places it in a shared volume that Keycloak can read from:

services:
  init-keycloak-conf:
    image: alpine:3.22.2
    command: /config/configure.sh
    volumes:
      - ./certs:/certs:ro
      - ./kc:/config:ro
      - kc-conf:/output
  keycloak:
    image: quay.io/keycloak/keycloak:26.4
    depends_on:
      init-keycloak-conf:
        condition: service_completed_successfully
    environment:
      KC_BOOTSTRAP_ADMIN_USERNAME: admin
      KC_BOOTSTRAP_ADMIN_PASSWORD: change_me
    volumes:
      - kc-conf:/opt/keycloak/data/import:ro
    command: start-dev --import-realm
    ports:
      - "8080:8080"
volumes:
  kc-conf:

Fire it up with docker compose up -d. Give it a minute to start, then you can access the Keycloak admin portal at http://localhost:8080. Log in with the credentials we set in the compose file (username: admin, password: change_me—and yes, please change that in production!).

The Authentication Flow in Action

Machine A (Client): Getting an Access Token

To get an access token from Keycloak, Machine A needs to create and sign its own JWT (the client assertion) to prove its identity.

Quick JWT refresher: every JWT has three parts separated by dots (.):

  1. Header: Specifies the token type and signing algorithm (we're using RS256)
  2. Payload: Contains claims that identify the client (issuer, audience, expiration, etc.)
  3. Signature: A cryptographic signature of the header and payload, proving the token hasn't been tampered with

Let's see how to create this in both Bash and Node.js.

Bash Implementation

This Bash script shows the JWT creation process step-by-step. It's a bit verbose, but that's actually helpful for understanding what's happening under the hood:

#!/usr/bin/env bash
set -e

# Path to our private key
client_signing_key=./certs/machine-a-signing.key

# Helper function: JWTs use base64url encoding (not standard base64)
# This converts + to -, / to _, and removes padding (=)
function base64url_encode() {
  openssl base64 -e -A |  \
    tr '+/' '-_' |        \
    tr -d '='
}

# Step 1: Create the header and base64url encode it
header=$(cat << END
{
  "alg":"RS256",
  "typ":"JWT"
}
END
)
header_enc=$(echo $header | jq -c | base64url_encode)

# Step 2: Create the payload with required claims
current_time=$(date +%s)
expiration_time=$(($current_time + 60)) # Token valid for 60 seconds
issuer='machine-a'  # Who's creating this token
audience='http://keycloak:8080/realms/sjwt-example/protocol/openid-connect/token'  # Who it's for
payload=$(cat << END
{
  "iss": "$issuer",
  "aud": "$audience",
  "sub": "$issuer",
  "jti": "$(uuidgen)-client",
  "iat": $current_time,
  "exp": $expiration_time
}
END
)
payload_enc=$(echo $payload | jq -c | base64url_encode)

# Step 3: Create the signature by signing the header and payload with our private key
signature_enc=$(echo -n "$header_enc.$payload_enc" | openssl dgst -sha256 -binary -sign $client_signing_key | base64url_encode)

# Step 4: Combine all three parts to create the final JWT
sjwt="$header_enc.$payload_enc.$signature_enc"

Node.js Implementation

The Node.js version is much more concise thanks to the jsonwebtoken package, which handles all the encoding and signing for us:

import * as path from 'path';
import * as fs from 'fs';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';

// Keycloak config
const realm = 'sjwt-example'
const issuer = `http://keycloak:8080/realms/${realm}`;
const clientId = 'machine-a';

// JWT config
// Path to our private key
const clientSigningKey = fs.readFileSync(path.resolve(import.meta.dirname, '../certs/machine-a-signing.key'));

function createClientAssertion({ clientId, audience, clientSigningKey }) {
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign(
    {
      iss: clientId,        // Issuer: who created this token
      aud: audience,        // Audience: who this token is for
      sub: clientId,        // Subject: who this token is about (same as issuer for client credentials)
      jti: uuidv4(),        // JWT ID: unique identifier for this token
      iat: now,             // Issued at: when this token was created
      exp: now + 60,        // Expiration: token valid for 60 seconds
    },
    clientSigningKey,
    { algorithm: 'RS256' }  // Use RSA with SHA-256
  );
}

const sjwt = createClientAssertion({ clientId: clientId, audience: issuer, clientSigningKey: clientSigningKey })

Exchanging the Client Assertion for an Access Token

Now that we have our signed JWT (client assertion), we can exchange it for an access token from Keycloak.

One quirk: we need to send the request to keycloak (not localhost) to ensure the audience claim in the returned token is correct. Since we're running locally, we'll use some DNS trickery to make this work.

Bash Implementation

We'll use curl's --resolve flag to tell it that keycloak should resolve to 127.0.0.1:

token=$(curl -fsS                                                                   \
  --resolve 'keycloak:8080:127.0.0.1'                                               \
  -d 'client_id=machine-a'                                                          \
  -d 'grant_type=client_credentials'                                                \
  -d 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer' \
  -d 'client_assertion='$sjwt''                                                     \
  -d 'scope=openid'                                                                 \
  'http://keycloak:8080/realms/sjwt-example/protocol/openid-connect/token')

# Extract the access token from the response
token_type=$(echo $token | jq -r '.token_type')
access_token=$(echo $token | jq -r '.access_token')

Node.js Implementation

For Node.js, we'll create a custom HTTP agent that overrides DNS resolution:

import http from 'http';
import dns from 'dns';
import fetch from 'node-fetch'; // not the global fetch because undici does not support https.Agent

const customDnsMap = {
  'keycloak': '127.0.0.1',
};

// Create a custom HTTP agent that resolves 'keycloak' to localhost
export const agent = new http.Agent({
  lookup: (hostname, options, callback) => {
    const ip = customDnsMap[hostname]
    if (ip) {
      // Override 'keycloak' to 127.0.0.1
      callback(null, [{"address":ip,"family":4}], undefined);
    } else {
      // For everything else, use normal DNS
      dns.lookup(hostname, options, callback);
    }
  },
});

const tokenUri = `${issuer}/protocol/openid-connect/token`;

async function getAccessToken(token) {
  const params = new URLSearchParams({
    client_id: serverClientId,
    grant_type: 'client_credentials',
    client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
    client_assertion: token,
    scope: 'openid',
  });
  const response = await fetch(
    tokenUri,
    {
      agent: agent,
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: params.toString()
    }
  );
  const data = await response.json();
  return data;
}

const token = await getAccessToken(sjwt)
const accessToken = token.access_token

Perfect! Now you have an access token. Use it in the Authorization header when calling protected APIs:

curl -H "Authorization: Bearer $access_token" http://machine-b:3000/api/protected

Machine B (Server): Validating Access Tokens

On the receiving end, Machine B needs to validate the access token. You've got two options here: offline or online verification.

Offline Verification (Recommended for Most Cases)

Offline verification is faster and doesn't hammer your auth server with requests. The server checks:

  • All required claims are present in the token
  • The token hasn't expired
  • The signature is valid (using the public key we injected in Keycloak)

You can grab Keycloak's public keys from the /realms/<realm>/protocol/openid-connect/certs endpoint. Most JWT libraries will handle this for you automatically.

This works great for short-lived tokens (which is best practice anyway). The only downside? You can't detect if a token has been revoked until it expires naturally.

Online Verification (When You Need It)

Sometimes you need to check if a token has been revoked, maybe a service account was compromised, or you're dealing with longer-lived tokens. In these cases, you'll want online verification via Keycloak's introspection endpoint.

Here's how Machine B would verify a token online. First, it creates its own client assertion (just like Machine A did), then sends both that and the access token to Keycloak for verification:

# Path to our private key
server_signing_key=./certs/machine-b-signing.key

# Header
server_header=$(cat << END
{
  "alg":"RS256",
  "typ":"JWT"
}
END
)
server_header_enc=$(echo $server_header | jq -c | base64url_encode)

# Payload
server_current_time=$(date +%s)
server_expiration_time=$(($server_current_time + 300)) # 5 mins from now
server_issuer='machine-b'
server_audience='http://keycloak:8080/realms/sjwt-example'
server_payload=$(cat << END
{
  "iss": "$server_issuer",
  "aud": "$server_audience",
  "sub": "$server_issuer",
  "jti": "$(uuidgen)-server",
  "iat": $server_current_time,
  "exp": $server_expiration_time
}
END
)
server_payload_enc=$(echo $server_payload | jq -c | base64url_encode)

# Signature
server_signature_enc=$(echo -n "$server_header_enc.$server_payload_enc" | openssl dgst -sha256 -binary -sign $server_signing_key | base64url_encode)

# sJWT
server_sjwt="$server_header_enc.$server_payload_enc.$server_signature_enc"


# Send both the server's client assertion and the access token to verify
introspection_response=$(curl -fsS                                                      \
  --resolve 'keycloak:8080:127.0.0.1'                                                   \
  -d 'client_id=machine-b'                                                              \
  -d 'client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer'     \
  -d 'client_assertion='$server_sjwt''                                                  \
  -d 'token='$access_token''                                                            \
  -d 'token_type_hint=access_token'                                                     \
  'http://keycloak:8080/realms/sjwt-example/protocol/openid-connect/token/introspect')

# Check if the token is active
is_active=$(echo $introspection_response | jq -r '.active')
if [ "$is_active" = "true" ]; then
  echo "Token is valid!"
else
  echo "Token is invalid or revoked"
fi

Wrapping Up

And there you have it—a complete setup for machine-to-machine authentication using Keycloak and signed JWTs. While it's definitely more setup than basic auth, the security and scalability benefits are worth it, especially as your system grows.

Key Takeaways

  • sJWTs eliminate shared secrets: Private keys never leave the client machine
  • Stateless verification: Services can validate tokens without calling Keycloak every time
  • Better scalability: No session storage, no constant auth server requests
  • Fine-grained control: Use scopes and claims to control exactly what each service can do

Next Steps

Want to take this further? Here are some ideas:

  1. Add HTTPS: Check out the HTTPS examples in the repo for production-ready security
  2. Implement token caching: Cache access tokens until they expire to reduce requests to Keycloak
  3. Set up key rotation: Implement a process for rotating certificates without downtime
  4. Add monitoring: Track token issuance, validation failures, and expiration patterns
  5. Explore scopes: Use Keycloak's scope system to implement fine-grained permissions

The complete working example is available on GitHub, so feel free to clone it and experiment. Happy authenticating!