Back to PermitCore

Security

Last updated: June 22, 2026

Found a security vulnerability? Email [email protected] with details. We respond within 48 hours.

1. Security Philosophy

Most SaaS products are built on implicit trust: you trust the provider not to misuse your data and trust that their servers won't be breached. PermitCore is built on a different premise: assume the server will be compromised, and design so that doesn't matter.

This shapes every decision:

  • License keys are encrypted client-side — the server stores ciphertext it structurally cannot decrypt
  • License keys are never stored in plaintext — validation receives the key transiently over TLS but never persists or logs it
  • Passwords are hashed with a deliberately slow, memory-hard algorithm — a stolen database resists offline cracking
  • JWT tokens are signed asymmetrically — a stolen public key cannot forge tokens
  • API keys are stored only as hashes — a database dump does not expose usable keys
  • Every cryptographic choice is documented here with full reasoning — security through obscurity is not a strategy we rely on

Where we made a trade-off, we explain exactly what it is and why we accepted it. If anything here is unclear or you believe there is a flaw in the design, tell us.


2. Zero-Knowledge Encryption — AES-256-GCM

License keys are the most sensitive data in PermitCore. The server is structurally incapable of reading them — not by policy, but because the encryption happens in your browser before anything is transmitted.

Your browser
Generate license key
Derive AES key (PBKDF2)
Encrypt (AES-256-GCM)
PermitCore server
Stores ciphertext + IV
Stores HMAC hash (for lookup)
Never receives the plaintext key

What happens when you create a license

The entire cryptographic process runs in your browser using the native Web Crypto API:

  1. Key generationcrypto.getRandomValues() generates 16 characters from a 32-character alphabet (A–Z excluding I and O, digits 2–9), producing a key with 80 bits of entropy: PERMIT-XXXX-XXXX-XXXX-XXXX.
  2. Key derivation — Your passphrase is transformed into a 256-bit AES key via PBKDF2 (600,000 iterations, SHA-256) with a per-tenant random salt. This runs entirely in your browser.
  3. Encryption — The plaintext key is encrypted with AES-256-GCM, producing ciphertext and a random 12-byte IV. The plaintext is discarded.
  4. What the server receives — Ciphertext (base64), IV (base64), and a SHA-256 hash of the plaintext. The plaintext never leaves your browser.
  5. HMAC layer (server-side) — The server applies HMAC-SHA256 (using a secret only it knows) to the SHA-256 hash before storing it. The final stored value cannot be used to verify guesses without the server secret.

Why AES-256-GCM and not something else?

