API Tokens (PATs)
Most authentication in Dashify is for humans sitting in front of browsers. Sessions, cookies, passkeys, they all assume there is a person clicking buttons.
But sometimes a script needs to call the API. A nightly export. A Zapier integration. The IdP pushing SCIM updates. A custom tool the customer is building. None of those have a browser. None of them can scan a QR code or tap a fingerprint.
For these cases Dashify issues Personal Access Tokens (PATs), long, opaque strings that authenticate API calls on behalf of the user who created them.
What a PAT looks like
A Dashify PAT is a string like:
dashify_pat_a3f9...4c2e
The prefix dashify_pat_ makes the token instantly identifiable in logs and grep, if a token leaks into a public repository, automated scanners (GitHub's secret scanning, for instance) recognise it and alert.
The hex part is a long random value. No information is encoded in it; it is just an opaque identifier.
When a PAT is generated, the API also returns a short prefix (the first 8 characters of the hex part). The prefix is shown to the user as a way to identify the token in lists. The full token is shown once at creation time and never again.
How they are stored
The full token is never stored in the database. What is stored is:
- The prefix (visible).
- A SHA-256 hash of the full token.
- The owner (user id), the scopes, an optional expiry, the creation time, and the last-used time.
When a request arrives with a PAT in the Authorization: Bearer ... header, the API hashes the submitted token with SHA-256 and looks for a matching record. If found, the request is authenticated as the token's owner.
If the database is breached, the attacker gets a list of hashes, but the actual tokens are gone forever, locked behind the irreversibility of SHA-256. No PAT can be reconstructed from a hash.
Why SHA-256, not Argon2?
Passwords use Argon2 because users pick bad passwords, short, memorable, predictable. The slowness of Argon2 makes brute forcing those bad passwords expensive.
PATs are not bad passwords. They are 256 bits of cryptographic randomness. There is no dictionary attack against them, no rainbow table that helps. Brute-forcing a PAT means trying 2^256 possibilities, which is more atoms than there are in the observable universe. Adding Argon2's slowness on top would not help, it would only slow down legitimate verification.
So PATs use SHA-256: fast on the verify path, irreversible against the leak path. Right tool for this job.
How a PAT request flows
Notice that the path is different from the cookie-based auth path, but only at the very start. Once the API has authenticated the request, every downstream layer (tenant scoping, RBAC, audit logging) is identical. A PAT's owner has the same access as if they had logged in normally.
Scopes
Each PAT can be created with a list of scopes that limit what it can do:
read, can call read-only endpoints.write, can call mutating endpoints.scim, can call the SCIM endpoints.*, can do anything the owner can do.
The scope check happens after authentication. If a PAT scoped only to read is used on a POST endpoint, the API returns 403.
In practice most PATs are scoped narrowly, a SCIM connector PAT only needs scim; a backup script only needs read. Limiting scope limits the damage of a leak.
Expiry
PATs can be created with no expiry (lasts until revoked) or with one (auto revokes after the date). Long-lived PATs are convenient but risky; short lived PATs require rotation but are safer.
The platform does not force a choice; it just makes both options available. For SCIM connectors, the convention is no expiry, because rotating them in production IdP configs is painful. For ad-hoc scripts, expiry of a few weeks is sensible.
Revocation
A PAT can be revoked from the user's security page or from /organization/api-tokens (admins). Revocation deletes the PAT record. The next request using that PAT fails authentication.
When a user is deactivated (manually or via SCIM), every PAT they own is automatically revoked. There is no need for an admin to remember.
Last-used tracking
Every PAT has a lastUsedAt field that updates on every successful authentication. Two reasons:
- It helps the owner (or an admin) spot stale PATs that nobody is using anymore. The /api-tokens page shows "last used 73 days ago" next to each token.
- It is a soft signal of compromise. A PAT that has not been used in months suddenly being used from a new IP is suspicious.
The update is non-blocking, it happens in the background after the request has already returned, so it never adds latency.
CSRF and PATs
Browser-based requests need CSRF protection because the browser is sneaky about sending cookies. Server-to-server requests using PATs do not need CSRF protection, there is no browser, no cookie, no cross site forgery vector.
The auth middleware detects the request was authenticated via a PAT and bypasses the CSRF check for that request only. Cookie-authenticated requests on the same endpoint still go through CSRF.
When to use PATs vs SCIM vs SSO
- A user logging into the web app, SSO if the tenant has it, otherwise password+2FA.
- A user logging in via a passkey, same flow, no PAT involved.
- An external script calling the API as a user, PAT.
- An IdP pushing user lifecycle events, PAT (with SCIM scope).
- A first-party Dashify worker calling the API, internal JWT (covered on the JWT page), not a PAT.
The right primitive for the right job. The PAT is for "long-lived credential, server-to-server, behaves as a specific user."
Key takeaways
- PATs are long, opaque strings,
dashify_pat_<hex>, used to authenticate scripts and integrations. - They are stored only as SHA-256 hashes. The plaintext is shown to the user once and never again.
- PATs are scoped so a leak gives away only what the scope allows.
- PATs are automatically revoked when their owning user is deactivated.
- PATs bypass CSRF because they are not used by browsers.
- SHA-256 is the right hash for PATs (high-entropy input); Argon2 is the right hash for passwords (low-entropy input).