Skip to content

Security

Threat Defense Section
One tenant reading another’s data Three isolation layers, one enforced by Postgres Tenant isolation
A manager doing more than their job needs Four manage roles, checked on every request Manage roles
Guessing a password or a code Limits per address and per account Rate limits
A stolen password used from somewhere new A risk score that asks for more or refuses Risky sign-ins
A change to an account going unnoticed An email to the person for each security change Security emails
Losing the record an auditor asks for A retention period of up to seven years, and exports Keeping and exporting activity
An app making masks call a private address One check on every address an app or manager supplies Calls to other servers
A leaked or aging signing key Staged rotation with a 24-hour overlap Rotating signing keys
Request parameters leaking from the address bar Pushed authorization requests Pushed requests
A forged or altered authorization request Signed request objects Signed requests
A stolen token being replayed Tokens bound to the client’s key with DPoP Binding a token with DPoP
A token that allows more than one action Authorization details checked against a schema Rich authorization requests

Encryption of secrets at rest is covered in self-hosting. Blocking a device before it can try a password is in sessions and devices.

Layer What it does If it fails
TenantScoped Scopes every query in the app. A forgotten scope would leak rows.
Row-level security FORCE ROW LEVEL SECURITY in Postgres. masks refuses to run as a role that bypasses it, and a production server refuses a role that owns its tables.
Per-tenant signing keys A token’s kid exists in one tenant’s JWKS only. Verification refuses the token.

A SUPERUSER or BYPASSRLS role skips every policy with no error, and the role that owns a table can switch its policy off with one ALTER TABLE. Tenant.switch checks the role once per process. It raises if the role holds either privilege, and in a production server it also raises if the role owns a table that row-level security protects. The server therefore migrates as one role and serves as another. See database roles.

  1. A Rack middleware resolves the hostname and enters its tenant before any controller runs. A hostname with no tenant gets a 404.

  2. Each job records the tenant it was enqueued in, and the worker enters that tenant before performing it. A job enqueued outside a tenant raises. Maintenance across every tenant declares it:

    class CleanupJob < ApplicationJob
    across_tenants!
    end

Other cross-tenant code uses Tenant.switch(tenant) { ... }, which sets a session-scoped Postgres setting and opens no transaction. Outside a tenant, every query returns zero rows, including Model.unscoped.

Each manager holds one or more of four scopes. A token for /manage carries the ones the person holds and the console asked for, and masks checks that the person still holds them on every request.

Scope Can
masks:manage:read Read everything manage shows, and change nothing.
masks:manage:support Read, and help people: sign them out, suspend and restore them, reset passwords and factors, resend invitations, and revoke their sessions, consents, and tokens.
masks:manage:security Read, and change signing keys, clients, providers, sign-in policies, adapters, event streams, and provisioning tokens for one organization.
masks:manage Everything, including tenant settings, email wording, deleting people, and granting any scope.

Every mutation declares the least scope it needs, and one that declares none needs masks:manage. Manage shows a line at the top of each page saying what a viewer below masks:manage can do.

A role below masks:manage:

  • Cannot change another manager, or a session, token, consent, connection, or device that belongs to one. Support cannot change an owner’s email and then reset their password.
  • Cannot give a person or a client any manage scope or masks:scim, or issue a provisioning token for every account. Either would reach managers through SCIM.
  • Cannot change a client that can carry a manage scope, so it cannot point the manage console at another address.
  • Can open manage in a new browser once an owner has connected the console, and cannot connect one itself.

Taking a role away ends it on the person’s next request. Their token is refused, and manage asks them to sign in again.

masks counts attempts in a window and answers with an error once the count is reached. Each count is kept per tenant. The environment variables are listed in ENV vars.

Limit Counted per Covers
MASKS_ATTEMPT_LIMIT IP address Passwords, codes, and passkeys at sign-in, and sign-in through a provider.
MASKS_ACCOUNT_ATTEMPT_LIMIT Account The same attempts against one account from any address, password changes, and sign-in approvals.
MASKS_RECOVERY_LIMIT IP address or account Codes and links sent by email or text message.
MASKS_REGISTRATION_LIMIT IP address Dynamic client registration.
MASKS_DEVICE_CODE_LIMIT IP address User codes entered at /device.
MASKS_CLAIM_LIMIT IP address, across tenants Tenants claimed on first visit under a subdomain template.
MASKS_CLAIM_CEILING The whole server The same claims from every address together.

