Security Overview

How SikkerKey secures your secrets, where our responsibility ends, and where yours begins.

SikkerKey encrypts, stores, transports, and authenticates. You own everything the moment a secret leaves our API.

This page lists the technical controls SikkerKey runs on our side and marks the points where responsibility transfers to you. Each claim here maps to specific behavior in our backend, not aspirational principles.

How SikkerKey Secures Your Secrets

Encryption at rest

Every secret value is stored under three layers of encryption.

  • Per-secret data key. Each secret value is encrypted with its own fresh 256-bit key under AES-256-GCM with a random 12-byte IV per write. The secret ID is fed in as Additional Authenticated Data (AAD), which cryptographically binds the ciphertext to the secret record. A ciphertext decrypted under the wrong secret ID fails the GCM tag check, so an attacker cannot swap one secret's ciphertext into another's record.
  • Per-project master key. Data keys are wrapped with a project-scoped master key. Projects in different vaults have independent master keys; a master key leak from one project reveals nothing about any other.
  • Server encryption key (unseal key). Master keys at rest are themselves encrypted with the operator-supplied unseal key. The unseal key is never stored on disk, never logged, and never persisted anywhere in our infrastructure. It exists only in server process memory, provided by the operator after each restart through the unseal URL.

When a secret is decrypted, the master key is loaded into memory on demand, the data key is unwrapped, the plaintext is returned, and both the master key and data key are zeroed in finally blocks before the request returns. Plaintext secrets are never cached.

Passwords, TOTP seeds, and recovery codes at rest are hashed with Argon2id at OWASP-recommended parameters (t=3, m=64 MiB, p=2). Webhook signing secrets and sync credentials are encrypted at rest with AES-256-GCM using a per-subsystem subkey derived from the data encryption key via HMAC-SHA256.

Encryption in transit

  • TLS 1.2+ is enforced at the edge by Cloudflare. Requests on lower protocol versions are rejected before they reach our origin.
  • HSTS is set on every production response with max-age=31536000; includeSubDomains, so browsers reuse HTTPS for a year after first contact.
  • CSP is set on every production response with a per-request script nonce on HTML routes and default-src 'none'; frame-ancestors 'none' on API routes. Scripts that did not originate from our own build cannot execute in the dashboard.
  • Origin / Referer validation on every state-changing request (POST / PUT / PATCH / DELETE) rejects cookie-authenticated requests whose Origin or Referer does not match the configured frontend hosts. Combined with SameSite=Lax cookies, this closes the CSRF surface.

Machine authentication

Machines authenticate to SikkerKey with Ed25519 request signatures, not bearer tokens. Every request is signed over {method}:{path}:{timestamp}:{nonce}:{bodyHash} with the machine's private key. The machine's private key is generated on the machine during bootstrap and never transmitted to SikkerKey. Only the public key is stored server-side.

  • Signature verified before nonce consumed. An invalid signature never touches the nonce table, so an attacker cannot burn legitimate nonces.
  • Nonce uniqueness is enforced by a database primary key keyed on {machineId}:{nonce}. A replay causes a unique constraint violation.
  • Timestamp window is 5 minutes past, 1 minute forward, enforced by the signature verifier.
  • Per-IP and per-machine rate limits track failures separately. A syntactically valid machine UUID records against its own bucket even if the UUID is unknown, so distributed UUID-spraying still pays per-machine cost.
  • Query strings are rejected. Machine-authed routes reject any request with a non-empty query string before signature verification runs. The signed payload covers only the path component, and we refuse to trust unsigned parameters.
  • Failure responses are generic. The server returns Authentication failed for every verification failure mode. Specific reasons (clock skew, nonce reuse, unknown key length) live only in the audit log, never in the client response.

Dashboard authentication

  • Argon2id password hashing at t=3, m=64 MiB, p=2.
  • Password policy rejects passwords under 12 characters, passwords without at least one digit and one non-alphanumeric character, a curated common-password list, passwords containing the username or email local part, and passwords with fewer than 5 distinct characters.
  • Per-(IP, email) login lockout on top of per-account failedLoginAttempts and per-IP rate limits, so distributed credential stuffing and spray both hit a cap.
  • Email verification codes and other verification secrets are stored as HMAC-SHA256 hashes with a server-side pepper derived from the data encryption key, so a database leak does not yield a rainbow-tablable hash.
  • TOTP with mandatory 2FA for employee accounts and optional 2FA for customer accounts. Recovery codes are Argon2id-hashed and single-use.
  • JWT signing keys are derived per-subsystem (session, TOTP, employee, OAuth, GitLab, Bitbucket integrations) from a root JWT secret via HMAC-SHA256. A compromised subsystem signing context does not forge tokens for any other subsystem.
  • Access tokens have a 15-minute lifetime. Every access-token verification checks that the session row still exists in the database, so server-side revocation takes effect on the next request.
  • securityVersion check on every token verify. Password change or 2FA toggle increments the version, which immediately invalidates every token issued under the previous version.
  • Refresh tokens are rotated on every refresh. The old hash is deleted before the new one is stored. The absolute session lifetime is 90 days (users) or 30 days (employees).
  • Session cookies are HttpOnly, Secure (in production), and SameSite=Lax. The presence-indicator cookie is also HttpOnly so client-side JavaScript cannot observe authentication state.

