Forhopp

ForhoppPay Connect — Authentication & API Keys

Feature Reference: FR-1, FR-2 | Service: MerchantApiKeyService, ApiKeyAuthenticationFilter


1. Overview

ForhoppPay Connect uses a dual-key authentication model inspired by Stripe. Every merchant receives a publishable/secret key pair per environment. Secret keys authenticate server-side API calls; publishable keys are safe for client-side code.


2. Key Format Specification

Key Type Regex Pattern Example
Live Publishable pk_live_[a-zA-Z0-9]{32} pk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
Live Secret sk_live_[a-zA-Z0-9]{32} sk_live_q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
Test Publishable pk_test_[a-zA-Z0-9]{32} pk_test_x1y2z3a4b5c6d7e8f9g0h1i2j3k4l5m6
Test Secret sk_test_[a-zA-Z0-9]{32} sk_test_n7o8p9q0r1s2t3u4v5w6x7y8z9a0b1c2

Total length: 40 characters (8-char prefix + 32-char random body).

Random body is generated using java.security.SecureRandom with the character set [A-Za-z0-9].


3. Key Generation

3.1 Flow

Merchant Dashboard (JWT auth)
    │
    ▼
POST /api/v1/connect/keys
    │
    ▼
ConnectKeyController
    │
    ▼
MerchantApiKeyService.generateKeyPair()
    │
    ├─ Validate account exists
    ├─ Check active key count < 10
    ├─ Generate unique publishable key (retry up to 10x)
    ├─ Generate secret key
    ├─ Hash secret key with BCrypt
    ├─ Persist entity
    └─ Return response with plaintext secret (one-time only)

3.2 API

POST /api/v1/connect/keys
Authorization: Bearer {jwt_token}
Content-Type: application/json

{
  "name": "Production Key",
  "environment": "live"
}

Response 201 Created:

{
  "id": 42,
  "name": "Production Key",
  "publishable_key": "pk_live_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
  "secret_key": "sk_live_q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2",
  "environment": "LIVE",
  "permissions": ["charges:write", "charges:read", "webhooks:write", "webhooks:read"],
  "rate_limit_per_second": 100,
  "created_at": "2026-03-30T12:00:00",
  "livemode": true
}

The secret_key field is returned exactly once. It is never stored in plaintext.

3.3 Storage Security

Field Storage Notes
publishable_key Plaintext Used for direct lookup; indexed UNIQUE
secret_key_hash BCrypt hash Cost factor 10; original never persisted

3.4 Limits

  • Maximum 10 active (non-revoked) keys per merchant account
  • Uniqueness enforced at database level (UNIQUE constraint on publishable_key)
  • Generation retries up to 10 times if a collision occurs

4. Request Authentication

4.1 Filter Chain Position

Request
  → ApiKeyAuthenticationFilter   (Connect API key auth)
  → ConnectRateLimitFilter       (Rate limiting)
  → Controller

4.2 Authentication Flow

// Pseudocode of ApiKeyAuthenticationFilter
1. Check path starts with /api/v1/connect/
2. Skip if /checkout/** (public) or /keys/** (JWT auth)
3. Extract Authorization header
4. Validate "Bearer sk_" prefix
5. Validate format: sk_(live|test)_[a-zA-Z0-9]{32}
6. Call MerchantApiKeyService.authenticateSecretKey(token)
   → Extracts environment from prefix
   → Loads all active keys for that environment
   → BCrypt.matches() against each hash
7. If match found and key is active:
   → Set MerchantContext (ThreadLocal)
   → Record key usage timestamp
   → Continue filter chain
8. If no match:
   → Return 401 with error JSON
9. Finally:
   → Clear MerchantContext (prevent leaks)

4.3 MerchantContext

After successful authentication, a MerchantContext is set on the current thread:

public class MerchantContext {
    private Long accountId;
    private Long apiKeyId;
    private ApiKeyEnvironment environment;  // LIVE or TEST
    private MerchantApiKey apiKey;
    private String[] permissions;
}

Controllers access it via MerchantContextHolder.getContext().

4.4 Error Responses

Scenario HTTP Error Code Message
No Authorization header 401 missing_api_key No API key provided
Not Bearer format 401 invalid_auth_format Invalid Authorization header format
Publishable key used 401 invalid_key_type Must use secret key (sk_*)
Malformed key 401 invalid_api_key Invalid API key format
Key not found / revoked 401 invalid_api_key Invalid API key provided
Key explicitly revoked 401 api_key_revoked This API key has been revoked

All 401 responses follow the standard error envelope:

{
  "error": {
    "type": "authentication_error",
    "code": "missing_api_key",
    "message": "No API key provided. Include your API key in the Authorization header using Bearer auth.",
    "doc_url": "https://docs.pay.forhopp.com/api/errors#missing_api_key"
  }
}

5. Environment Routing

The key prefix determines the environment for the entire request:

Prefix Environment Behavior
sk_live_ LIVE Real payments, production data
sk_test_ TEST Sandbox simulation, isolated data

Environment is stored on every entity created during the request (charges, refunds, etc.) via the environment column.


6. Key Revocation

DELETE /api/v1/connect/keys/{id}
Authorization: Bearer {jwt_token}

Revocation is a soft delete — sets revoked_at timestamp. The key record is preserved for audit trail. Revoked keys immediately fail authentication.


7. Key Listing

GET /api/v1/connect/keys
Authorization: Bearer {jwt_token}

Returns all keys (active and revoked) without secret key values. Response includes:

  • id, name, publishable_key, environment
  • permissions, rate_limit_per_second
  • last_used_at, created_at, active (boolean)

8. Database Schema

CREATE TABLE forhopppay.merchant_api_keys (
    id              BIGSERIAL PRIMARY KEY,
    account_id      BIGINT NOT NULL REFERENCES forhopppay.forhopppay_accounts(id),
    name            VARCHAR(100) NOT NULL,
    publishable_key VARCHAR(50) NOT NULL UNIQUE,
    secret_key_hash VARCHAR(255) NOT NULL,
    environment     VARCHAR(10) NOT NULL CHECK (environment IN ('LIVE', 'TEST')),
    permissions     TEXT[] DEFAULT ARRAY['charges:write','charges:read','webhooks:write','webhooks:read'],
    rate_limit_per_second INTEGER DEFAULT 100,
    last_used_at    TIMESTAMP,
    revoked_at      TIMESTAMP,
    created_at      TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMP
);

CREATE INDEX idx_merchant_api_keys_account ON forhopppay.merchant_api_keys(account_id);
CREATE INDEX idx_merchant_api_keys_active ON forhopppay.merchant_api_keys(account_id)
    WHERE revoked_at IS NULL;

9. Correctness Properties

# Property Validates
1 Key format matches pk_(live|test)_[a-zA-Z0-9]{32} / sk_(live|test)_[a-zA-Z0-9]{32} FR-1.1, FR-1.2
2 Secret key stored as valid BCrypt hash; plaintext never in DB FR-1.3
3 All generated keys are globally unique FR-1.4
4 sk_live_ routes to LIVE; sk_test_ routes to TEST FR-2.1, FR-2.2
5 Invalid/revoked/malformed keys return 401 FR-2.3, FR-2.4

10. Performance

  • Authentication target: < 50ms p99
  • Filter logs a warning if authentication exceeds 50ms
  • Key usage recording is fire-and-forget (does not block the request)

Last Updated: March 2026

Was this page helpful?

Edit this page