Verigrant › Security & custody

Custody is a set of properties, not a promise

The reason to hand a system your career record, or to build a hiring pipeline on top of one, is that it can say precisely what it does with it, in terms specific enough to be wrong about. So here is all of it: what your browser seals before we ever see it, the one other key that opens it, what we can still see anyway, and what happens to the parts you lend out.

Your record is sealed before it reaches us

What you type is encrypted inside your own browser, under a key made from your password. The password is stretched with argon2id against a salt belonging to your account, and two separate values come out of that stretch: the key that locks the record, and the credential you sign in with. Only the second one is ever sent. Your password never reaches this service. What we hold for your account is a verifier for that derived credential, and no password anybody could type would match it, so there is nothing on this side for a support tool to check or for an attacker to steal.

What that seals is the substance of the record: the narrative core, every position in your work history, every qualification, every skill, and the contact details, licences, languages, references and availability filed beside them. Each is stored as its own block of bytes under its own data key, and the key that opens those data keys is wrapped under yours. What arrives here is ciphertext, a wrapped key we have no way to unwrap, and a label saying which kind of record it is.

So there is no support tool that opens it, no administrator button and no route in the software that would, because there is nothing on this side to open it with. A stolen copy of the database is a pile of locked boxes and no keys, and every box has a different lock: there is no master key in the environment and no single secret that unlocks everybody, so somebody holding the whole database still faces one account at a time, and your password is the thing they have to beat. That is why the signup screen refuses a weak one rather than warning about it.

An institution reads any of it only because you approved a disclosure and your own browser, holding your keys, sealed a copy of the data keys for exactly the records covered to that institution's registered public key. It then decrypts in its own environment with a private half we have never held, and we carry a block we cannot open in either direction. A disclosure that could be made while your keys were locked would be a disclosure we could make, so the screen that seals refuses to run without them.

Three things are not in the sealed half yet, and naming them is the point of a page like this. A document you upload is written to disk as the bytes you sent. The screening answers in your answer bank are readable columns. The regulated sections, which are work eligibility, the voluntary self identification answers, your consent trail and the date of birth and last four digits you may have given, sit outside the record document. Each of the three answers to a scope of its own and none is disclosed without one. All three are readable by us.

one section, saved
# in the browser tab, before anything is sent
MK       = argon2id(password, salt, m=64MiB, t=3)
KEK      = HKDF(MK, "verigrant/v2/kek")   # locks
auth_key = HKDF(MK, "verigrant/v2/auth")  # is sent

# what actually goes over the wire
PUT /me/records/8c21-…
{ "scope": "history", "record_type": "work_history",
  "byte_class": 3,
  "ciphertext": "k4Xq…", "ct_nonce": "9f…",
  "wrapped_dek": "Uu2…", "dek_nonce": "1b…" }

# there is no field on that body that holds a value
# from your record, and no way to add one without
# the client changing.

# what we hold afterwards, in full
ciphertext, a wrapped key, a type label, a size band

# what we hold to open it with
nothing
  • A label is not the content We know a row is a work history. We do not know the employer, the title, the dates or a word of what you wrote about it.
  • Every version gets a fresh key Editing a record seals it under a new data key, so an institution that was handed version seven cannot read version eight with what it already has.
  • The seal is bound to the row it belongs to The record's id, scope, type and version are authenticated alongside the ciphertext, so we can read those labels and cannot swap one person's bytes for another's without the decryption failing.

The recovery key is the only other way in

At the end of signup you are shown a recovery key, once, and you have to type the end of it back before the screen will let you past. It is not a hint and not a reset link. It is a second key to the same record, made in your browser out of random bytes, wrapped around the same master key your password wraps, and never sent to us. It is the only other thing in the world that opens what your password opens.

Using it does not hand it over either. Your browser asks for a challenge, opens the recovery slot on your own machine, signs the challenge with a key it takes out of that slot, and sends the signature. We check it against a public half we already hold and answer with an ordinary session. Nothing is decrypted on this side, and the recovery key itself never crosses the wire. The screen that follows asks for a new password and is the whole of the page until it is answered, because a recovery that stopped short would leave the account exactly as its owner found it. Choosing that new password rewraps the same master key, so every record stays readable and the recovery key you just used stays good.

