Secure Server-to-Server Authentication with RSA Signatures

Feb 23, 2026 4 min read

How I eliminated shared secrets between services using asymmetric cryptography

TL;DR:

  • I implemented RSA signature-based authentication between a CMS frontend (PHP) and a Python API backend—no shared passwords or API keys.
  • Each trusted site holds a private key; the API holds their public keys. Requests are signed with the private key and verified with the public key.
  • Timestamps prevent replay attacks. The API rejects requests older than 5 minutes.
  • This pattern works for any server-to-server communication where you need strong authentication without credential sharing.

When I built the Meeting Intelligence Platform, the Drupal frontend needed to authenticate users with the Python API. The obvious approach—shared API keys or passwords—has problems:

  1. Credential rotation is painful — Change the key and both services need updating simultaneously.
  2. Compromise spreads — If someone extracts the API key from the frontend, they can impersonate any user.
  3. No non-repudiation — You can't prove which service made a request.

If you're integrating a CMS or legacy frontend with a separate API, this pattern lets you authenticate those services securely without juggling shared secrets or API keys.

Instead of shared credentials, I used RSA asymmetric cryptography. Each frontend site has a private key (kept secret). The API has each site's public key (can be shared freely). Requests are signed with the private key and verified with the public key.

This is the same principle behind HTTPS certificates, SSH keys, and JWT RS256—battle-tested cryptography applied to service authentication.

When to use this pattern: If both services are in the same cluster behind a trusted network, mTLS or a shared secret in a secure vault might be enough. RSA signatures shine when services are operated by different teams or organizations, or when you have multiple "trusted sites" you onboard over time—exactly the case here.

The Authentication Flow

┌─────────────────┐                           ┌─────────────────┐
│  Drupal Site    │                           │  Python API     │
│  (has private   │                           │  (has public    │
│   key)          │                           │   key)          │
└────────┬────────┘                           └────────┬────────┘
         │                                             │
         │  1. Create payload: {"email": "user@..."}   │
         │  2. Add timestamp                           │
         │  3. Sign: payload|timestamp → signature     │
         │                                             │
         │  POST /auth/trusted/register                │
         │  {payload, timestamp, signature, site_id}   │
         │────────────────────────────────────────────▶│
         │                                             │
         │                      4. Verify site_id known│
         │                      5. Check timestamp     │
         │                      6. Verify signature    │
         │                      7. Create user & JWT   │
         │                                             │
         │◀────────────────────────────────────────────│
         │  {access_token, user}                       │

The beauty: even if someone intercepts the request, they can't forge new ones without the private key. And the timestamp ensures captured requests can't be replayed.

Generating the Key Pair

First, generate an RSA key pair for each trusted site:

# Generate 2048-bit RSA private key
openssl genrsa -out drupal_private.pem 2048

# Extract public key
openssl rsa -in drupal_private.pem -pubout -out drupal_public.pem

The private key stays with Drupal (never transmitted). The public key goes to the API.

The PHP Side: Signing Requests

The Drupal site signs requests using PHP's OpenSSL functions:

// from CryptoService.php
public function createSignedRequest(array $data): ?array {
    $timestamp = time();
    $payload = json_encode($data, JSON_THROW_ON_ERROR);

    // Create the message to sign: payload|timestamp
    $message = $payload . '|' . $timestamp;
    $signature = $this->sign($message);

    if ($signature === NULL) {
        return NULL;
    }

    return [
        'payload' => $payload,
        'timestamp' => $timestamp,
        'signature' => $signature,
        'site_id' => $this->siteId,
    ];
}

public function sign(string $payload): ?string {
    if (!$this->isAvailable()) {
        return NULL;
    }

    $signature = '';
    $result = openssl_sign($payload, $signature, $this->privateKey, OPENSSL_ALGO_SHA256);

    if (!$result) {
        return NULL;
    }

    return base64_encode($signature);
}

Key points:

  • The message format is payload|timestamp — concatenating them prevents tampering with either independently
  • OPENSSL_ALGO_SHA256 uses SHA-256 for the hash, same as Python's verification
  • Signature is base64-encoded for safe transmission in JSON

The API client uses this to make authenticated requests:

// from ApiClient.php
public function registerTrusted(string $email): array {
    $signedRequest = $this->cryptoService->createSignedRequest(['email' => $email]);

    if ($signedRequest === NULL) {
        throw new \Exception('Failed to sign request - check private key configuration');
    }

    $response = $this->httpClient->request('POST', $this->getBaseUrl() . '/auth/trusted/register', [
        'json' => $signedRequest,
        'headers' => [
            'Content-Type' => 'application/json',
            'Accept' => 'application/json',
        ],
    ]);

    return json_decode($response->getBody()->getContents(), TRUE);
}

No API key, no password—just a cryptographically signed request.

The Python Side: Verifying Signatures

The API verifies signatures using Python's cryptography library:

# from crypto.py
class TrustedSiteVerifier:
    """Verifies requests from trusted sites using RSA signatures."""

    def __init__(self) -> None:
        self._public_keys: dict[str, rsa.RSAPublicKey] = {}
        self._load_public_keys()

    def _load_public_keys(self) -> None:
        """Load all public keys from the trusted sites directory."""
        keys_dir = Path(self._settings.trusted_sites_keys_dir)
        
        for key_file in keys_dir.glob("*_public.pem"):
            site_id = key_file.stem.replace("_public", "")
            with open(key_file, "rb") as f:
                public_key = serialization.load_pem_public_key(f.read())
                if isinstance(public_key, rsa.RSAPublicKey):
                    self._public_keys[site_id] = public_key

