Skip to main content

Your database schema is a security document

· 8 min read
Vulkro
Security research

Ask a team where their application keeps personal data and you usually get an answer about systems: the main database, the analytics warehouse, the logging stack. Ask which columns, and which endpoints write them, and the answer gets vague. Yet that information is already written down, precisely, in the repository. Every ORM model and every migration is a statement about what the application stores and how.

That makes the schema one of the most useful security documents a team has, and one of the least read. A password column next to a create() call that never hashes. A users.email that is used to sign in but is not unique. An api_token stored as it was issued. These are not subtle bugs, and the schema shows them plainly once someone looks.

What the schema already tells you​

A model declaration answers questions a reviewer would otherwise have to ask:

model User {
id Int @id @default(autoincrement())
email String
password String
apiToken String?
dateOfBirth DateTime?
diagnosis String?
iban String?
role String @default("member")
}

Read with a security eye, this one model holds a login credential (password), a secret (apiToken), personal data (email, dateOfBirth), health data (diagnosis) and financial data (iban). It also says something by omission: email has no @unique, and nothing here says how password and apiToken are protected. The code that writes them does.

Passwords stored as they were typed​

The vulnerable write is short:

// Vulnerable: the password column receives the raw value
app.post('/signup', async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.create({ data: { email, password } });
res.status(201).json({ id: user.id });
});

The fix is a slow, salted password hash, applied before the value reaches the database:

// Fixed: hash with argon2id before storing
const argon2 = require('argon2');

app.post('/signup', async (req, res) => {
const { email, password } = req.body;
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const user = await prisma.user.create({ data: { email, password: passwordHash } });
res.status(201).json({ id: user.id });
});

The OWASP Password Storage Cheat Sheet recommends Argon2id first, scrypt where Argon2id is not available, bcrypt for legacy systems and PBKDF2 where FIPS-140 compliance is required, each with minimum work factors. It also makes the distinction that matters here: passwords should be hashed, not encrypted, because "hashing is a one-way function" and encryption can be reversed by anyone who obtains the key.

Plain-text passwords are not only a problem for small projects. In March 2019 Meta (then Facebook) said that a routine security review had found some user passwords stored in a readable format in its internal data storage systems, and that it expected to notify hundreds of millions of Facebook Lite users, tens of millions of other Facebook users and tens of thousands of Instagram users (later revised to millions). Meta said the passwords were never visible outside the company. In September 2024 Ireland's Data Protection Commission fined Meta 91 million euro over the incident. The Deputy Commissioner's summary: "It is widely accepted that user passwords should not be stored in plaintext."

That case was about internal systems and logs rather than one database column, but the principle is the same. If the stored value is the password, anyone who can read the store has every account, and every other site where users reused that password.

Tokens and API keys​

A token column has the same problem with a different fix. If your application issued the token and only needs to check it later (sessions, refresh tokens, password reset links, API keys you hand to customers), store a SHA-256 or HMAC digest and compare digests. The raw value is shown to the user once and never stored. If the application must use the value later, such as a third-party OAuth token, encrypt it at rest with a key kept outside the database. Either way, a leaked backup or a read-only SQL injection should not hand over working credentials.

A login identifier that is not unique​

The quieter schema bug is a sign-in column with no unique constraint:

# Vulnerable: Django model, email used to sign in, not unique
class Account(models.Model):
email = models.EmailField()
password = models.CharField(max_length=128)

If the code looks accounts up by email, two rows can share one: a sign-up race, or Alice@example.com and alice@example.com created separately. The lookup then returns whichever row the database finds first. Depending on the flow, one user can sign in to, reset the password of, or receive mail for another user's account.

# Fixed: unique and case-insensitive at the database
from django.db.models.functions import Lower

class Account(models.Model):
email = models.EmailField()
password = models.CharField(max_length=128)

class Meta:
constraints = [
models.UniqueConstraint(Lower("email"), name="account_email_ci_unique"),
]

Uniqueness checked in application code is not enough. Two requests can both check, both find nothing and both insert. Only the database closes that race, which is why the constraint belongs in the schema.

What Vulkro maps​

Since 0.34.0, every vulkro scan builds a map of the application's database schema from the ORM models and migrations in the repository. It reads Prisma, Drizzle, Mongoose, TypeORM, MikroORM, Sequelize and Knex migrations for JavaScript and TypeScript; Django, SQLAlchemy and Alembic for Python; JPA for Java; GORM and ent for Go; plus plain SQL migrations, Rails, EF Core and Laravel. A table found in both a model and a migration becomes one entry. Vulkro reads code only and never connects to a database.

For each table the map records:

  • the columns, with type, nullability, keys, uniqueness, defaults and foreign keys,
  • a sensitivity class per column: personal, health, financial, secret or credential,
  • whether the code shows a column is hashed or encrypted,
  • tenant, owner, soft-delete and timestamp hints,
  • the endpoints that read or write the table, with method, path and whether they need sign-in.

That last link is the useful one. It turns "we have a patients table" into "these three routes write it, and one of them does not require sign-in". The map appears as a schema object in --format json output and in the local console, and Vulkro Cloud shows it per repository with tables, a sensitivity filter on columns and a relationships diagram.

The rules on top of it​

Four rules use the map directly:

  • schema-plaintext-password-column (High): a login password column is written, and none of its write sites, the value's own assignment or the model's own file (a pre('save') hook, a setter, set_password) passes the value through a password hash.
  • schema-plaintext-secret-column (Medium): a token, API key or secret column is written raw, with no hash or encryption on any write site.
  • schema-login-identifier-not-unique (Medium): a table that stores a password has an email or username column with no unique constraint in any model or migration, and the code looks accounts up by it.
  • schema-tenant-column-missing (Medium): in a multi-tenant schema, a sensitive table has no tenant column and no key to a tenant-scoped table. We cover it in its own post.

The write-path rules are deliberately strict. They fire only on a write site the code actually contains, and stay silent if any write site or the model's own file hashes or encrypts the value. A column that is declared but never written in the repository is not reported.

Two more schema facts are added as context to findings that already exist, without changing their severity: a missing-authentication finding whose endpoint reaches a table with credentials, secrets, financial or health data says so, and a mass-assignment finding whose endpoint writes role, admin, balance, owner or tenant columns names them.

The limits, stated plainly​

Sensitivity is matched on whole words in column names. A column called notes that holds clinical notes is not classed as health data. A column added by hand in production and never declared in the repository is not seen. And the map is a view of what the code declares, not of what the database contains today. These are the right trade-offs for a scanner that runs offline on source code, but they are trade-offs, and we would rather say so than imply the map is complete.

Where to see it​

Run vulkro scan . --format json and look for the top-level schema object, or open the console with vulkro serve. The database schema docs list every supported source and field, and each rule has its own page, for example plain-text password columns and non-unique login identifiers. To get set up, start here.

Sources​