Why AES-256 and not AES-128?
AES-128 is secure against all known attacks. We chose AES-256 for a wider safety margin against future advances — including theoretical quantum attacks (Grover's algorithm halves effective key strength, making AES-128 equivalent to 64-bit classical security). The performance cost on short license key strings is unmeasurable. There is no reason to use the smaller key.
Why GCM mode and not CBC?
GCM (Galois/Counter Mode) is an authenticated encryption mode — it simultaneously encrypts data and produces an authentication tag. If anyone modifies the stored ciphertext, decryption fails with an authentication error. CBC (the older alternative) does not authenticate, making it vulnerable to padding oracle attacks and bit-flipping. GCM requires a separate HMAC for integrity in CBC; GCM provides both in a single primitive. TLS 1.3, Signal, and WhatsApp all use GCM variants for the same reasons.
Why client-side and not server-side encryption?
If encryption happened server-side, we would need your passphrase on the server — and then we could read your keys. Client-side encryption is the only architecture where zero-knowledge is technically enforced, not just promised. We use the browser's native Web Crypto API rather than a JavaScript library because native APIs run in an isolated browser context that JavaScript code cannot inspect or intercept.
Net result: A complete PermitCore database dump contains only ciphertext. An attacker who steals the database cannot read a single license key without your passphrase. This threat is eliminated at the architectural level.

Passphrase recovery

If you lose your passphrase, PermitCore cannot decrypt your keys — that is the point. However, two optional recovery mechanisms exist (both in Settings → Security → Passphrase Recovery):

  • Recovery Key (recommended, zero-knowledge) — a 160-bit random key generated in your browser. Your passphrase is AES-256-GCM encrypted with a key derived from the recovery key, and that ciphertext is stored. We store only the SHA-256 hash of the recovery key — we can never see either the recovery key or your passphrase.
  • Passphrase Escrow (opt-in, breaks zero-knowledge) — you deliberately send your passphrase to us, we encrypt it with a server-managed key. Suitable for non-technical teams with no password manager. Only choose this if you understand the trade-off.
If you lose both your passphrase and your recovery key, your encrypted license keys cannot be recovered. Validation and activation still work (they use the stored hash). But you cannot view or copy plaintext keys to send to new customers. Store your recovery key offline.

3. Key Derivation — PBKDF2 (600,000 iterations, SHA-256)

A passphrase like "MyProduct2024!" cannot be used directly as an AES key — it must be transformed into a fixed-size, high-entropy key. We use PBKDF2 (Password-Based Key Derivation Function 2) for this transformation.

PBKDF2 runs HMAC-SHA256 repeatedly — 600,000 times — over your passphrase combined with a random per-tenant salt, producing a 256-bit AES key. The high iteration count makes brute-force expensive: on modern hardware, testing 1 billion passphrases per second would still require decades to crack a strong 16-character passphrase.

Why 600,000 iterations?

NIST SP 800-132 (2023 revision) recommends a minimum of 600,000 iterations for PBKDF2-SHA256 when deriving encryption keys. We follow this recommendation exactly. The number is not configurable down — lowering it reduces brute-force cost proportionally.

Why PBKDF2 and not Argon2id?

Trade-off
Argon2id is superior to PBKDF2 for new systems — it is memory-hard (requiring large amounts of RAM per hash, making GPU farms much less effective). We use Argon2id for server-side password hashing where we control the runtime.

However, Argon2id is not available in the browser's Web Crypto API. Implementing it via a JavaScript library would introduce third-party code that could be modified, injected via a supply-chain attack, or intercepted — a far worse trade-off than using a slightly weaker algorithm through a trusted native API. PBKDF2-SHA256 at 600,000 iterations via Web Crypto API is the strongest practical option available in the browser today.
Per-tenant salt: Each tenant has a unique 32-byte random salt. Even if two tenants use the same passphrase, their AES keys are completely different. This also defeats rainbow tables — an attacker cannot precompute a lookup table that works across tenants.

4. License Validation — HMAC-SHA256 (Two-Layer Hash)

License keys are encrypted client-side and never stored in plaintext. During validation and activation, the submitted key is received transiently over TLS, converted to its protected lookup value in memory, and never persisted or logged — it is not written to disk, a database row, or an access log at any point. This is solved with two layers: SHA-256 computed client-side during key creation, and HMAC-SHA256 applied server-side before storage and on every subsequent lookup.

The validation flow (step by step)

  1. Customer sends the plaintext key in a POST request body (e.g. PERMIT-A3K7-MN2P-...).
  2. Server computes SHA-256(plaintext_key) → 32 bytes.
  3. Server applies HMAC-SHA256(LICENSE_HMAC_SECRET, sha256_bytes) → 32 bytes. The secret is a server-only random value, never in the database.
  4. Server looks up the result in the database. Match = valid. No match = invalid. Done.

Why two layers (SHA-256 + HMAC)?

Why not store plain SHA-256?
SHA-256 is a public algorithm with no secret parameter. If an attacker steals the database, they can test candidate keys offline — hash each one and compare. With 80 bits of entropy per key this is still computationally infeasible, but why accept that risk at all?

HMAC adds a server-side secret to the computation. The attacker cannot verify any guesses without LICENSE_HMAC_SECRET. Even with the full database, they are blocked — the secret never leaves the server and is never stored in the database.
Why SHA-256 first (client-side)?
During creation, the admin panel runs entirely in the browser and computes SHA-256 before sending — the plaintext key is never transmitted at all, and the server never receives or sees it. The server then applies HMAC to the received hash before storing. Validation and activation are a different path: the customer's app sends the plaintext key itself (that's how a stateless SDK integration works — see the step-by-step flow above), so the server does receive it transiently, in memory, over TLS. What it never does is store it, log it, or write it anywhere — it's hashed and discarded within the same request. This is zero-knowledge storage: a stolen database can never expose usable key material, even though a single validation request necessarily passes through server memory.
Why POST and not GET for validation?
GET requests embed parameters in the URL. URLs are logged by web servers, CDNs, load balancers, browser history, and analytics tools simultaneously. A license key in a GET URL would appear in your server logs, your customer's browser history, and Cloudflare access logs. POST with a JSON body keeps the key exclusively in the encrypted TLS payload — it never appears in any log.
Net result: An attacker with a full database copy cannot verify whether any guessed key is valid. They would also need the server's HMAC secret, which is not in the database. Database theft and offline brute-force are decoupled attack vectors.