Authorization

Machine access to a secret requires all six of the following, enforced in a single transaction per request:

  1. Valid Ed25519 signature over the request
  2. Machine is approved
  3. Machine is enabled
  4. Vault owner's account is active (not suspended)
  5. Machine is linked to the project the secret belongs to
  6. Machine has an explicit grant for that specific secret

No inheritance, no bulk access by default. Granting a machine access to a project does not grant it access to that project's secrets.

Dashboard access to a project uses the same single-transaction enforcement for team members: owner ownership, team membership, vault owner status, and the specific team permission set granted for that project.

Audit trail

Every privileged action is recorded in an append-only audit log. The append-only guarantee is enforced at the database level by a PostgreSQL trigger that rejects UPDATE and DELETE on audit_log and employee_audit_log unless a session-local flag is set. Only the audit retention pruner sets the flag, and it only sets it to delete rows that have aged out per the customer's plan retention.

Audit entries pass through a sanitizer that strips ASCII control characters (CR, LF, NUL, ANSI escape, tab) from any user-supplied detail field before storage, so attacker-controlled input like a machine name cannot forge lines in downstream log viewers.

See the full audit reference for every action type, its severity, and when it fires.

Tenant isolation

Every project has its own master key. No key material is shared across projects, regardless of which vault owns them. A customer never holds key material that could decrypt another customer's data, and we never decrypt one customer's data during a request scoped to another.

Vault-scoped team membership means a user invited to Vault A has no visibility into Vault B, even if both vaults have the same owner.

Defense in depth

  • SSRF guard on every outbound webhook delivery. URLs are validated for HTTPS scheme and non-localhost hostname, then the resolved addresses are checked against loopback, link-local, RFC 1918 / ULA private ranges, cloud-metadata (169.254.0.0/16), and IPv4-mapped IPv6. The OkHttp client uses a custom DNS resolver that runs every resolved address through the same guard before the connection is established, closing the DNS-rebinding TOCTOU that a pre-connect DNS lookup would leave open.
  • Rate limits cover login, registration, password reset, TOTP verify, email verify, machine auth, bootstrap, enrollment, webhook delivery, and a general API bucket. Failed machine-auth attempts lock out both the source IP and the machine ID after 3 failures in 5 minutes, for 30 minutes.
  • Request body size caps per path, enforced at the pipeline layer. Chunked transfers without a declared size are rejected.
  • Unhandled exceptions return Internal server error to the client. Stack traces and exception messages live only in the operator's log.

Backups

Database backups run daily at 03:00 UTC via a scheduler in the application server.

  • pg_dump produces a compressed custom-format dump of the database.
  • The dump is encrypted in-process with AES-256-GCM (12-byte random IV, 128-bit authentication tag) under BACKUP_ENCRYPTION_KEY — a 32-byte key distinct from the unseal key and from DATA_ENCRYPTION_KEY. All three keys are supplied at boot and never written to disk or stored in the database.
  • A SHA-256 checksum is computed over the encrypted bytes.
  • The encrypted blob and checksum are pushed to a separate backup server over HTTPS. Every backup-server request is signed with HMAC-SHA256 over a timestamp + nonce + body, with per-request replay protection. The backup server verifies the checksum after upload.
  • Every push attempt, success or failure, is logged to backup_push_log with trigger (scheduled or manual), filename, size, checksum, backup ID, error message, duration, and timestamp. The log is visible to the operator only.

The backup server holds only encrypted blobs. A breach of the backup server alone yields ciphertext that cannot be decrypted without BACKUP_ENCRYPTION_KEY, which is not held on the backup server.

What We Store vs. Never See

Stored on our sideNever on our side
Encrypted secret ciphertexts and wrapped data keysDecrypted plaintext secrets (decrypted in memory on demand, zeroed after use)
Machine public keys, registered during bootstrapMachine private keys (generated on the customer's machine, never transmitted to us)
Email addresses, usernames, audit events, rate-limit stateThe server encryption key on disk (operator-supplied at boot, memory only)
Argon2id hashes of passwords and recovery codesPlaintext passwords or recovery codes
Stripe customer and subscription IDsPayment card numbers (held by Stripe)
Encrypted webhook signing secrets, TOTP seeds, sync credentialsThe user-facing "unseal key" beyond the operator-held copy

Where Our Responsibility Ends

Four clear transition points. Responsibility transfers at each.

  • The API response leaves our server. The plaintext bytes are now in your process. What happens to them from that moment on is outside our visibility.
  • A sync target receives a secret. Pushing a secret to GitHub Actions, GitLab CI, Bitbucket Pipelines, or Supabase delivers the plaintext to that provider under their own terms of service. SikkerKey's custody guarantees end at the provider's ingress.
  • You invite a team member. The invitee's dashboard account is now an additional access path into your vault.
  • A managed-secret agent completes a rotation. The ALTER ROLE runs, your database now holds the new password. We do not connect to, monitor, or see anything past that statement.

What's On Your Side

  • Your machine's private key file, generated on your machine during bootstrap
  • Your dashboard account credentials, including password and 2FA factor
  • Who you invite to your vault and what you grant them
  • Your IP allowlist configuration, if enabled
  • Revoking machines through the dashboard when you decommission them