Exporting Your Users
Your end users are yours. You can read and list them through the API at any time, but an API cannot hand out password hashes, so for the whole set — with the hashes, so that nobody has to choose a new password — you ask us, and we give you one file.
There is no button and no endpoint for this, on purpose: a hash is the most sensitive thing Gate holds, and it never leaves in a response that anyone with an API key could request. A person at Verne Software runs the export on our server, at the request of the account owner, and encrypts it to a key that only you hold. We cannot read the file ourselves once it is encrypted.
How to ask
- Make a key pair on your own machine with age (
brew install age,apt install age, or a binary from the project's page):age-keygen -o gate-export.key # Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pgate-export.keyis your private key. Keep it; do not send it to anyone, us included. The line that startsage1…is the public key, and that one is safe to send. - Write to support@vernesoft.com from the address of your Verne Software account — the account owner's address; we do not act on a request that comes from any other. Say that you want an export of your end users with password hashes, and paste the public key. If you only want one project's users, give its project ID. If something looks odd to us (a different address, a key that arrives separately), we will ask you to confirm by replying to the mail we send to your account address.
- We send you the file within 7 days of the request being confirmed, to your account address, and give you its SHA-256 by a second route. Compare it before you open the file.
- Open it with your private key:
age -d -i gate-export.key tenant-users-….json.age > users.json
The export is recorded in your account's audit log as account.users_exported, with the number of users and nothing else. Run it again whenever you need a fresh one: a hash reflects the password at the time of the export, so someone who changes their password afterwards has a new one.
What you get
One JSON document with a header and a list of users. For each user:
identity— the user, shaped as the body ofPOST /v1/gate/identities:traits(the email address exactly as the person typed it, and the other traits such aslocale),state,external_idif you set one, yourmetadata_public,email_verified, andcredentials.credentials.password.config.hashed_password— the password hash, exactly as Gate stores it. For a password set on Gate it is bcrypt ($2a$12$…); a hash you imported from another system is kept, and exported, in the format it came in (the header counts the algorithms). A user who has no password — signed up by one-time code or with a social account — has nopasswordentry.credentials.oidc.config.providers— social sign-in links:providerandsubject.projects— the IDs of your projects the user belongs to.organizations— the user's organizations and roles: organization ID and name, project, the role's name and its permissions.source— the user's ID with us, when they were created, and when their address was confirmed. Useful to map your records to the new ones.
The header says which tenant and project the file is for, when it was made, how many users it holds (and how many with a password, with a social link, and with a confirmed address), the algorithms of the hashes, and how to import the file.
What is not in the file, ever: authenticator-app (TOTP) secrets, recovery codes, security keys, sessions, the access and refresh tokens a social provider gave us, and the internal identifiers Gate adds to keep one tenant's users apart from another's. People enrol a second factor again on the other side. Users who signed up with Telegram (Passepartout) are in the file with their Telegram fields and no password.
Example
All values here are made up.
{
"format": "verne-gate-users-export",
"version": 1,
"tenant_id": "6f1d6b0e-2b0c-4a9a-9d55-0f3f4a1e2c11",
"project_id": null,
"exported_at": "2026-10-22T09:14:03Z",
"counts": {
"users": 2,
"by_schema": { "user": 2, "passepartout": 0 },
"with_password": 1,
"with_oidc": 1,
"email_verified": 2,
"organization_memberships": 1
},
"password_hashes": {
"field": "users[].identity.credentials.password.config.hashed_password",
"algorithms": { "bcrypt": 1 },
"note": "Each hash is exactly as the identity store holds it…"
},
"mapping": { "verne_gate": "…", "ory_kratos": "…" },
"not_included": ["totp", "lookup_secret", "webauthn", "sessions", "provider tokens", "metadata_admin"],
"users": [
{
"source": {
"identity_id": "0b9c3f0e-8d1d-4b5e-a8f4-3c6e8a1d2f40",
"created_at": "2026-03-02T10:21:44.120931Z",
"email_verified_at": "2026-03-02T10:22:09.004211Z"
},
"identity": {
"schema_id": "user",
"traits": { "email": "ada@example.com", "locale": "en" },
"state": "active",
"external_id": "crm-1042",
"metadata_public": { "plan": "pro" },
"email_verified": true,
"credentials": {
"password": { "config": { "hashed_password": "$2a$12$Zk0w3mH7q9P1sVn2cX4yUuJ5l8aB6dE9fG0hI1jK2lM3nO4pQ5rSe" } }
}
},
"projects": ["a2f1c7d4-5b36-4e0f-9c88-1d2e3f4a5b6c"],
"organizations": [
{
"id": "c4d5e6f7-0a1b-4c2d-8e3f-4a5b6c7d8e9f",
"name": "Acme",
"project_id": "a2f1c7d4-5b36-4e0f-9c88-1d2e3f4a5b6c",
"role": "admin",
"permissions": ["users:read", "users:write"],
"joined_at": "2026-03-05T08:00:00Z"
}
]
},
{
"source": {
"identity_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"created_at": "2026-04-11T15:02:10.554000Z",
"email_verified_at": "2026-04-11T15:02:10.554000Z"
},
"identity": {
"schema_id": "user",
"traits": { "email": "grace@example.com", "locale": "fr" },
"state": "active",
"metadata_public": {},
"email_verified": true,
"credentials": {
"oidc": { "config": { "providers": [{ "provider": "github", "subject": "583231" }] } }
}
},
"projects": ["a2f1c7d4-5b36-4e0f-9c88-1d2e3f4a5b6c"],
"organizations": []
}
]
}
Importing the file
Decrypt it on a machine you trust, and treat users.json as the secrets it holds: delete it when you are done.
Into Gate (another account, or back)
Each user's identity is a request body, unchanged:
jq -c '.users[].identity | select(.schema_id == "user")' users.json | while read -r body; do
curl -sS -X POST https://api.vernesoft.com/v1/gate/identities \
-H "Authorization: Bearer $VERNE_GATE_KEY" \
-H 'Content-Type: application/json' \
-d "$body"
done
The users keep their passwords (they sign in with the ones they had), their confirmed addresses (email_verified), their metadata_public, their state and their social links. Rate limits and error codes are those of importing users; a user who already exists is 409 conflict. A social account belongs to one tenant at a time, so import into the new account after the old users are gone. Organizations and roles are not part of that call: recreate them with the organizations API from the organizations lists. Users of the passepartout schema cannot be created through this route; they sign in with Telegram again.
Into Ory Kratos
Gate runs on Ory Kratos, and a Kratos admin POST /admin/identities takes almost the same body. Two differences: Kratos wants the verified address as verifiable_addresses, not email_verified; and traits must match your identity schema — Gate's user schema has email and locale.
jq -c '.users[].identity | select(.schema_id == "user")
| (.email_verified as $v | del(.email_verified)
| . + {verifiable_addresses: [{value: .traits.email, via: "email", verified: $v, status: (if $v then "completed" else "pending" end)}]})' \
users.json | while read -r body; do
curl -sS -X POST "$KRATOS_ADMIN/admin/identities" -H 'Content-Type: application/json' -d "$body"
done
Kratos checks the bcrypt hash as it is. If your schema uses the address as the password identifier (the usual setup), users sign in with the same email and password.
Into an Auth0-style bulk import
Auth0's user import takes bcrypt hashes in custom_password_hash. From the export:
jq '[.users[].identity | select(.credentials.password) | {
email: .traits.email,
email_verified: .email_verified,
user_metadata: .metadata_public,
custom_password_hash: { algorithm: "bcrypt", hash: { value: .credentials.password.config.hashed_password } }
}]' users.json > auth0-import.json
Auth0 also asks for the connection when you start the import job. Users without a password (one-time code or social sign-in) are left out of this file on purpose; import them without custom_password_hash. If the system you move to wants an algorithm other than the one in the file, the hash cannot be converted: those users would reset their password once.
Security notes
- The file is encrypted to the public key you sent. If that key was not yours — or if you lose the private key — nobody can open it, including us; ask for a new export with a new key.
- Anyone who holds
users.jsonholds every user's password hash. A bcrypt hash is slow to guess against, but not impossible for a weak password: keep the file on an encrypted disk, import it, and delete it. - We delete our copy of the file when you confirm that you have it, and in any case within 7 days. The export is not kept in our backups.
- Nothing about your users is changed by an export: they stay signed in, and their accounts and passwords keep working.