5. Password Hashing — Argon2id

User passwords are never stored. We store a one-way hash. On login, we hash what you entered and compare. We use Argon2id — the current OWASP and NIST recommendation for password hashing.

Why Argon2id and not bcrypt or plain SHA-256?

Why not SHA-256 (or MD5)?
SHA-256 is designed to be fast. A modern GPU can compute 10 billion SHA-256 hashes per second. An attacker who steals a database could test the entire RockYou password list (14 million entries) in milliseconds. Fast hashing algorithms must never be used for passwords.
Why not bcrypt?
bcrypt is widely deployed and respectable. However, it was designed in 1999 and has limitations: passwords are silently truncated at 72 bytes, and it is not memory-hard. An attacker with many GPUs can test many bcrypt hashes in parallel cheaply because GPU RAM cost per attempt is low.
Why Argon2id?
Argon2id won the Password Hashing Competition in 2015 and is the current recommendation from OWASP and NIST. It is memory-hard: currently configured for 64 MB of RAM per hash. To crack a stolen hash, an attacker must allocate 64 MB of RAM per attempt — making large GPU clusters far less effective, since GPU RAM is limited and expensive. It also runs across 4 CPU threads, making single-attempt cost high without making login unusably slow. Each hash uses a unique 16-byte random salt, making rainbow tables impossible.
Parameters: 64 MB memory, 3 iterations, 4 parallel threads, 16-byte random salt per user. Stored as a single opaque string — never the plaintext password.

6. Session Tokens — RS256 JWT (RSA-2048)

After login, you receive a JWT (JSON Web Token) — a signed credential that proves your identity on every API request. Access tokens expire after 15 minutes; a refresh token (7-day lifetime) silently renews them in the background.

Why RS256 (asymmetric) and not HS256 (symmetric)?

HS256 — the problem
HS256 uses a single shared secret: the same key that signs tokens can also verify them. Any service that verifies tokens must hold the signing secret — meaning any compromised service can forge tokens for the entire system.
RS256 — why we chose it
RS256 uses RSA-2048 asymmetric cryptography. A private key signs tokens; the corresponding public key verifies them. The public key cannot sign — it can only verify. This means:
  • The private key lives exclusively on the API server and never leaves it
  • Future external services (SDKs, webhooks) can verify tokens using only the public key
  • A stolen public key cannot forge any token
  • Key rotation is zero-downtime: existing tokens are valid until expiry (max 15 min)
Why 15-minute token lifetime?
JWTs cannot be revoked before expiry without a server-side blocklist (which adds latency to every request). A short 15-minute lifetime is a pragmatic balance: a stolen token becomes worthless within 15 minutes maximum. Users never notice because the Blazor app silently refreshes tokens 60 seconds before they expire.
Refresh tokens are stored as SHA-256 hashes only — the plaintext is discarded immediately after issuance. A stolen database does not expose refresh tokens that can be replayed.

