Vi bruker informasjonskapsler for analyse og markedsføring. Les mer
Back to Lab
m51 PlatformApril 2026 · 10 min

Security in M51 AI OS: How we protect your customer data

Row-Level Security, two-factor authentication, rate-limiting, and continuous auditing. The concrete measures that keep your multi-tenant data safe.

Marketing data is among the most sensitive data a company holds. Campaign budgets, customer lists, organic performance, ad results, and not least OAuth access to Meta, LinkedIn, Google Ads, and analytics tools, all gathered in one place.

When we build a multi-tenant platform that handles this for hundreds of customers simultaneously, security cannot be a sales argument bolted on afterwards. It has to be an architectural prerequisite from the very first line of code.

This article describes the concrete technical measures running in M51 AI OS today. No promises, no hype. Just what we do, why, and how you can verify it.


Data isolation between customers

All core models in the platform carry a customer_id that identifies which customer owns the data. This column is used both at the application layer, where every API call verifies that the logged-in user belongs to the correct customer_id, and at the database layer, where Row-Level Security (RLS) is enabled on all sensitive tables.

RLS is Postgres' built-in mechanism for row-based access control. Our policies require queries to run as service_role, a key that is only used by our backend services. The anon key, which is accessible to clients, has no access to the protected tables at all.

Which tables RLS protects

All tables containing customer-specific data run under service_role policies. An excerpt from migration 043, which enabled RLS on previously unprotected tables:

TableContentsRLS policy
daily_kpi_historyDaily KPI snapshots per customerservice_role only
pagespeed_snapshotsPageSpeed measurementsservice_role only
agent_activity_logLog of agent executionsservice_role only
scroll_stopper_simulationsCampaign simulationsservice_role only
budget_recommendationsBudget recommendationsservice_role only
agreement_acceptancesAgreement acceptancesservice_role only

We also run automated security scanning of the database schema on every migration PR. If someone opens a migration that forgets to set RLS on a new table, the PR is automatically blocked before it can reach production.

Encryption of sensitive data

RLS protects against unauthorized queries against the database. But what happens if an attacker somehow obtains the service_role key itself? Without another layer, they would get plaintext OAuth tokens for every customer. That was unacceptable, so we encrypt all sensitive fields individually before they are written to the database.

What gets encrypted

  • OAuth tokens for Google, Meta, and LinkedIn (access_token, refresh_token, and the associated email address)
  • Integrations secrets for Clickup, HubSpot, Tripletex, and Slack (including webhook URLs and API keys)

The values are stored as Fernet ciphertext directly in the database fields. Fernet is an authenticated encryption algorithm (AES-128-CBC combined with HMAC-SHA256) that both prevents reading and detects tampering.

The key is separated from database access

The encryption key is derived via HKDF-SHA256 from a dedicated environment variable, TOKEN_ENCRYPTION_KEY, which is separated from both DB_SERVICE_ROLE_KEY and JWT_SECRET. The application refuses to start in production if the key is missing or shorter than 32 characters, the same pattern as the JWT_SECRET validator.

This means an attacker must compromise two independent secrets to read tokens: both the database service_role and the application's encryption key. They have different rotation schedules and are not stored together.

Tamper resistance via AAD

Every ciphertext is bound to a combination of customer, provider, and field via Additional Authenticated Data (AAD). If someone attempts to move a ciphertext from one row to another, for example copying customer A's Meta token into customer B's row to gain access, decryption will fail. The HMAC layer in Fernet additionally detects any change to the ciphertext itself automatically.

Versioning and key rotation

Every ciphertext carries a version prefix (v1:...), and the oauth_tokens and integrations tables have an encryption_version column. This makes it possible to rotate keys without downtime: we introduce v2, re-encrypt gradually in the background, and remove v1 only once every row has been updated.

KMS-ready architecture

The code is built around a FieldEncryptor interface. Today's implementation uses application-layer encryption because our production environment lacks native KMS integration, but the entire tool chain (encryption, decryption, rotation) can be swapped for AWS KMS, GCP KMS, or HashiCorp Vault without touching the code that writes or reads tokens. We will switch the day the first enterprise customer sets that as an explicit requirement.

Production state

All existing rows have been migrated from plaintext to v1 encryption via an idempotent backfill script. The production database has zero rows with plaintext tokens. New tokens are encrypted automatically on write through the same code path.

Authentication and access

Sign-in goes through a managed Auth layer with JWT as the session format. Passwords are stored hashed with bcrypt. In production, JWT_SECRET must be at least 32 characters long. The application refuses to start if this is not configured correctly.

The role structure is intentionally simple: a binary admin/user role is combined with a numeric access_level that controls which pages and features the user sees. Access is verified on every API call, not only in the UI layer.

Two-factor authentication (MFA)

MFA is built in, not an add-on service from a third party. We support three methods side by side:

  • TOTP: standard authentication apps (Google Authenticator, 1Password, Authy, etc.)
  • SMS: one-time code to a verified phone number
  • Recovery codes: one-time codes you can write down in case you lose access to your device

Rate-limiting on the MFA endpoints prevents brute-force: send-OTP, verify-OTP, and recovery-code use all have stricter limits than the general API.

Protecting the AI layer