The sentence we will not soften

We can give you your account back and we cannot give you your data back.

Lose the password and the recovery key together and the record is gone. Permanently, for you and for us. There is no operator override, because an override is a key, and a key we held would make everything above this false.

The first half of that sentence is now a door you can walk through. If both keys are gone, you can ask us to empty the account and free the address, and the way it works is shaped around the one thing you can still prove, which is that you read your email. We send a link there. Opening it shows you the sentence above and asks you to type two words rather than press a button. Then nothing happens for three days, and we mail the same address again to say what is scheduled and when, so that if somebody else asked for this you have time to stop it. Stopping it takes your password or your recovery key, deliberately, because whoever asked already showed they can read the mailbox and a link we sent there would be a link they hold. After three days with no cancellation the record, the keys and the stored documents are destroyed together, we mail you to say so, and the address is free to sign up with again.

Read the whole of that as what it is. It returns an account and it destroys a record, and it is offered because the alternative we shipped first was worse: an address held for ever by an account nobody could open and nobody could close. What still ships beside it is the refusal, unchanged. The emailed password reset checks whether an account is encrypted and, if it is, declines outright and points you at your recovery key, rather than quietly setting a password that would open nothing.

a recovery, without a decryption
# the browser asks for something to sign, and for the
# wrap slot it will need. neither is a secret to us.
POST /auth/recovery-challenge { "email": … }

200 { "challenge": "7d1c…",
      "recovery_wrapped_dmk": "Qa9…" }

# on your machine: recovery key opens the slot, the
# master key comes out, and it signs the challenge
POST /auth/recover { "signature": "MEUC…", … }

200 # a session, and nothing was decrypted here

# the one thing that session may do before anything else
PUT /me/keyring   # the same master key, wrapped
                  # under the password you just chose

# an address that names no account gets a challenge of
# the same shape, so this route answers no questions
# about who has an account here.
  • Shown once, and only once It is generated in the tab and displayed there. No copy is kept on this side, so we cannot show it to you again and neither can anybody who compels us.
  • Changing your password does not change it A password change rewraps the master key and leaves the recovery slot alone, so the copy you wrote down at signup keeps working and the account page says so.
  • Export is the backup that needs nobody You can take the whole record out whenever you like. A copy you hold yourself is the one form of recovery that does not depend on us at all.

What we can still see

Everything above is about what we cannot read. This is the other half, and it is the honest cost of a service that has to route a login, rank a vacancy and take a delegation back. None of it comes from reading your record, because we cannot. All of it is in the clear because something on this side has to act on it.

The list, in full

  • Your email address A sign in has to be routed somewhere and a message has to arrive somewhere.
  • That a record exists, and roughly how large each part of it is Sizes are coarsened into padding classes before they are stored, so what we hold is a band rather than a byte count. It still tells us whether your work history is a long one or a short one.
  • When each part was last written The times you edited, per record, whether or not we know what changed.
  • Every grant you issued, and every time it was used The scopes, the institution it names, when it expires, how many times it has been pulled and when it was last pulled.
  • The organisations, the applications, and the graph between them Which institutions you applied to, when, which advanced you, and which agents you paired.
  • The addresses your requests arrived from On sign ins, on failures, and on every entry in the custody trail.

What that adds up to

A real social graph. It is not the record, and the difference is the entire product, but a page that listed what is encrypted without listing what is inferable would be marketing rather than a security page. Hiding the graph as well would take private information retrieval or a mixnet, and we have built neither.

If your threat model includes us

Then weigh the list above first, because it is what we see without attacking anything at all. Then weigh this: the client that does the sealing is delivered by us, over the web, so an operator willing to serve bad code to your browser could take your password the next time you sign in. That is inherent to cryptography delivered by a web page, and no page claiming otherwise should be believed, including this one if it ever does.

One part of that is defended, not just disclosed