Other limits are fixed:

Endpoint Limit
Token, pushed request, device authorization, and SAML sign-in 60 per IP address each minute
Introspection 120 per IP address each minute
SCIM 600 per IP address each minute
Organization invitations 30 per account each minute
Account download 5 per account each hour

When a sign-in policy sets a score to act on, masks scores each sign-in right after the first factor, from 0 to 100.

Signal Adds
A device the account has not signed in from in 90 days, or no device at all 30
A network (a /24, or a /48 for IPv6) the account has not signed in from in 90 days 25
An address in one of the tenant’s Risky networks 50
Three or more refused attempts on the account in the last hour, or ten or more 20, or 40
No sign-in for six months 10
A password found in a known breach 40

An account with no sign-in in the last 90 days has nothing to compare, so the device and network add nothing. Under Risk in a sign-in policy:

  • Ask for a second factor at a score of asks for one from that score on. An account without one is asked to add one, as in step-up.
  • Refuse at stops the sign-in from that score on and asks for the first factor again. It must be higher than the score that asks for a second factor.

Any score above zero is recorded as sign_in.risky with the signals, and the person gets a security email. The tenant’s risky networks are under Settings, Who may sign in.

Refuse passwords found in known breaches checks a new password, and each one used to sign in, against the Pwned Passwords range API. masks sends the first five characters of the password’s SHA-1 hash and compares the rest itself, so the password and its full hash never leave the server. A new breached password is refused, and one used to sign in adds to the risk score. When the service does not answer within three seconds, the password is allowed.

masks emails a person when something changes on their account:

Group Sent when
Password The password is changed or reset.
Factors A passkey, an authenticator app, backup codes, or email or text message codes are added or removed, or a backup code is spent.
Sessions A new device signs in, a sign-in looks unusual and is asked for more or refused, a session is signed out, or a device is blocked.
Applications An application is allowed in, an account elsewhere is linked or unlinked, or a device sign-in is approved.
Account A manager changes the person’s scopes, or the person downloads their account.
Organizations Someone else changes the person’s role in an organization or removes them, or archives an organization they are signed in to an app for. See what members are told.
  • A new sign-in is reported once per device.
  • Emails go only to a confirmed address. An account with no confirmed address receives none, and the account page says so.
  • Each person can turn any group off under Emails we send you on the account page.
  • The email names the device and address the change came from, in the person’s time zone.

Every email masks sends can be previewed under Email in /manage, rendered with sample people and the tenant’s own name and links.

An email sent while someone signs in to an approved app is headed by that app’s logo and name, and every other email by the tenant. An app nobody approved never heads an email, so a self-registered app cannot use the tenant’s mail to pass as someone else.

Under Email in /manage, an owner rewords the invitation, organization invitation, password reset, email confirmation, confirmation code, approval request, and approval emails, and adds a signature that closes every email.

  • The subject (up to 150 characters) and the opening text (up to 2,000) each replace the ones masks writes. Leaving either blank keeps masks’ own.
  • Both are plain text, so markup shows as written and no link can be added. A blank line starts a new paragraph.
  • {{tenant}}, {{name}}, {{nickname}}, {{organization}}, {{role}}, and {{code}} fill in where the email has them, and the page lists which each one offers. A placeholder an email does not offer is refused.
  • masks still adds the button or the code, how long it lasts, and the lines that tell a person what to do if it was not them. A password reset a manager started still names the manager.
  • The wording applies in every language the tenant serves.
  • Each change is recorded as mail_template.updated.

masks keeps each event for 180 days by default. The owner changes this under Settings, Activity, from 30 days to seven years. The nightly sweep deletes older events, so a shorter period takes effect the next night.

To keep a copy, open Activity, choose a range of up to 366 days, and choose Download. The file has one event per line as JSON, in the shape event streams send, and follows the filter on the page. Any manage role may download, and each download is recorded as events.exported.

  • The link lasts ten minutes and works only in a browser signed in as the manager who asked for it.
  • A range with more than 250,000 events is refused.
  • For a continuous copy, use an event stream.

