Blog / · 12 min read

How we secure your ad accounts

A database row holding an unreadable encrypted access token, its key kept elsewhere
On this page

Here is everything our database knows about my own Facebook ad account:

enc:v1:B/DKGTnvmIOCIVjzQnjOjRIRrglgcsOYVYEjr/gcppKkkJMixrH+RuUoS/vwQSFkOSyOkVrmd4oAvsIW+G/ykWNu9r9Myosm2+68Hxkjlbe7uoY+2pNxrq17QYFBFiGknJ1Un3HJYWK56PRtJm1yGe3tEfkwWuoarEkF58X1A/HdpHrANsOwSieYNQ9hd8/ADmqsS9LQBC7wzq/TWyqSeNQJbRFFJp59x5tWpM2VGQjj2tnxRAfwESnR1p0iRxwvVwzASwnr9oOtu7Pn8gfENs9vLZE2OdgniR75Lr20We72jnzyh83pcAIcbGt4QIRrqjxD8us=

That is the whole row. The copy button hands you every character of it.

It is not a throwaway sandbox account. It is the live connection to the account I actually buy media from: my business, my card on file, spend limits raised, campaigns delivering against it while you read this. A working token for it would let a stranger spend my money before lunch.

It is published on purpose. The honest way to answer "is my ad account safe with you" is to show you exactly what somebody who stole our entire database would walk away with. If you can turn that string back into a working access token, keep whatever you find.

That offer is safe to make, and the rest of this post is why.

The token is the whole game

When you connect Meta or TikTok, you never give us a password. You never type one into us at all. You log in on Facebook's own site, Facebook asks whether you want to grant us access, and if you agree it hands us an access token: a long random string that lets us call the API as you, for the permissions you approved and nothing more.

That string is the crown jewel. Whoever holds it can do everything you approved without needing your password, your phone, or your face. Every other security question is downstream of one thing: where does that string live, and who can read it.

If our database leaked tomorrow

Assume the bad day. Someone walks off with a full copy of our database, every table, every row.

What they get is your name, your email, your folder names, and a column of strings that look like the one at the top of this page. Not a single ad account is reachable with it. Four decisions make that true.

Tokens are encrypted before they are written, not after. Every OAuth token we hold, Meta and TikTok and Google Drive and Dropbox alike, is encrypted with AES-256-GCM on its way into the database. It is never written down in readable form and then cleaned up later. The GCM part matters in practice: it is authenticated encryption, so an attacker who can write to the database still cannot swap one stored value for another and have it accepted.

The key lives somewhere the database cannot see. It sits as an encrypted secret in our hosting platform. It was generated and piped straight into that secret store without ever touching a file on the way. There is no .env file holding it, no copy in a password manager, no line in a chat message. Someone who steals the database steals a pile of locked boxes and no key.

Readable tokens exist for a few milliseconds at a time. A token is unlocked for the length of a single API call and then it is gone. Nothing readable is written to disk. Nothing readable goes into a log, which is worth saying out loud, because logs are how tokens usually escape: not stolen, just quietly copied into a debugging trail somebody forgot about.

It breaks loudly instead of degrading quietly. Production refuses to start without a valid key, and the code refuses to read any token row that is not encrypted. There is deliberately no "if decryption fails, try reading it as plain text" path, because that fallback is exactly the convenience that turns into a breach two years later.

One consequence we accept on purpose: if we ever rotate that key, every stored token becomes unreadable and everyone reconnects their accounts. That is genuinely annoying. It is also the proof that there is no spare copy of the old key lying around to make rotation painless.

What we will not claim

Plenty of tools tell you "not even we can see your data". We will not say that, and you should be a little suspicious of any product like this one that does.

Our servers have to call the Meta API on your behalf, sometimes at three in the morning while a scheduled upload runs. So our code necessarily receives the unlocked token at the moment it runs, which means anyone who can deploy our code can reach an unlocked token. That is not an oversight we are planning to fix. It is an unavoidable property of any tool that acts on your ad account for you. The only way around it is to keep the key on your device and require you to be sitting there for every single API call, which would break the automation you are hiring the tool to do.