The institution key your browser seals a grant to is a key we hand it, so a Verigrant that wanted to read a delegation could answer that lookup with a key of its own. Every institution key we register is therefore a leaf in an append only log, and your browser checks the entry against a signed head under a publication key it pins, keeps the newest head it has accepted, and refuses to seal anything if the log has been rewound, forked, or grown in a way that does not contain what it saw last time. An answer we change later is a refusal on your screen instead of a silent redirection of your record.

And the half of that which is not finished

Pinning catches a Verigrant that tells one browser two different stories over time. Catching one that tells two browsers different stories at the same moment needs somebody outside us comparing published heads. The proofs and the heads are served openly so that anybody can, and today nobody is doing it, which is a gap we would rather write down than leave for a reader to discover.

A delegation, not a copy

A grant is a set of scopes and an expiry, recorded as a row and handed out once as a token signed with ed25519. The signature only proves the token is one of ours. The row is the authority, and it is read again on every use, which is what makes revocation immediate rather than eventual.

The token is returned exactly once, at mint, and never stored: no later endpoint can reissue it, and a copy of our database hands nobody a working credential. Every use is counted, timestamped and attributed, so a delegation's whole history is something its owner can read back.

Approving a disclosure does one more thing underneath the row. Your browser asks us for the institution's public key, asks the institution's own domain for the same key, refuses to go on unless the two agree, and only then seals a copy of the data keys for exactly the records covered. So the scopes are not only a rule we enforce: what a holder was not granted, they were handed no key for.

The default lifetime is 90 days. A grant is a standing delegation, but never an unbounded one.

What a scope delegates

Thirteen scopes, each a tier of the record rather than a single box on a form that gives away all of it at once. profile:read on its own is an introduction. Anything beyond it has to be asked for, in the open, on a token its owner can read and revoke.

Each grant scope and the part of the record it delegates
Scope What the holder may read
profile:read Who you are, how to reach you, where you live now, and which of your claims have been checked
history:read Work history, education, skills, languages, driving experience
credentials:read Licenses and certifications, including CDL class, endorsements and restrictions
preferences:read What you are looking for, and when you can start
documents:read Documents you uploaded, and their contents
answers:read / answers:write Your answer bank, and adding to it
identity:read Legal and former names, address history, date of birth and SSN last four
one scope each Work eligibility, EEO self identification, and your consent trail, read and, for consents, write
applications:submit Nothing at all. It is permission to act, to send an application in your name

A section you never granted comes back empty rather than missing, so a holder learns nothing from its absence. Three things no scope delegates, ever: your references, your former supervisors, who are other people and cannot consent through you, and your pay history.

a grant, start to finish
# minted once; the token comes back here and nowhere else
POST /me/grants
{ "scopes": ["profile:read", "history:read"] }

201 { "grant_id": "8f3a-…",
      "token": "v1.eyJncmFudF9pZCI6…",
      "expires_at": "2026-11-17T09:00:00Z" }

# the holder reads exactly what you delegated. no more.
GET /v1/profile
Authorization: Bearer v1.eyJncmFudF9pZCI6…

200 { "headline": …,
      # a check note goes to the person, who decides who sees it
      "verification_status": "verification status is available from the applicant" }

# you change your mind
DELETE /me/grants/8f3a-…

403 revoked
# on their very next request. no window, no cache.
  • A signature is never the whole answer A revoked grant carries a perfectly valid signature forever. Every check also reads the row, so what a reader gets is the current state of the delegation, not what it was when it was issued.
  • Unavailable is a real answer If the row cannot be read, the answer is unavailable. Nothing here degrades to "probably fine" under load.
  • Revocation stops the next read, not the last one An institution that already pulled and decrypted a version of your record still has it, under its own retention, and no cryptography undoes that. What exists instead is a manifest of exactly which records crossed, at which versions and under whose signature. The service answers for it per delegation today, and the screen that shows it to you is still to be built.
  • An employer is an account, never the holder of a loose token Applying to a vacancy mints a grant scoped to exactly what the posting asked for. The employer is a roster of accounts this service already signs in, so there is nobody to hand a loose credential to, and cutting access off works the same either way.

A check somebody reviewed, in a form you can keep

