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 (
UNIQUEconstraint onpublishable_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,environmentpermissions,rate_limit_per_secondlast_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