7. Two-Factor Authentication — TOTP (RFC 6238)

MFA adds a second factor to login: something you know (password) + something you have (phone). We use TOTP — the same standard used by Google, GitHub, AWS, and most banks.

Why TOTP and not SMS?

SMS 2FA weaknesses
SMS is vulnerable to SIM-swapping (attacker convinces your carrier to move your number to their SIM), SS7 interception (carrier-level protocol attacks allowing SMS interception without physical access), and real-time phishing (fake login page relays your SMS code to the real site instantly). Multiple high-profile breaches (Twitter, Coinbase, Cloudflare) occurred with SMS 2FA in place.
Why TOTP?
TOTP codes are generated locally on your phone using a shared 20-byte secret (seeded during MFA setup) combined with the current 30-second time window. There is no SMS transmission to intercept — codes are never sent over any network during use. Compatible apps: Google Authenticator, Authy, 1Password, Bitwarden, Microsoft Authenticator, Duo.

How the TOTP secret is protected

The TOTP seed secret is sensitive — anyone with it can generate valid codes indefinitely. We encrypt it at rest with AES-256-GCM using a server-managed key (MFA_ENCRYPTION_KEY) that is stored separately from the database. A stolen database does not expose the TOTP seed directly.

Backup codes (10 per user, 64-bit entropy each) are stored as SHA-256 hashes only — the same reason we hash API keys. A stolen database does not expose usable backup codes.


8. API Keys

API keys are an alternative to JWT for server-to-server authentication, passed via the X-Api-Key header.

Format: permit_live_...

Why a structured prefix?
The permit_live_ prefix is a machine-readable marker. GitHub, GitLab, and other code platforms run automated secret scanning on every push. If a PermitCore API key is accidentally committed to a public repository, the scanner detects the permit_live_ pattern and alerts you within minutes — before an attacker can exploit it. This is a free, zero-effort safety net.
Storage
The raw API key is shown exactly once at creation and then discarded — we store only its SHA-256 hash. If you lose the key, revoke it and create a new one. A stolen database does not expose any usable raw API keys. Maximum 10 active keys per tenant.

9. Rate Limiting — Three Independent Layers

Three separate rate-limiting mechanisms operate simultaneously. Each catches a different class of abuse.

Layer Limit Protects against
IP — Login 10 req/min Password brute-force attacks
IP — Public API 60 req/min License key enumeration, scraping
Tenant (billing tier) 60–1,000 req/min Runaway SDK clients, billing fairness
User (per account) 200 req/min Compromised account data scraping
Tenant and user counters are stored in Valkey (Redis-compatible) and shared across all API instances. Limits are accurate under horizontal scaling. Counters use atomic INCR operations — no race conditions even under concurrent load. Each counter window expires automatically after 2 minutes.

10. Tenant Isolation — PostgreSQL Row-Level Security

PermitCore is multi-tenant: many companies share one database. The primary isolation is application-layer filtering (every query includes WHERE TenantId = @id). We add a second, independent layer: PostgreSQL Row-Level Security (RLS).

What is RLS?

RLS is a database-level feature where PostgreSQL enforces access rules on every query — independent of the application. Even if application code forgot to filter by TenantId (a bug), the database would still return only rows belonging to the current tenant.

How it works in PermitCore

On every database connection, the API sets a session variable: SET app.current_tenant_id = '<uuid>'. RLS policies on all tenant tables read this variable and filter rows. The variable is set by a TenantRlsInterceptor in the EF Core pipeline — it runs automatically before every query, without any per-query code. SuperAdmin sessions set the variable to NULL, bypassing RLS (SuperAdmins can see all tenants by design).
Two independent layers: A bug that bypasses the application-layer TenantId filter would still be stopped at the database level. Both layers must fail simultaneously for a cross-tenant data leak to occur.