Everything a person types is a claim. A check is somebody else's review of one of those claims, and it lives apart from the record on purpose: a claim and a review of it have different authors, different lifetimes and different trust, and merging them would make a field the subject can edit indistinguishable from one somebody else looked at.

Checks are reviewed by Verigrant staff working a queue, oldest first, and outside verification vendors are the next thing to be connected. They are never decided by the person being checked, which would make the whole layer decoration. What reaches that queue is the shape of the check: the kind, the reviewer, their reference and notes. The evidence behind it never enters this service at all, and neither does the record.

An approval is signed. What that signature says is that this kind of check was reviewed and approved on this date, and never that the record itself is true. We could not stand behind that record even if we wanted to, because we cannot read it. A refusal is written down where the subject can read it, in the reviewer's own words, because the person refused is entitled to know why.

Encryption is not an identity check

Sealing a record proves that it was sealed. It does not prove that the person who sealed it is who they say they are, and no page here should leave a reader thinking otherwise. What this service improves is the provenance of a claim, the consent recorded around it, and the structure of what an institution receives.

Identity and fraud stay the institution's decision. Where an identity check is used it is a separate step: opened as its own kind, reviewed by a person away from this service, and recorded with a date. What its approval says is that the review happened. It does not say the applicant is genuine, and nothing in the cryptography above says it either.

  • A note is a record of a moment; a grant is a permission A permission can be withdrawn, so every use of one reads the row again. A record of a moment cannot be unsaid. It is superseded instead, so the note carries its own expiry and needs no lookup at all.
  • Only approvals are signed A pending check says nothing yet, and signing a negative finding about a person to be carried around by outside parties is a different product from this one.
  • Minted per read, never stored issued_at should say when we asserted something. A token cached in a table would eventually be asserting something we had stopped believing.
a credential, checked offline
# minted when a reviewer approves the check
cred.v1.eyJzdWJqZWN0X3VzZXJfaWQiOiI….MEUCIQD…

# six fields, signed. no reference, no notes, no row id.
{ "subject_user_id": "1c9d-…",
  "kind": "employment", "status": "verified",
  "provider": "manual",
  "issued_at": 1766000000, "expires_at": 1789000000 }

# ed25519 over the token's own prefix, so a grant and a
# credential can never be read as each other
POST /v1/credentials/verify
200 { "valid": true, … }

# or check it yourself: a public key and fifteen lines.
# no database, no network, no call back to us.

The key is published at GET /v1/credentials/pubkey, because a statement only we can check is a statement we could quietly withdraw. What the note attests is that a review happened on a date. It is never a guarantee of the truth of a record, and nothing on this site will describe it as one.

Enforced below the code that could get it wrong

The encryption is the floor and not the whole building. Everything around the ciphertext still has to behave, and each of these is a property the handlers cannot accidentally opt out of. That is the distinction worth caring about: the failure mode of a mistake should be a failing test, not a quiet disclosure.

The rule is enforced by the database

Every request opens a transaction that names whose record it is acting for, and twenty five tables carry a forced policy admitting only that person's rows, so even the owning role cannot slip past it.

A statement that forgets to say who it is acting for reads an empty database rather than everybody's.

The employer's side is fenced the same way

Organizations, rosters, postings, notifications, matches, pipeline notes and access requests each carry their own forced policy. Membership confers a rank over a roster and never access to anybody's record.

The stage is the only field of an application an employer can write, and the database enforces that rather than the handler.

Credentials that survive a dump

What the login route receives is not your password but a value your browser derived from it, and what is stored is an argon2id hash of that. A session is 32 random bytes of which only a SHA 256 digest is kept, so a copy of the sessions table hands nobody a working credential.

Sessions are rows rather than tokens that describe themselves, which is what makes signing out take effect at once.

Secrets encrypted in an envelope

A stored API key gets a fresh 32 byte data key, and only that data key is wrapped under a master key held in the environment and never in the database. A stolen dump decrypts to nothing.

That master key is for platform credentials such as a provider key you chose to store. It opens no record, and no route anywhere returns a stored key: the one that reports on it gives the provider and the last four characters.

An audit trail that holds no content

Account creation, every login and failure, session lifecycle, grants minted and revoked, every shared read, every write to the encrypted store and every agent draft are appended to a log that is never rewritten.