masks calls addresses that apps and managers supply: a client’s jwks_uri, sector_identifier_uri, logo_uri, metadata document, back-channel logout URI, and shared signals endpoint, a resource’s protected resource metadata, every provider’s endpoints and SAML metadata, each SMS adapter, and each event stream’s endpoint. It also calls the Pwned Passwords API. Each of these calls:

  • Uses https, with no username or password in the address.
  • Resolves the hostname, refuses it if any address is private, loopback, link-local, or reserved, and connects to the address it checked.
  • Stops at a time limit and a size limit set for each kind of call.

In development and test, masks allows http and private addresses.

Apps and providers on a private network, such as a tailnet or an identity provider inside the office, need their ranges listed in MASKS_OUTBOUND_ALLOWED, separated by commas. See ENV vars.

A key is staged before it signs anything, so clients that cache JWKS fetch it first. See signing keys for the states.

Action Does The outgoing key
Stage Publishes a new key ahead of time. One at a time. Keeps signing.
Activate Makes the staged key the signing key. Verifies for 24 hours.
Rotate Generates and activates a key at once, for when caches cannot wait. Verifies for 24 hours.
Discard Removes a staged key, which has signed nothing. Keeps signing.

Only a staged key can be activated or discarded. SAML applications must re-read masks’ metadata after a rotation.

A normal authorization request puts every parameter in the address bar, where history, logs, and later pages can read it. A pushed request (RFC 9126) sends them to /par over the back channel, and only a reference goes in the URL. Discovery advertises pushed_authorization_request_endpoint.

  1. Post the parameters, authenticating as at /token. Include client_id in the body, even with Basic auth. A public client sends its client_id and no secret.

    Terminal window
    curl -u "$CLIENT_ID:$CLIENT_SECRET" https://demo.auth.example.com/par \
    -d client_id="$CLIENT_ID" \
    -d response_type=code \
    -d redirect_uri=https://app.example.com/cb \
    -d scope="openid profile" \
    -d code_challenge="$CHALLENGE" \
    -d code_challenge_method=S256 \
    -d state="$STATE"
    {
    "request_uri": "urn:ietf:params:oauth:request_uri:8mZ1...",
    "expires_in": 90
    }
  2. Redirect to /authorize with only the client ID and the reference:

    https://demo.auth.example.com/authorize
    ?client_id=...
    &request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A8mZ1...

The reference lasts 90 seconds, works once, and only for the client that pushed it. Following it twice, late, or with another client_id returns invalid_request_uri. A wrong client_id does not spend it.

mutation {
updateClient(clientId: "...", requirePushedAuthorizationRequests: true) {
client { clientId }
}
}

masks then refuses a plain /authorize link for that client with invalid_request. A self-registered client can ask for the same setting at registration. Sign-in and consent continue the pushed request.

A client with jwks or a jwks_uri can send the whole request as a signed JWT, a request object (RFC 9101), with the parameters as claims:

{
"iss": "$CLIENT_ID",
"aud": "https://demo.auth.example.com",
"client_id": "$CLIENT_ID",
"exp": 1789000300,
"nbf": 1789000000,
"jti": "5c1f...",
"response_type": "code",
"redirect_uri": "https://app.example.com/cb",
"scope": "openid profile",
"code_challenge": "$CHALLENGE",
"code_challenge_method": "S256",
"state": "$STATE"
}

Send it as request, beside client_id, to /authorize or /par. Type it oauth-authz-req+jwt.

Claim or property Rule
Signature RSA, RSA-PSS, or ECDSA, with a registered key. none and shared secrets are refused.
iss The client.
aud This issuer.
client_id The same as the one beside it.
exp Required, at most an hour away.
nbf If present, not in the future and at most an hour ago.
jti If present, spent on first use.
  • A pushed request or a request object is the whole request. masks ignores anything else in the /authorize query.
  • A request object that fails, or contains its own request or request_uri, is refused with invalid_request_object.
  • requireSignedRequestObject requires signing, as requirePushedAuthorizationRequests requires pushing.
  • masks accepts only a request_uri it issued from /par, and never fetches one from a client URL.