11. Webhook Signatures — HMAC-SHA256

Every webhook request includes an X-PermitCore-Signature header so you can verify it genuinely came from PermitCore and was not tampered with in transit.

How to verify

  1. Read the raw request body as bytes (before JSON parsing)
  2. Compute HMAC-SHA256(your_endpoint_secret, body_bytes)
  3. Hex-encode the result and compare to the X-PermitCore-Signature header
  4. Reject requests where signatures do not match
Use constant-time comparison. A regular string equality check (==) exits on the first mismatched byte. An attacker can measure response time to infer the correct signature byte-by-byte (timing attack). Use hmac.compare_digest (Python), crypto.timingSafeEqual (Node.js), or the equivalent in your language.
Each webhook endpoint has its own unique secret, shown once at creation and stored only as a hash. Revoking a single endpoint's secret does not affect others.

12. Data in Transit and at Rest

In transit

  • All traffic encrypted via TLS 1.2+ (enforced at the reverse proxy and CDN layer)
  • License keys sent in POST request bodies, never in URLs — keys never appear in any server log
  • HSTS on every response — browsers remember to always use HTTPS for this domain
  • Security headers on every response: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, X-XSS-Protection: 1; mode=block, Referrer-Policy: strict-origin-when-cross-origin

At rest — what each sensitive type stores

Data What is stored Can we recover plaintext?
License keys AES-256-GCM ciphertext + HMAC hash No — requires your passphrase
Passwords Argon2id hash + salt No
TOTP secrets AES-256-GCM (server key) Yes — needed to generate codes
API keys SHA-256 hash only No
Refresh tokens SHA-256 hash only No
Stripe / payment keys AES-256-GCM (server key) Yes — needed for Stripe API calls

What we don't do

  • We do not sell your data to third parties
  • We do not use your license key data for any purpose other than providing the service
  • We do not store plaintext license keys — ever
  • We do not grant government access to your data without a valid legal order, and will challenge overly broad requests
  • Payments are processed exclusively by Stripe (PCI DSS Level 1) — we never see raw card data

13. Infrastructure and Data Residency

PermitCore is built and operated by an EU-based team. Production infrastructure — the API, database, cache, and background jobs — runs on servers physically located in Germany. We did not choose EU hosting as a marketing checkbox; it is simply where the company and its infrastructure are based.

Available worldwide. EU hosting is about where data resides, not who can use the product. There is no region restriction on sign-up, no EUR-only billing, and no support hours limited to a European time zone. The public API and Store are reachable globally over standard HTTPS.

What “EU-hosted” covers

The application servers, the PostgreSQL database, and the Valkey cache all run in the same EU facility. Daily database backups are GPG-encrypted and pushed off-site to a Hetzner Storage Box — a separate EU-based service — so a single-facility incident doesn't threaten the only copy. Primary data and its backups both stay within the EU; nothing is copied to a third country.

GDPR

GDPR is not exclusively an EU concern — it also applies to companies outside the EU that offer services to people inside it. PermitCore is designed to support GDPR obligations: data export, account and license deletion workflows, a Data Processing Addendum (see /dpa), and EU-based hosting for the underlying data. We do not claim a formal GDPR certification — certification is a specific, scoped, third-party mechanism that PermitCore has not obtained. “Designed to support GDPR obligations” is the accurate description; treat any stronger claim you see elsewhere as an error and report it.


14. Responsible Disclosure

We welcome responsible disclosure of security vulnerabilities. If you find an issue:

  • Email [email protected] with a description, reproduction steps, and potential impact
  • Do not publicly disclose until we have addressed it (90-day embargo)
  • Do not access, modify, or delete data that does not belong to you

We acknowledge all reports within 48 hours and provide status updates within 7 days. Full disclosure policy: security-policy.txt

Contact

Security issues: [email protected]
Privacy / DPA: [email protected]