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.
Derive AES key (PBKDF2)
Encrypt (AES-256-GCM)
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:
- Key generation —
crypto.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. - 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.
- Encryption — The plaintext key is encrypted with AES-256-GCM, producing ciphertext and a random 12-byte IV. The plaintext is discarded.
- What the server receives — Ciphertext (base64), IV (base64), and a SHA-256 hash of the plaintext. The plaintext never leaves your browser.
- 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?
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.
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.
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.
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.
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?
Why PBKDF2 and not Argon2id?
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.
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)
- Customer sends the plaintext key in a POST request body (e.g.
PERMIT-A3K7-MN2P-...). - Server computes
SHA-256(plaintext_key)→ 32 bytes. - Server applies
HMAC-SHA256(LICENSE_HMAC_SECRET, sha256_bytes)→ 32 bytes. The secret is a server-only random value, never in the database. - Server looks up the result in the database. Match = valid. No match = invalid. Done.
Why two layers (SHA-256 + HMAC)?
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.
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.
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.
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?
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.
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.
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.
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 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 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)
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.
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 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.
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_...
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.
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 |
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
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).
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
- Read the raw request body as bytes (before JSON parsing)
- Compute
HMAC-SHA256(your_endpoint_secret, body_bytes) - Hex-encode the result and compare to the
X-PermitCore-Signatureheader - Reject requests where signatures do not match
==)
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.
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.
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]