A bearer token works for anyone who holds it. DPoP (RFC 9449) binds a token to a key the client keeps, so a copied token is useless without the key. masks binds a token for any client that asks.

  1. The client generates a key pair and keeps it.

  2. For each request it signs a proof: a JWT typed dpop+jwt naming the method and URL, with the public key in its header.

  3. It sends the proof in the DPoP header:

    POST /token HTTP/1.1
    DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2Iiwiandr...
  4. masks returns a bound token:

    { "access_token": "...", "token_type": "DPoP", "expires_in": 3600 }

The token’s cnf claim holds jkt, the key’s SHA-256 thumbprint (RFC 7638). The client presents it with the DPoP scheme and a new proof every time, and a proof sent with a token includes ath, the token’s hash.

GET /userinfo HTTP/1.1
Authorization: DPoP eyJhbGciOiJSUzI1NiIs...
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs...
Refused Why
A bound token sent as Bearer A bound token needs a proof.
A proof signed by another key
A proof whose htm or htu names another request
A proof with no ath, or the wrong one
A proof older than 90 seconds or more than 30 in the future A 60-second window plus 30 seconds of leeway.
A proof with no jti, or one already seen A replay.
A proof carrying a private key, a symmetric key, or alg: none
Any algorithm but ES256, ES384, ES512, PS256, PS384, PS512, RS256

A refused proof returns 401 with WWW-Authenticate: DPoP error="invalid_dpop_proof" and the accepted algs at a resource, or 400 invalid_dpop_proof at /token. masks sends no nonce.

  • Authorization code. dpop_jkt on /authorize binds the code to the key, so an intercepted code cannot be redeemed:

    GET /authorize?...&dpop_jkt=NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs
  • Refresh token. A bound grant’s refresh token is bound too. A mismatched proof is refused before the refresh token is spent.

A client with dpop_bound_access_tokens set is refused a token without a proof. A client sets it at registration, and a manager sets it with dpopBoundAccessTokens on updateClient.

{ "client_name": "...", "dpop_bound_access_tokens": true }

A scope says what kind of thing an app may do. authorization_details (RFC 9396) says exactly what, such as one payment of 123.50 EUR to one payee.

An approved client that serves those requests declares each type it accepts under Authorization details on its page in /manage. A declaration has a label for the consent screen, a schema, and optionally remember:

{
"payment_initiation": {
"label": "Send a payment",
"remember": 2592000,
"schema": {
"type": "object",
"required": ["instructedAmount"],
"properties": {
"instructedAmount": {
"type": "object",
"required": ["currency", "amount"],
"properties": {
"currency": { "type": "string", "enum": ["EUR", "USD"] },
"amount": { "type": "string", "maxLength": 20 }
}
},
"creditorName": { "type": "string", "maxLength": 140 }
}
}
}
}
  • A schema uses type, properties, required, additionalProperties, items, enum, maxLength, maxItems, minimum, maximum, title, and description. A field the schema does not list is refused unless additionalProperties is true.
  • remember is a number of seconds, up to 400 days. Without it, the type is asked about every time.
  • Discovery lists every declared type in authorization_details_types_supported.

The app that asks registers the types it uses in authorization_details_types, and sends the details as a JSON array to /authorize, /par, or in a signed request object:

GET /authorize?response_type=code&client_id=…&scope=openid
&authorization_details=[{"type":"payment_initiation","instructedAmount":{"currency":"EUR","amount":"123.50"},"creditorName":"Merchant A"}]

masks refuses with invalid_authorization_details a type nobody declared, a type the app did not register, an entry its schema does not allow, more than ten entries, or more than 8KB. client_credentials does not accept them.

The consent screen lists each entry with its label and fields. It asks about every entry, even when the person allowed the app before, unless each entry was allowed before and its type sets remember. An allowed entry of such a type is kept on the person’s consent for that long. A request with any entry that is not kept asks about all of them. An app that skips consent still asks about entries that are not kept.

The person sees what is kept, and until when, under Apps you have let in on their account page. A manager sees it on the person’s and the app’s consents in /manage. Revoking the app, or the end of the client’s consent lifetime, forgets it.

  • The granted details are in the token response, the access token’s authorization_details claim, and introspection.
  • At the token endpoint the app can send authorization_details to narrow an access token to some of the granted entries. A refresh token keeps every granted entry, and a token exchange passes them on or narrows them. Nothing can add an entry that was not granted.