So here is the narrower claim, the one we actually hold ourselves to: your token is encrypted at rest with a key kept outside the database, a database breach alone yields nothing usable, and reaching an unlocked token requires production deploy access. That is a smaller promise than "we can never see it", and it has the advantage of being true.

The freelancer you onboarded in March

Most of what goes wrong in media buying is not a hacker. It is access.

Teams hand tools to editors, contractors, and client-side reviewers constantly, and then people move on and the access does not. "Please only look at the Q4 folder" is a request, not a security control. Neither is "I'll remember to remove them Friday".

Scope people to folders, and the server enforces it. A member can be limited to specific folders, descendants included. They do not see other folders, do not see creatives outside their set, and cannot act on them. This is not hidden buttons. Guessing the URL of a folder outside your scope returns a refusal, not the folder, and the same check runs on the file itself, so the video cannot be pulled directly either.

Scope people to ad accounts. A member can be limited to specific ad accounts, and credentials are loaded only for the accounts they are allowed to touch. Picking an out-of-scope account does not quietly work anyway.

Permissions are finer than roles. Owner, admin, and member set the shape, and on top of that there are separate grants for uploading, editing, commenting, approving, deleting, importing, creating share links, viewing ads, creating ads, activating ads and changing budgets, and API access. So the editor who should never be able to raise a budget cannot raise a budget. The hierarchy is pinned explicitly in our own code rather than inherited from a library's defaults, so a dependency upgrade cannot silently widen what a member can do.

One compromised admin cannot clear out the team. Only the owner invites admins or changes roles, and an admin cannot remove another admin unless the owner demotes them first. Every workspace has exactly one owner, and ownership does not transfer inside the product, by design.

Removing someone is immediate and total. It revokes every API key they created in that workspace, disconnects any AI client they had pinned to it, and drops their live sessions on the spot. There is no window where the app has removed them but a script of theirs keeps working. And if a revocation were ever missed, key authentication re-checks membership on every single request and treats a severed key as invalid anyway.

A review link is the one thing that leaves your control the moment you send it. It gets forwarded. It sits in a thread with the agency that pitched against you last quarter. So the link itself has to hold.

If you password-protect a link, that password is salted and hashed with PBKDF2-SHA256 at 100,000 iterations, which is a slow-on-purpose recipe: even with the database in hand, guessing at it costs an attacker real time per attempt instead of billions of guesses a second. Comparisons run in constant time, so the answer cannot be teased out one character at a time by watching how long we take to say no.

Once a reviewer unlocks a link, what their browser holds is signed with our server secret, so an unlock cannot be forged from a stolen database row. And because the unlock is derived from the password itself, changing the password instantly invalidates every unlock already handed out. Cutting off a reviewer does not depend on them being honest about closing the tab.

Expiry and revocation are re-checked on every request rather than only when the page loads, and media and download requests are matched against the link's scope, version history included. Nothing outside the folder you shared comes out by guessing a URL.

There is no password to steal

Most account breaches are boring. Someone reuses a password, it turns up in a dump from an unrelated site, and the attacker walks through the front door.

That cannot happen here, because we never built the door. Accounts are passwordless. You sign in with a six-digit code emailed to you, good for ten minutes and one use only, or with a passkey using Face ID, Windows Hello, or a hardware key. Passkeys are phishing-resistant by construction: they simply will not authenticate against a lookalike domain, no matter how convincing the email was.

We store no password hash, so there is nothing in our database for an attacker to crack offline at their leisure, and nothing for you to have reused elsewhere.

Two-factor is a real second factor: codes from an authenticator app, plus single-use backup codes. We deliberately do not offer email as the second factor, because the first factor is already an emailed code, and asking your inbox twice proves nothing new. Owners can require two-factor from every member of a workspace, and that is enforced on every page and every API route, not just at the login screen. Someone who has not enrolled yet can reach their account settings to finish, sign out, or leave the workspace. That is all.

Sending codes is capped, too. A bot check guards the endpoint and there are per-address and per-network daily ceilings, so nobody can flood your inbox with sign-in codes or farm new accounts from one machine.

