Security and Compliance
Overview
Membership processes sensitive personal data (names, addresses, bank accounts, health-related information) for potentially millions of members across hundreds of organizations. The security architecture is designed to meet GDPR requirements, eIDAS electronic signature standards, and OWASP Application Security Verification Standard (ASVS) Level 2. Every design decision in this chapter is informed by the 196 findings from the Cash360 Phase 4 security audit, ensuring that known vulnerabilities are not repeated.
GDPR Compliance
Data Protection Principles
| GDPR Principle | Implementation |
|---|---|
| Lawfulness | Every data processing operation linked to a legal basis (contract, consent, legitimate interest). Consent records stored with timestamp and version. |
| Purpose limitation | Data collection fields tagged with processing purpose. No silent data enrichment. |
| Data minimization | Registration forms collect only essential fields. Optional fields clearly marked. Custom attributes are tenant-configured, not platform-mandated. |
| Accuracy | Members can self-service update their profiles. Admin-modified fields carry audit trail. |
| Storage limitation | Configurable data retention policies per entity. Automated purge jobs for expired data. |
| Integrity and confidentiality | Encryption at rest (AES-256) and in transit (TLS 1.2+). Access controls on all personal data. |
Consent Management
Every data processing activity requires documented consent. The system maintains a consent registry:
Consent record:
- Member ID
- Consent type (marketing_email, data_sharing, photo_publication, health_data, etc.)
- Granted: true/false
- Granted at: timestamp
- Consent version: "v2.1" (links to consent text version)
- IP address at time of consent
- Revoked at: timestamp (if applicable)
Consent can be granted during registration, membership purchase, or through the member profile. Withdrawal of consent triggers immediate cessation of the associated processing activity.
Right to Erasure (Art. 17)
When a member requests data deletion:
- Validation: Verify identity of the requesting person (email confirmation + authentication)
- Scope assessment: Determine which data can be deleted vs. must be retained (legal obligations: tax records for 10 years, contract documents for 6 years under German commercial law)
- Soft delete: Member profile is anonymized (personal fields replaced with "[DELETED]", email hashed) rather than hard-deleted, preserving referential integrity
- Cascade: All linked documents deleted from object storage. All linked communications purged. CheckIn history anonymized (member reference removed, statistical data retained).
- Third-party notification: Cash360 notified to anonymize related transaction records. External services (email providers, push services) instructed to purge subscriber data.
- Confirmation: Deletion certificate generated and sent to the member.
Data Export (Art. 20 -- Right to Data Portability)
The endpoint GET /api/account/export-data generates a machine-readable export of all personal data:
Response format: JSON file containing: - Personal profile data - Contract history - Transaction history - Bank account data (masked: only last 4 digits of IBAN) - Check-in history - Communication history (sent to this member) - Consent records - Custom attribute data
The export is generated asynchronously (large datasets) and delivered via a time-limited download link sent to the member's verified email address.
Data Protection Impact Assessment (DPIA)
The following processing activities require a DPIA:
- Health-related data processing (medical certificates, body composition data)
- Biometric data processing (fingerprint check-in, if enabled)
- Systematic monitoring (check-in tracking, attendance analytics)
- Large-scale processing of children's data (youth sports clubs)
- Automated decision-making (dunning workflows, contract suspension)
eIDAS-Compliant Contract Conclusion
Membership contracts are legally binding documents. The platform supports electronic signatures compliant with the eIDAS Regulation (EU 910/2014):
Signature Levels
| Level | Use Case | Implementation |
|---|---|---|
| Simple Electronic Signature (SES) | Standard membership contracts | Click-to-sign with audit trail (timestamp, IP, user agent) |
| Advanced Electronic Signature (AES) | High-value contracts, minors | Signature pad capture + identity verification |
| Qualified Electronic Signature (QES) | Legal disputes, franchise agreements | Integration with qualified trust service provider (e.g., DocuSign, Swisscom) |
Contract Signing Flow
For minors (under 18), the contract requires the signature of the responsible person (parent/guardian). The system enforces this by requiring idResponsiblePerson on the Member record and routing the signature request to the responsible person's email.
Zero-Trust Architecture
The platform follows zero-trust principles: never trust, always verify. No network location, device, or user is inherently trusted.
Principles Applied
- Verify explicitly: Every API request authenticated and authorized, even internal service-to-service calls
- Least privilege access: Users receive the minimum permissions required for their role. Admins do not have blanket access to all data.
- Assume breach: All data encrypted at rest. Audit logging on all sensitive operations. Session tokens expire aggressively.
Network Architecture
- The API Gateway validates JWT tokens before forwarding requests
- The Data Layer is not accessible from the public internet
- Internal services communicate over mTLS
- Database connections use SSL with certificate-based authentication
Authentication
JWT Token Architecture
Membership uses a dual-token strategy with short-lived access tokens and long-lived refresh tokens:
| Token | Lifetime | Storage (Mobile) | Storage (Web) |
|---|---|---|---|
| Access token | 15 minutes | In-memory only | In-memory only |
| Refresh token | 30 days | AES-256 encrypted (Android Keystore / iOS Keychain) | HttpOnly + Secure + SameSite=Strict cookie |
Critical lesson from Cash360 audit: Cash360 used a hardcoded JWT signing key ("springboot-core") and stored it in application properties. Membership uses:
- Asymmetric keys (RS256): Private key for signing (server only), public key for verification (can be distributed to API consumers)
- Key rotation: Keys rotated every 90 days. Old keys remain valid for verification until all access tokens issued with them expire.
- Key storage: Private key stored in environment variable or secrets manager, never in source code or config files.
Access Token Claims
{
"sub": "user-42",
"iss": "membership.example.com",
"aud": "membership-api",
"iat": 1740000000,
"exp": 1740000900,
"eid": 1,
"role": "ADMIN",
"mid": 42,
"jti": "unique-token-id-uuid"
}
eid: Entity ID (tenant context)role: User rolemid: Member ID (null for admin-only users)jti: Unique token ID for revocation tracking
Refresh Token Rotation
Every time a refresh token is used, a new refresh token is issued and the old one is invalidated. If a refresh token is reused (indicating it was stolen), all tokens for that user are revoked and the account is flagged for review.
Registration and Verification
- Member submits registration:
POST /api/auth/register(email, password, entity code) - Server creates User with
statusCd = PENDING_VERIFICATION - Verification email sent with time-limited token (24 hours)
- Member clicks link:
GET /api/auth/verify?token=... - Server activates user:
statusCd = ACTIVE - If token expires:
POST /api/auth/resend-verification
Password Policy
Password requirements are configurable per entity (stored in Entity settings JSONB):
{
"passwordPolicy": {
"minLength": 10,
"requireUppercase": true,
"requireLowercase": true,
"requireDigit": true,
"requireSpecialChar": true,
"maxRepeatingChars": 3,
"passwordHistoryCount": 5,
"expirationDays": 0
}
}
Default policy enforces minimum 10 characters with mixed case, digit, and special character. Password history prevents reuse of the last 5 passwords.
Brute-Force Protection
- After 5 consecutive failed login attempts: account locked for 15 minutes
- After 10 consecutive failed attempts: account locked for 1 hour
- After 20 consecutive failed attempts: account locked until manual admin intervention
- Failed attempt counter resets on successful login
- Rate limiting at API Gateway level: max 10 login attempts per IP per minute
- CAPTCHA challenge after 3 failed attempts from the same IP
Authorization (RBAC)
Role Hierarchy
| Role | Scope | Key Permissions |
|---|---|---|
| MEMBER | Own data | View/edit own profile, view own contracts, view own transactions, check-in, book courses |
| TRAINER | Assigned courses + member data | Mark attendance, view participant profiles, manage assigned courses, view resource bookings |
| ADMIN | Single entity | Full CRUD on members, contracts, products, resources, events. User management. Financial reports. Settings. |
| GROUP_ADMIN | Parent + child entities | Everything ADMIN can do, plus: cross-entity reporting, manage child entity settings, create/deactivate child entities |
| SYSTEM_ADMIN | All entities | Platform administration, tenant management, system configuration, global monitoring |
Permission Enforcement
Permissions are checked at two levels:
- Method level:
@PreAuthorize("hasRole('ADMIN') or hasRole('GROUP_ADMIN')")on controller methods - Data level: Repository queries filtered by tenant context. An ADMIN of Entity 1 cannot access data of Entity 2 even if they craft a direct API call with Entity 2's IDs.
Admin roles are scoped to their entity. A GROUP_ADMIN at a parent entity can access child entity data through explicit parent-child traversal endpoints, never through direct entity ID injection.
Transport Security
| Measure | Implementation |
|---|---|
| TLS | TLS 1.2+ required for all connections. TLS 1.0/1.1 rejected. |
| HSTS | Strict-Transport-Security: max-age=31536000; includeSubDomains; preload |
| Certificate pinning | Mobile apps pin the API server's certificate chain (backup pins included for rotation) |
| CORS | Explicit origin whitelist per entity's configured domain. No wildcards. (Cash360 used *, which is a P0 vulnerability.) |
| CSP | Content Security Policy headers on all web responses |
| Cookie security | Secure; HttpOnly; SameSite=Strict on all auth cookies |
Input Validation
All input validation happens at the API boundary (controller layer) before reaching business logic:
| Validation | Implementation |
|---|---|
| Request body | Jakarta Bean Validation (@NotNull, @Size, @Email, @Pattern) on all DTOs |
| Path parameters | Type-safe binding + range checks |
| IBAN validation | Checksum validation (ISO 13616) + bank directory lookup for BIC auto-fill |
| Email validation | RFC 5322 format + MX record check (no disposable email domains) |
| Phone validation | libphonenumber for international format validation |
| File upload | MIME type validation, max size enforcement (10 MB default), virus scan via ClamAV |
| SQL injection | Parameterized queries only (JPA/Hibernate). No string concatenation in queries. |
| XSS | HTML sanitization on all text inputs. Output encoding in templates. |
| JSONB injection | Schema validation on all JSONB inputs against entity's custom attribute definitions |
Audit Logging
Every security-relevant action is logged to the AuditLog table (immutable, append-only):
Logged actions:
| Category | Actions |
|---|---|
| Authentication | Login, logout, failed login, password change, password reset, MFA enable/disable |
| Authorization | Permission denied, role change, user creation/deactivation |
| Data access | Member profile view (by admin), financial data export, bulk data query |
| Data modification | Create, update, delete on any business entity |
| Configuration | Entity settings change, role assignment, integration setup |
| Financial | Transaction creation, billing run, SEPA export, refund |
| Compliance | Data export request, erasure request, consent change |
Log format:
{
"timestamp": "2026-01-15T14:30:00Z",
"userId": 42,
"entityId": 1,
"action": "UPDATE",
"entityType": "Member",
"entityId": 99,
"changes": {
"email": {"old": "old@example.com", "new": "new@example.com"}
},
"ipAddress": "192.168.1.100",
"userAgent": "MembershipApp/1.0 (Android 14)"
}
Audit logs are retained for 7 years (configurable per entity to meet local regulations). They are stored in a separate database partition for performance and can be exported for compliance audits.
Critical rule: Personal data (PII) is never logged in plaintext in application logs (console/file). Only the AuditLog table contains PII, and access to it requires ADMIN role.
ADR-AUTH-001: Custom Authentication vs. Keycloak
Status
Accepted (February 2026)
Context
The Membership platform requires authentication and authorization for multiple user types (members, admins, trainers, franchise directors) across a multi-tenant SaaS environment. The decision is whether to:
- (A) Build custom authentication in the membership-auth module (Spring Security + JWT RS256), or
- (B) Deploy Keycloak (self-hosted, Bitwarden-compatible OpenID Connect / OAuth2 identity provider) as the primary identity system.
The team is small (4 developers), the infrastructure budget is constrained (~EUR 100/month on Hetzner Cloud), and the initial target market consists of small to medium studios and clubs that need simple email/password authentication.
Decision
Option A for v1.0: Custom authentication (membership-auth module) with an IdP abstraction layer that enables Keycloak integration in v2.0+ for Enterprise customers.
Rationale
Why custom auth for v1.0
| Factor | Custom Auth | Keycloak |
|---|---|---|
| Tenant-aware JWT claims | Native — eid (entity), role, mid (member) baked into token generation |
Requires custom Protocol Mapper SPIs and Keycloak extensions |
| Infrastructure cost | Zero — runs in the existing API pods | +1–2 GB RAM, own PostgreSQL DB, ~EUR 10–15/month additional |
| Operational complexity | Part of the monolith, same deployment pipeline | Separate system: own admin console, realm management, theme customization, patching |
| Team expertise | Spring Security is well-known to the team | Keycloak SPI development requires specialized knowledge |
| v1.0 target users | Small studios and clubs need email + password, nothing more | SSO/SAML/LDAP are irrelevant for this segment |
| Time to market | Auth module already designed, standard Spring Security patterns | Keycloak setup, customization, and integration adds 2–4 weeks |
Why Keycloak becomes relevant for v2.0+
| Enterprise Feature | Needed From | Justification |
|---|---|---|
| SAML/OIDC SSO | v2.0 (Enterprise tier) | Fitness chains (50+ locations) with corporate IT require federation with their Active Directory or Azure AD |
| Social Login (Google, Apple) | v2.0 (all tiers) | Members expect one-click registration via their existing accounts |
| WebAuthn / FIDO2 (Passkeys) | v2.0 (all tiers) | Passwordless authentication for admin panels and mobile apps |
| LDAP/AD Federation | v2.0 (Enterprise tier) | Large organizations with existing employee directories |
| Identity Brokering | v3.0+ | Multi-IdP scenarios (franchise with different identity providers per franchisee) |
Architecture: IdP Abstraction Layer
The membership-auth module exposes an internal IdentityProvider interface that abstracts the authentication source. In v1.0, the sole implementation is the built-in LocalIdentityProvider. In v2.0+, a KeycloakIdentityProvider can be added without changing any downstream code.
// Identity Provider abstraction (membership-auth)
public interface IdentityProvider {
AuthResult authenticate(AuthRequest request);
TokenPair issueTokens(AuthenticatedUser user);
AuthenticatedUser validateToken(String accessToken);
void revokeTokens(String userId);
UserProfile getUserProfile(String userId);
}
// v1.0: Built-in implementation
@Component
@ConditionalOnProperty(name = "auth.provider", havingValue = "local", matchIfMissing = true)
public class LocalIdentityProvider implements IdentityProvider {
// JWT RS256, bcrypt passwords, Redis-based brute-force protection
// Tenant-aware claims (eid, role, mid) — native
}
// v2.0+: Keycloak implementation (Enterprise tier)
@Component
@ConditionalOnProperty(name = "auth.provider", havingValue = "keycloak")
public class KeycloakIdentityProvider implements IdentityProvider {
// Delegates to Keycloak via OpenID Connect
// Maps Keycloak tokens to internal AuthenticatedUser with eid/role/mid claims
// Keycloak realm per tenant (entity) or single realm with group-based isolation
}
Token flow with Keycloak (v2.0+):
Keycloak deployment (v2.0+): - Self-hosted on Hetzner Cloud (dedicated CX22 node, ~EUR 5–10/month) - Keycloak 26+ (Quarkus-based, reduced footprint vs. legacy WildFly) - One realm per Enterprise customer (tenant isolation at identity level) - Team/Starter/Professional customers continue using LocalIdentityProvider - Enterprise customers choose their auth method during onboarding: local, Keycloak SSO, or federated (SAML/LDAP)
Consequences
Positive: - v1.0 ships faster with proven Spring Security patterns — no Keycloak complexity - Infrastructure costs remain at ~EUR 100/month for v1.0 - The IdP abstraction layer ensures clean separation — Keycloak can be introduced without refactoring business logic - Enterprise SSO becomes a premium upsell feature (justifies Enterprise pricing)
Negative: - Social Login (Google, Apple) is not available in v1.0 — must be built or deferred to Keycloak in v2.0 - MFA (TOTP/WebAuthn) must be implemented in-house for v1.0 (standard Spring Security + aerogear-otp pattern from Cash360) - The IdP abstraction adds a thin layer of indirection that would not exist with a Keycloak-first approach
Risks: - If an Enterprise customer requires SSO before v2.0 is ready, a fast-track Keycloak deployment may be needed - The IdP abstraction must be respected in all auth-related code — any direct coupling to LocalIdentityProvider creates technical debt
Review Date
Re-evaluate when the first Enterprise customer requests SSO/SAML, or when v2.0 planning begins — whichever comes first.
Lessons from the Cash360 Security Audit
The Cash360 Phase 4 audit identified 196 findings (25 P0 Critical, 45 P1 High). The following table maps the most critical findings to Membership's mitigation strategy:
| Cash360 Finding | Severity | Membership Mitigation |
|---|---|---|
Hardcoded JWT signing key "springboot-core" |
P0 | RS256 asymmetric keys, stored in secrets manager, rotated every 90 days |
CORS wildcard * on all endpoints |
P0 | Explicit per-entity origin whitelist, no wildcards |
| Missing authentication on public endpoints | P0 | All endpoints require authentication except: login, register, verify, forgot-password, public entity info |
No @Version on any generated entity |
P0 | @Version on every entity (optimistic locking) from day one |
| Race conditions in billing (HashMap, no DB locking) | P0 | SELECT ... FOR UPDATE SKIP LOCKED on billing cycle, ConcurrentHashMap where needed |
| Double used for financial amounts | P1 | BigDecimal / DECIMAL(19,4) exclusively for all monetary values |
Missing @Transactional on batch operations |
P1 | All service methods with DB mutations annotated with @Transactional |
Unguarded Optional.get() calls |
P1 | Strict code review rule: use orElseThrow() with meaningful exception |
| Passwords stored with weak algorithm | P1 | bcrypt with cost factor 12 (configurable) |
| Hardcoded EBICS/SMTP credentials in config | P1 | All credentials in environment variables or secrets manager |
Encryption
At Rest
| Data | Encryption |
|---|---|
| Database | PostgreSQL TDE (Transparent Data Encryption) or volume-level encryption |
| Object storage | AES-256 server-side encryption |
| Backups | Encrypted before transfer, stored encrypted |
| Sensitive fields (MFA secrets, tokens) | Application-level AES-256 encryption before DB storage |
In Transit
| Path | Encryption |
|---|---|
| Client to API | TLS 1.2+ (HTTPS) |
| API to Database | SSL with certificate authentication |
| API to Cash360 | TLS 1.2+ with certificate pinning |
| API to external services | TLS 1.2+ |
| Internal service-to-service | mTLS |
Key Management
- Encryption keys managed by Hetzner Cloud Secrets or Kubernetes Sealed Secrets (SOPS), with HashiCorp Vault as option for on-premises
- Key rotation: encryption keys rotated annually, re-encryption of existing data performed during maintenance windows
- Separation of duties: different keys for different data categories (authentication, financial, personal)
Team Credential Management (Vaultwarden)
The development team and operations staff use Vaultwarden (self-hosted, Bitwarden-compatible) as the team password manager for all human-accessed credentials:
| Credential Type | Example | Storage |
|---|---|---|
| Service account passwords | Hetzner Cloud console, GitLab admin, DATEV portal | Vaultwarden (shared vault) |
| API keys (external) | My-Factura API key, Cloudflare API token, SMTP credentials | Vaultwarden + Kubernetes Sealed Secrets |
| Infrastructure access | SSH keys, Kubernetes kubeconfig, database admin passwords | Vaultwarden (restricted vault) |
| Third-party logins | Domain registrar, IHK portal, Finanzamt ELSTER, insurance portals | Vaultwarden (CEO vault) |
| Recovery codes | MFA backup codes, root account recovery tokens | Vaultwarden (emergency vault) |
Vaultwarden deployment: - Self-hosted on Hetzner Cloud (Docker container on infra node), accessible only via VPN or admin IP allowlist - Encrypted with master password + TOTP-based MFA for all team members - Automatic nightly backup to Object Storage (encrypted) - Bitwarden client apps (desktop, mobile, browser extension) for team access - Organization vaults with role-based sharing (CEO, Dev Lead, Developer)
Distinction: Vaultwarden manages human-accessed credentials (admin panels, third-party portals). Kubernetes Sealed Secrets / SOPS manages application-injected secrets (database passwords, JWT keys, API keys injected as environment variables). Both systems complement each other — Vaultwarden for people, Sealed Secrets for pods.
Software License Compliance
All tools and libraries used by the Membership One platform are reviewed for license compatibility with commercial SaaS distribution. The following table lists every significant component with its license type and commercial use assessment.
License Inventory
| Component | Product | License | Commercial Use | Notes |
|---|---|---|---|---|
| Runtime | Java 25 (Eclipse Temurin) | GPLv2 + Classpath Exception | Yes | Free for commercial use |
| Framework | Spring Boot 4.0 | Apache 2.0 | Yes | |
| Build | Apache Maven | Apache 2.0 | Yes | |
| Database | PostgreSQL 18 | PostgreSQL License (MIT-like) | Yes | |
| Cache | Redis 7 | RSALv2 / SSPLv1 (since Redis 7.4) | Yes* | *Server-side license changed; client libraries remain MIT. Self-hosted use is permitted. |
| Messaging | RabbitMQ 4 | MPL 2.0 | Yes | |
| Search | Elasticsearch 8 | SSPLv1 / Elastic License 2.0 | Yes* | *Self-hosted use permitted; cannot offer as a service. Consider OpenSearch (Apache 2.0) as alternative. |
| ORM | Hibernate 6.x | LGPL 2.1 | Yes | |
| Migrations | Flyway | Apache 2.0 (Community) | Yes | Community edition sufficient |
| Security | Spring Security | Apache 2.0 | Yes | |
| JWT | auth0 java-jwt 4.x | MIT | Yes | |
| Open HTML to PDF | LGPL 2.1 | Yes | ||
| API Docs | SpringDoc OpenAPI | Apache 2.0 | Yes | |
| Annotations | Lombok | MIT | Yes | |
| JSON | Jackson | Apache 2.0 | Yes | |
| Resilience | Resilience4j | Apache 2.0 | Yes | |
| Utilities | Guava | Apache 2.0 | Yes | |
| Testing | JUnit 5 | EPL 2.0 | Yes | |
| Testing | Mockito | MIT | Yes | |
| Testing | Testcontainers | Apache 2.0 | Yes | |
| Testing | WireMock | Apache 2.0 | Yes | |
| Coverage | JaCoCo | EPL 2.0 | Yes | |
| Frontend | Flutter / Dart | BSD 3-Clause | Yes | |
| State | Riverpod | MIT | Yes | |
| HTTP | Dio | MIT | Yes | |
| Routing | GoRouter | BSD 3-Clause | Yes | |
| Code Gen | Freezed | MIT | Yes | |
| Monitoring | Prometheus | Apache 2.0 | Yes | |
| Dashboards | Grafana | AGPL 3.0 | Yes* | *Self-hosted use permitted; modifications must be shared if offered as a service |
| Logging | Loki | AGPL 3.0 | Yes* | *Same as Grafana |
| Monitoring | Icinga | GPL 2.0 | Yes | |
| ACME | Dehydrated | MIT | Yes | |
| Passwords | Vaultwarden | AGPL 3.0 | Yes* | *Self-hosted use permitted |
| CI/CD | GitLab CE | MIT (CE) | Yes | Community Edition |
| Container | Docker | Apache 2.0 | Yes | |
| Orchestration | Kubernetes | Apache 2.0 | Yes | |
| CDN/WAF | Cloudflare | Proprietary (Free Plan) | Yes | Free tier sufficient for initial operations |
| Infrastructure | Hetzner Cloud | Proprietary (IaaS) | Yes | Pay-per-use, no license concerns |
License Assessment
All core components use permissive open-source licenses (Apache 2.0, MIT, BSD) that allow unrestricted commercial use. Components under AGPL 3.0 (Grafana, Loki, Vaultwarden) are self-hosted infrastructure tools -- AGPL obligations apply only if modifications are distributed as a service, which is not the case here. The Redis license change (RSALv2/SSPLv1 since 7.4) and Elasticsearch license (SSPL/Elastic License 2.0) permit self-hosted commercial use but prohibit offering them as a managed service to third parties.
Ongoing Compliance
License compliance is audited annually in Q1 via automated dependency scanning in the CI/CD pipeline (see Chapter 16, Ongoing Compliance). Any license change in upstream dependencies triggers a review before the next release.