What it records is that a thing happened: a provider, a company, a grant id. Never the key, the draft or the record behind it. An audit log that copies the data it audits is a second place to breach.

Administrators govern accounts, not records

Whoever runs a deployment can search the account directory, suspend and reactivate, and read the trail and the counters. No route hands them a profile, a document, an answer or a draft, and for the sealed half there is nothing to hand them.

An operator who needs to read a record asks for a grant like anybody else, and that read appears in the owner's own trail.

A small machine facing surface

The public gateway publishes four REST routes and an MCP face over two of them. It holds no database handle, no key material and no session state, and it decides nothing about authorisation.

It cannot be talked into proxying a route it does not name, so an outside party holding a grant reaches the delegated read and nothing beside it.

Regulated answers are fenced off

Work eligibility, EEO self identification, the consent trail and the sensitive identifiers sit outside the record, each behind its own scope and absent entirely from a read that did not delegate it. They are among the parts we can still read, which is why the fence around them matters.

There is no column anywhere for a full Social Security number, and the EEO answers are read by nothing that scores, ranks or searches.

Ranking that can be argued with

A match is a weighted average of named factors, each of which is a comparison anybody could make by hand from the same two rows, and each of which records what it concluded and how much it counted for.

It is a smaller engine than it was, deliberately: a server that cannot read a headline cannot rank on one, so what is left scores on the parts this service can still see. A reason may carry ratios, counts, verdicts and the posting's own words, and never a value out of the seeker's record.

Vendor checks fail loudly

Outside verification vendors sit behind one interface and answer 501 until they are connected. A request that asked for an identity vendor must never come back looking like it got one.

Inbound vendor callbacks are signed with an HMAC over the raw body and a timestamp, compared in constant time, and nothing unverified reaches the database.

Export it, or end it

One route hands you everything this service holds about you in a single document, naming every table it drew from and counting every row. It cannot contain what we cannot read, so your browser opens the sealed sections and adds them to the same file, and tells you which half is missing if it is holding no keys.

Another route destroys the account and every row under it, confirmed with your password. Custody you cannot walk away from is not custody, so both were built at the same time as the thing they undo.

Prompt injection has a bounded target

The drafting helper you run in your own account is offered the record as fact and the job posting as quoted material, so instructions hidden in a job description are text to be read rather than a turn to obey.

Over MCP, no tool takes an argument at all. The most a hostile prompt achieves is a second read of the same record the agent was already entitled to.

Questions worth asking before you hand over a career

Can Verigrant read my record?

Not the substance of it. The narrative core, your work history, your education and your skills are encrypted in your own browser under a key made from your password, and what reaches us is a block of bytes, a wrapped key we cannot unwrap, and a label saying which kind of record it is. Your password never reaches this service at all: what the login route receives is a separate value derived from it, so there is no password stored here and no support tool, administrator button or route in the software that opens the ciphertext. What we can read is listed in full further up this page, and it is not nothing.

What is the recovery key, and what happens if I lose it?

It is a second key to the same record, made in your browser at signup out of random bytes, shown to you once, and never sent to us. It is the only other thing in the world that opens what your password opens. Lose both and the record is gone, permanently, for you and for us. We can give you your account back and we cannot give you your data back, because there is no override here: an override would be a key, and a key we held would make the rest of this page false.

What can a Verigrant operator see?

Your email address, that a record exists and roughly how large each part of it is, when each part was last written, every grant you issued and every time it was used, the organisations and applications and the graph between them, and the addresses your requests arrived from. That is a real social graph, it is not the record, and it is the honest cost of a service that has to route a login, rank a vacancy and revoke a delegation.

What exactly is a grant?

A grant is a delegation you issue: a set of scopes and an expiry, recorded as a row, handed out as a token signed with ed25519. The holder presents the token; we check the signature, then read the row, then check that the scope covers what they are asking for. The row is the authority, and the signature only proves the token is one of ours. When you approve a disclosure, your own browser seals a copy of the record keys to the institution's registered public key, so what the holder decrypts, they decrypt in their environment and not in ours.