If someone gets your session anyway

Now the worse case. Assume an attacker has your logged-in session, cookie and all.

On a lot of products that is the end of the story, because the session alone can change the email address, switch off two-factor, and lock you out before you notice. Here, every action that would lock you out demands something the session does not have.

ActionWhat it also requires
Turn off two-factorA current code from your authenticator
Re-enroll a new authenticatorA current code from the old one
Remove a passkeyA current code from your authenticator
Generate new backup codesA current code from your authenticator
Change your sign-in emailApproval at your current address
Delete the accountA confirmation link sent by email

Two details worth spelling out. A code that has already been used is refused for the rest of its window, so a code caught in transit cannot be replayed, and backup codes are burned the instant they are accepted. And when a sign-in email change does go through, the old address is notified afterwards, so even a successfully phished approval leaves a trail at the address the attacker no longer controls.

A sign-in from a browser or network we have not seen before also emails you and lands in the workspace activity log. The goal is that you find out about a takeover in minutes, not next quarter when the spend report looks wrong.

Keys, the CLI, and AI assistants

Programmatic access is where products quietly hand out more power than the person ever had in the interface. An API key that outlives its owner's job is a common way for an account to stay open long after everyone believes it is closed.

An API key here is SHA-256 hashed at rest and shown to you exactly once. After that even we can only see the first four characters, enough to tell your keys apart in a list and nothing more. If you lose it, you make a new one, because there is no copy for us to read back to you.

More importantly, a key is never more powerful than the person who made it. It runs with the intersection of its own scopes and its creator's workspace permissions, and it dies with their membership. A member whose API access was switched off cannot reach the API through a key, through the CLI, or by poking at requests in browser devtools.

AI assistants connect through an OAuth consent screen that cannot be skipped, and the access they get is pinned to the workspace you approved, so a connected assistant keeps acting where you authorized it rather than following whichever workspace you happen to open next. The CLI never asks you to paste a secret into a terminal: you approve a device code in the browser, valid for ten minutes.

And guessing gets nowhere. There are per-account rate limits and a penalty box for failed key authentication that stops a guessing loop at the edge, before it ever reaches the database.

The unglamorous layer

A few things that are less interesting to read about and are where real breaches actually happen.

Your card details never reach us at all. Checkout and billing are hosted by Stripe, so your payment information is not ours to lose.

Uploads are identified by reading their actual bytes, never by trusting the filename or the type your browser claims, which is what stops a file dressed up as an image from being something else entirely. When you hand us a URL to import from or a webhook endpoint to deliver to, we will only talk to public HTTPS addresses, never to anything on our own internal network, and our outgoing webhooks are signed so your endpoint can verify they really came from us.

Everything we serve carries strict transport security, framing denied, no MIME sniffing, and a strict referrer policy. Text that you control, like workspace and inviter names, is escaped before it enters any email we send, so a crafted workspace name cannot smuggle a link into an invitation.

And leaving is complete. Deleting your account cancels billing at the source, deletes the workspace and everything under it, and purges the underlying files from storage.

What we can actually do with your ad account

In plain terms: when you connect Meta, we ask permission to read and manage ads, to read your Pages and Instagram accounts and the comments on your ads, and to retrieve leads. That is exactly what lets the product upload creatives, build and edit ads, pull performance numbers, and put comments in one place.

We cannot log in as you, see your Facebook password, post to your personal profile, or touch anything outside that list. Payment methods stay with Meta.

And you are never locked in. Facebook's own settings let you revoke our access whenever you like, from your side, without asking us or waiting for us. The moment you do, our access stops working. That is the real guarantee underneath everything above: the permission was always yours, and taking it back takes about ten seconds.

If you want to check any of this before you connect an account, or you think we have something wrong, tell us. Being told we are wrong is considerably cheaper than the alternative.

, Founder

I build upload.ad, the creative library and review workflow media buying teams use to get ads from edit to live on Meta and TikTok. I write about the parts of that job that waste the most time: creative testing, platform specs, review approvals, and the API behaviour nobody documents properly.

All posts by Veikka Grundström · Get in touch