Technical specifications
Legal.id
1 · System
One issuer, many relying parties.
An OpenID Connect provider operated by Lextronica, Inc. Relying parties — Jane first — integrate over standard OIDC, no proprietary SDK. Applications never see credentials, only signed tokens carrying consented claims. Every identity also publishes a did:web document that anything can resolve without an account here.
| Endpoint | Path | Notes |
|---|---|---|
| Discovery | /.well-known/openid-configuration | Canonical metadata; everything below is advertised here |
| Authorization | /oauth/authorize | Browser redirect; consent screen for third-party clients |
| Token | /oauth/token | Code exchange + refresh; client secret over TLS |
| UserInfo | /oauth/userinfo | Bearer access token; same claim set as the ID token |
| JWKS | /oauth/discovery/keys | RSA public key(s), kid-tagged |
| Revocation | /oauth/revoke | RFC 7009; client-authenticated |
| End session | /logout/oidc | RP-initiated logout; validated post_logout_redirect_uri |
| DID document | /u/{handle}/did.json | did:web resolution target |
| Attestation | /u/{handle}/attestation.jwt | Fresh EdDSA-signed JWS of the identity's public facts |
| Verify (human) | /verify?u={handle} | Public lookup page; performs and displays the JWS check |
2 · Protocol
OAuth 2.0 + OIDC, under OAuth 2.1 rules.
How clients connect
| Grant types | authorization_code + refresh only. No implicit, no password grant, no client credentials. |
|---|---|
| PKCE | Required (S256), enforced server-side. Confidential clients send it too. |
| Clients | Confidential only, operator-registered. Exact redirect-URI matching; HTTPS outside development. |
| State / nonce | Round-tripped and validated. The nonce is embedded in the ID token. |
| Consent | Per-scope consent screen for third-party clients; first-party clients are pre-authorized. |
What the server issues
| Authorization code | Single use · expires in 60 seconds |
|---|---|
| Access token | Opaque (not a JWT) · 2 hours · revocable |
| Refresh token | Rotated on every use; the replaced token is invalidated |
| ID token | JWT, RS256, kid in header · 2 hours · aud = client id, iss = https://legal.id |
| Signing key | RSA-2048, published at JWKS. Rotation is a deploy procedure: the new key issues immediately, old tokens age out within the 2-hour TTL. Multi-key JWKS is a follow-up. |
| Subject | Opaque stable UUID (sub), never the handle, so handles can change without breaking RP links |
Scopes and the claims they release
| Scope | Claims released (ID token + UserInfo) |
|---|---|
| openid | sub (opaque UUID), standard JWT claims (iss, aud, exp, iat, nonce, auth_time) |
email, email_verified (boolean — RPs are instructed not to link accounts when false) | |
| profile | name, given_name, middle_name, family_name, preferred_username (the handle), locale, zoneinfo, birthdate (YYYY-MM-DD), legal.id/age_over_18 (boolean), legal.id/tier, legal.id/did |
| verifications | verified_claims (OpenID Connect for Identity Assurance 1.0): an identity check, with the evidence type, document type, check methods, time and the name as it appeared on the document. The issuing country is left out. legal.id/affiliations: {organization, domain, method, verified_at, expires_at} for each work email proven in the last 90 days. Both omitted when there's nothing verified. |
| address | address (OIDC Core): locality, region, country — where the person lives. Sensitive. |
| nationality | nationalities (OpenID Identity Assurance claims registration): every country the person is a citizen of. Sensitive. |
| birth | place_of_birth (country, locality), birth_given_name, birth_family_name (OpenID Identity Assurance). Sensitive. |
| residence_status | legal.id/residence_status: {country, status, visa_type}, status one of citizen, permanent_resident, visa, other. No standard claim exists for immigration status. Sensitive. |
Everything above except sub, email_verified, legal.id/tier, legal.id/did and the verifications scope is self-declared and not verified, and none of it affects legal.id/tier. Claims are omitted when the person hasn't provided them; an absent claim means "not provided", never false. Country codes are ISO 3166-1 alpha-2 throughout (the Identity Assurance registration allows two-letter codes for compatibility; it matches address.country and the EU wallet PID). A birthday can be set once; users under 13 cannot record one.
Sensitive scopes are never part of profile: an app must name them. The person sees each one, with its value, on the consent screen the first time an app asks — first-party apps included — and can untick it. A sensitive scope the person hasn't filled in is not granted, so nothing reaches an app unseen. Consent is remembered until the person revokes the app. When a verified source exists (for example a nationality presented from an EU Digital Identity Wallet), it will travel in verified_claims per OpenID Identity Assurance, alongside the self-declared value.
3 · Assurance
Tiers state what was verified.
Each tier names the verification that produced it and travels in the token as legal.id/tier. Relying parties make their own trust decisions.
| Tier | Verification performed | Recorded |
|---|---|---|
| Digital | Control of the email address proven via confirmation link | email_verified_at |
| Core | Identity corroborated by sign-in with a verified Google account | google_verified_at + provider uid |
| Verified | An organization checked a government ID against the person, in person or on a live video call. Today that's Legal.io, for its own people. | A verifications row: document type and issuing country, how it was checked, date, name as shown, and the admin who recorded it. Document images and numbers are never stored or entered. |
Verifications
Two kinds, shaped after OpenID Connect for Identity Assurance. An identity check is recorded once by an administrator and doesn't expire; revoking it drops the account to the tier its other verifications support. A work email is proven by signing in with an organization's Google Workspace account and lapses 90 days after the last such sign-in, so someone who leaves stops showing it. Apps asking for the verifications scope receive verified_claims and legal.id/affiliations. Every change writes an audit event.
Unverified accounts
An account with an unconfirmed email is Unverified: the card renders, but tokens carry email_verified: false and relying parties are told not to link accounts on it.
4 · Identity
did:web.
Resolution
did:web:legal.id:u:{handle} ⇩ resolves tohttps://legal.id/u/{handle}/did.json ⇩ publishesEd25519 key (publicKeyMultibase, z6Mk…)profile + verification service endpointsPath form, with an upgrade path to did:webvh. Key custody is server-side today, private keys encrypted at rest. Holder-controlled keys arrive with the wallet.
Attestation (JWS)
GET /u/{handle}/attestation.jwt ⇩ compact JWS, alg=EdDSAheader: { alg, typ, kid: {did}#key-1 }payload: { iss, sub: {did}, handle, legal.id/tier, legal.id/issued, iat, exp: iat+600 } ⇩ verify againstdid.json → Ed25519 public keyTen-minute validity: a timestamped assertion of current status, not a bearer credential. Wallet-held credentials (OpenID4VCI/VP, selective disclosure) are next, and not claimed until they ship.
5 · Operations
Sessions, limits, and what we store.
Holding a session
| Sign-in methods | Passkeys (WebAuthn, discoverable credentials, counter-checked) · password (min 10 chars, bcrypt) with mandatory email verification · optional Google corroboration |
|---|---|
| Session store | Server-side records (IP + user agent), signed httponly cookie, SameSite=Lax, Secure in production |
| Lifetime | 30 days absolute · 14 days idle · users see all active sessions and can revoke any, or all others at once |
| Fixation defense | Rails session reset on every privilege transition |
Ending a session
| RP-initiated logout | end_session_endpoint in discovery. Ends the IdP session, redirects only to origins registered by a known client, round-trips state. |
|---|---|
| App revocation | Revoking an app from Settings invalidates its tokens and grants immediately. Clients can revoke via RFC 7009. |
| Recovery | Password reset by email, 30-minute single-use token. Completing a reset invalidates all other sessions. Responses are generic, so accounts cannot be enumerated. |
| Audit | Append-only: sign-ins and failures, sign-outs, token issue/refresh/revocation, consents, passkey add/remove/failed attempts, verification changes, attestations. Recent events are shown to the user. Write failures alarm rather than drop silently. |
Rate limits
| Surface | Limit |
|---|---|
| Password login | 10/min per IP · 6 per 5 min per email |
| Signup | 8 per 5 min per IP. Invite redemption is atomic and capped |
| Token endpoint | 30/min per IP |
| Password reset | 5 per 15 min per IP |
| Passkey endpoints | 30/min per IP |
TLS is forced (HSTS). All responses send frame-ancestors 'none'.
What we hold, what RPs get
Stored: email, handle, display name, credential material (password hash, passkey public keys, Google uid), an Ed25519 DID keypair with the private key encrypted at rest, security logs. Never stored: plaintext passwords, identity document images or numbers, SSNs. Relying parties receive only consented claims. Retention and rights are in the privacy policy and terms.
6 · Where this is going
From an identity we host to one you hold.
Each step uses a published standard.
Today
Hosted keys
Legal.id holds each identity's Ed25519 signing key, encrypted at rest, and publishes it as did:web. You can already take your data with you (Settings → Your data) and revoke any app.
Next
Keys you hold
Move to did:webvh: a signed, verifiable history of every key change, with pre-rotation. The update key lives in your passkey or wallet, so Legal.id becomes the host and a witness, not the owner.
Then
Credentials that travel
Issue with OpenID4VCI and present with OpenID4VP (both final), as SD-JWT credentials you hold, disclosing only what an app needs. An over-18 proof without the birthday. Accept presentations from EU Digital Identity Wallets.
Get your Legal.id.
Invite only for now.