An AI platform has an attack surface that traditional web apps do not. Prompt injection, SSRF via AI-initiated HTTP calls, and exfiltration through hallucinated links are real threats. We treat all three as first-class security concerns.

  • Prompt injection: dozens of known patterns are blocked before input reaches the model, from "ignore previous instructions" in multiple languages to delimiter injection, role hijacking, system prompt extraction, and jailbreak attempts
  • SSRF defense: AI tools that fetch external URLs run through a validator that rejects localhost, cloud metadata endpoints (169.254.169.254), private IP ranges, and DNS rebinding
  • HTML sanitization: all sanitized fields strip script tags, iframes, event handlers (onclick and similar), and javascript: URIs before they are stored or rendered
  • Unicode normalization: fullwidth characters and combining marks are used actively to bypass pattern matching, so input is normalized before validation
  • Access control at the agent level: verifies that the logged-in user actually owns the customer_id referenced by the request, not just that the user is authenticated

All five items have dedicated test classes in test_security.py that run on every single PR.

Infrastructure and resilience

Security is not only about attack defense. It also means keeping the platform available and predictable under pressure, without leaking system information when something goes wrong.

Rate-limiting

The API has a general limit and stricter limits on endpoints that are sensitive to abuse:

EndpointLimit
General API traffic60 requests per minute per IP
Login5 attempts per minute
Registration3 attempts per minute
Password change3 attempts per minute
MFA verificationStricter per-user limit

Rate-limit counters are shared across all backend workers via a central datastore, so the limits hold even under heavy load with many parallel processes.

Circuit breaker and graceful degradation

A circuit breaker sits in front of the database layer. After five consecutive failures it switches to OPEN state, returns HTTP 503 for 30 seconds, and then tries a cautious HALF_OPEN probe before potentially returning to normal operation. This prevents a temporary database disruption from cascading into full downtime, and it gives the database time to recover without drowning in a retry storm from the platform.

The database client above the circuit breaker has its own retry with exponential backoff (0.5, 1, 2 seconds) for transient failures like 408, 429, 500, 502, 503, and 504.

Security headers

Every HTTP response from the backend sets:

  • Strict-Transport-Security: forces HTTPS in browsers that have visited us before
  • Content-Security-Policy: limits where scripts and resources can be loaded from
  • X-Frame-Options: SAMEORIGIN, prevents the platform from being embedded in iframes on foreign domains
  • X-Content-Type-Options: nosniff, stops browsers from guessing MIME types

The CSP policy for the dashboard differs from the CSP for the marketing pages: the dashboard has no connect-src to third-party analytics tools, while the public pages do so that we can measure conversion.

Secrets and configuration

Zero secrets are hardcoded in source code. All API keys, OAuth credentials, database URLs, and internal secrets are loaded from environment variables in the production environment and are only accessible to the runtime processes. Pydantic-based validation checks that critical values are set before the application starts. If anything is missing in production, the process refuses to come up at all.

Three critical secrets are deliberately separated with independent rotation schedules:

  • JWT_SECRET: signs user sessions, must be at least 32 characters
  • DB_SERVICE_ROLE_KEY: gives the backend access to the database behind RLS
  • TOKEN_ENCRYPTION_KEY: decrypts OAuth tokens and integrations secrets, must be at least 32 characters

An attacker must compromise all three to exfiltrate plaintext tokens from the production database. Leaking one key does not reduce the security of the other two.

Service account JSON for Google Workspace is base64-encoded in a separate environment variable and decoded in memory at startup. The JSON file itself never touches disk.

Security as continuous discipline

The controls above do not only run in production. They are validated on every single pull request by GitHub Actions before code can be merged to main.

  • ruff: Python lint rules that catch unsafe patterns and bad style
  • ESLint + TypeScript compilation in strict mode: frontend type-checks the entire codebase
  • pytest with coverage: 787 backend tests, including the full security suite in tests/test_security.py, tests/test_field_encryption.py, and tests/test_oauth_token_encryption.py
  • vitest: 162 frontend tests covering access control, API client, and sensitive hooks
  • npm audit: blocks PRs with high- or critical-severity frontend dependencies
  • pip-audit: scans every Python dependency against known vulnerabilities in the PyPA database
  • Database security scanning: blocks migration PRs with mistakes like missing RLS or unsafe functions

In other words: a developer cannot accidentally disable RLS on a table, introduce a prompt-injection-vulnerable endpoint, or add a dependency with a known CVE. CI catches it before the code reaches customers.

What this article does not cover

We have deliberately stuck to technical controls that are verifiable in the source code. Two things fall outside the scope:

  • Legal treatment of personal data: legal basis, data processor agreements, your rights under GDPR. Covered separately in the privacy policy
  • Formal certifications like SOC 2 or ISO 27001: we do not hold these as of April 2026. We are open about where we stand and are happy to discuss concrete requirements in sales conversations

Read the privacy policy for legal details

Privacy policy

Responsible disclosure

If you discover a vulnerability in M51 AI OS, we want to hear about it. Send an email to [email protected] with a description of the finding, ideally with the steps that reproduce the problem. We respond within two business days, and we will not pursue security researchers who report in good faith.

Do not test vulnerabilities against production data that does not belong to you. If you need a test environment for verification, reach out first. We will help you set up an isolated account.

About M51 AI OS

M51 AI OS is the platform that gives marketing teams and agencies access to specialized AI agents for content production, SEO, advertising, and campaign optimization. The security measures described here are in place because our customers trust us with work that is at the heart of their business.

Want to see the platform in action? Book a demo.

Book a demo
Built in NorwayGDPR-compliantClaude Opus 4.6
Privacy