Public keys are loaded at startup from a directory. The naming convention (drupal_public.pem) determines the site ID.

The verification logic:

 

# from crypto.py
def verify_signature(
    self,
    site_id: str,
    payload: str,
    signature: str,
    timestamp: int,
) -> bool:
    # Check site is trusted
    if site_id not in self._public_keys:
        raise ValueError(f"Unknown trusted site: {site_id}")

    # Validate timestamp (prevent replay attacks)
    current_time = int(time.time())
    time_diff = abs(current_time - timestamp)
    if time_diff > self._settings.trusted_site_timestamp_tolerance:
        raise ValueError(
            f"Timestamp too old or too far in future: {time_diff}s difference"
        )

    # Verify signature
    try:
        signature_bytes = base64.b64decode(signature)
        message = f"{payload}|{timestamp}".encode("utf-8")

        self._public_keys[site_id].verify(
            signature_bytes,
            message,
            padding.PKCS1v15(),
            hashes.SHA256(),
        )
        return True
    except InvalidSignature:
        raise ValueError("Invalid signature")

Three checks happen in sequence:

  1. Site ID check — Is this a known trusted site?
  2. Timestamp check — Is the request fresh? (Default: 5 minute tolerance)
  3. Signature check — Does the signature match the payload?


If any check fails, the request is rejected.

The API Endpoint

The FastAPI router ties it together:

# from auth_trusted.py
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
async def register_trusted(
    request: TrustedRegisterRequest,
    db: AsyncSession = Depends(get_db),
):
    """Register a new user from a trusted site."""
    # Verify signature and extract email
    email = verify_request_signature(
        request.site_id,
        request.payload,
        request.timestamp,
        request.signature,
    )
    
    # Check if email already exists
    query = select(DemoUser).where(DemoUser.email == email.lower())
    result = await db.execute(query)
    existing_user = result.scalar_one_or_none()
    
    if existing_user:
        raise HTTPException(
            status_code=status.HTTP_409_CONFLICT,
            detail="Email already registered.",
        )
    
    # Create new user (no password needed)
    user = DemoUser(
        email=email.lower(),
        password_hash=None,  # No password for trusted site users
        credits_remaining=settings.default_credits,
        registered_via=request.site_id,
    )
    
    db.add(user)
    await db.commit()
    
    # Create JWT token
    token = create_access_token(
        user_id=user.id,
        email=user.email,
        credits_remaining=user.credits_remaining,
    )
    
    return TokenResponse(access_token=token, token_type="bearer", ...)

Users created this way have no password—they're authenticated by the trusted site. The registered_via field tracks which site created them.

Why Not Just Use JWT for Everything?

A fair question: why not have Drupal issue JWTs that the API trusts?

The difference is purpose:

  • JWTs authenticate users — "This request is from user X"
  • RSA signatures authenticate services — "This request is from the Drupal site"

In other words: RSA signatures prove which site is talking; the JWT proves which user they're acting for.

In my architecture, Drupal signs the registration request to prove it's a legitimate trusted site. The API then issues a JWT for subsequent user requests. The RSA signature establishes the trust; the JWT carries it forward.

Deployment Considerations

Key storage: Private keys should never be in version control. I mount them as Docker secrets:

services:
  drupal:
    volumes:
      - ./config/keys/drupal_private.pem:/var/www/html/private/keys/drupal_private.pem:ro

Key rotation: To rotate keys:

  1. Generate new key pair (e.g., drupal_v2_private.pem / drupal_v2_public.pem)
  2. Add new public key to API's keys directory
  3. Update frontend with new private key
  4. Remove old public key from API

No downtime required. The API loads all *_public.pem files at startup, so during the transition both keys are valid. The site ID is derived from the filename, so you can use versioned names (drupal_v2) or simply replace the file after the frontend switches.

Clock synchronization: Timestamp validation assumes both servers have reasonably synchronized clocks. In practice, 5-minute tolerance handles normal drift. For stricter environments, use NTP.

Lessons Learned

1. Asymmetric crypto eliminates shared secrets

No API keys to rotate simultaneously. No credentials that compromise both systems if leaked.

2. Timestamps are essential

Without timestamps, a captured request could be replayed indefinitely. Even stale requests should be rejected.

3. The pattern scales to multiple sites

Adding a new trusted site means generating a key pair and dropping the public key in the API's keys directory. No code changes, no credential management.

4. Standard libraries do the heavy lifting

PHP's openssl_sign() and Python's cryptography library handle the actual RSA operations. I just wire them together with a consistent message format.

Wrapping Up

RSA signature-based authentication is more secure than shared secrets and not much harder to implement. The one-time setup (generating keys, configuring paths) pays off in simpler operations and better security.

This pattern works anywhere you need server-to-server authentication: microservices, webhooks, partner integrations, or multi-site architectures like this one.

You can see this in action on the live demo—when you register, the Drupal site is signing requests to the Python API behind the scenes.

Part of a series on building the Meeting Intelligence Platform. Next up: Orchestrating AI Pipelines with Celery.

Need secure service authentication? I can help with:

  • Design of service-to-service auth schemes
  • Implementation in your existing stack (PHP, Python, Go, Node)
  • Deployment patterns for key storage and rotation

[Get in touch on Upwork] | [Get in Touch Directly →] to discuss your architecture.