What happens when I revoke?

The next request made with that token fails with 403 revoked. There is no cache to expire and no propagation delay, because nothing trusts the token on its own. The trade is deliberate: every check costs a read, and in exchange revocation is real. What revocation cannot do is unsay a read that already happened, and no page should pretend otherwise: an institution that has already pulled and decrypted a version of a record still holds it.

Does an employer need a Verigrant account to read a profile?

Not a personal account, but the institution behind the reader does need a registration. The verifier page takes the grant token together with the institution's own credential, which is either an API key belonging to the organization or a signed in member session named with the organization's slug, and it reads through the public gateway holding no account of its own. Verigrant discloses a record only to a registered institution, so a grant token on its own opens nothing. An employer who wants to publish vacancies and run a hiring pipeline needs an account as well, because that is a place they write rather than a thing they were shown.

How is a verification different from something I typed?

A claim and a review of that claim have different authors, different lifetimes and different trust, so they are stored separately and shown separately. A check records its kind, its status and who did the reviewing, and an approved one also travels as a signed note a reader can keep and check offline. What the note says is that this kind of check was reviewed and approved on this date, never that the record is true. Where several checks exist for one kind, the strongest standing one is reported; a kind nobody ever checked is absent rather than reported as a status.

Which checks can actually run today?

Identity, employment, education and income are the kinds the layer models, and the reviewer who ships today is a person: you open a check, a member of Verigrant staff reviews it away from this service and records the outcome, and an approval is signed. Outside verification vendors are the next thing to be connected. Until they are, each one sits behind the same interface and answers 501, because a request that asked for an identity vendor must never come back looking like it got one.

Can an AI agent make things up about me?

The drafting helper you run in your own account is given your profile and the posting, and nothing else is offered to it as fact. Its instructions forbid inventing employment, degrees or credentials that are not in the record, and a screening question the record cannot answer comes back as a gap for you rather than as a guess. What it produces is a draft for you to read and send, which is the only sense in which anything here writes for you: Verigrant ships no agent that applies on anybody's behalf, and it is not going to. An outside agent reading through MCP is a different matter: it can write whatever it likes in its own words, which is precisely why checks are kept apart from claims. A reader can tell what you wrote from what somebody else reviewed.

Can an AI assistant read my record?

Only if you hand it a grant. The gateway speaks the Model Context Protocol at one route, and the grant is the whole session: there is no separate session id whose lifetime would outlive your revocation. The four tools take no parameters at all, so whose record is read comes from the grant rather than from anything a model can be talked into typing.

Who pays for the model?

For the drafting helper in your own account, you do, with your own API key, stored under envelope encryption and spent only on requests you or a delegate you authorised made. A deployment may instead offer a platform key, metered against a daily allowance per account, and one you host yourself can drive a locally authenticated CLI that needs no stored key at all. Either way it is a convenience you run rather than a service acting for you. An outside assistant reading through a grant brings its own model and costs us nothing.

Can a Verigrant administrator read my record?

No. A platform administrator governs accounts and reads no record: they can search the account directory, suspend and reactivate, and read the custody trail and the service's counters. There is no route that hands them a profile, a document, an answer or the text of a draft. For the sealed half there is nothing to hand them, because the key that opens it is not here. An operator who genuinely needs to read a record asks for a grant like everybody else, and that read lands in your own trail.

Where does my data actually live?

In one Postgres database behind an internal service that is not exposed to the internet, as ciphertext for the sealed half and as ordinary rows for the parts listed further up this page. The machine facing front door for outside parties holding grants is a separate gateway process which holds no database handle and no key material of its own, and which publishes four read routes plus the MCP face over two of them.

Can I take my record with me, or destroy it?

Both. One route hands you everything this service holds about you in a single document, naming every table it drew from and counting every row, and because a document we assembled cannot contain what we cannot read, your browser opens the sealed sections and adds them to the same file before you save it. Another route destroys the account and every row under it, sessions and grants included, confirmed with your password. Custody you cannot walk away from is not custody.

Build on it, or move onto it

The guarantees above are the same ones whether you are the employer reading, the agent integrating, or the person whose record it is. So are the limits.