Profile
GET /v1/profile reads the profile of the user who granted your app access. It
is one of the DFOS API's credential-gated routes — the others are the
membership routes and
GET /v1/credential — and every other endpoint is
anonymous. The credential itself comes from
Sign in with DFOS, where the user
approves a read:profile and/or read:email grant on a DFOS-hosted consent
screen.
curl https://api.dfos.com/v1/profile \
-H 'Authorization: DFOS <request proof>' \
-H 'X-Credential: <credential>'
Under a credential covering read:profile read:email:
{
"did": "did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc",
"username": "bvalosek",
"displayName": "Brandon",
"description": "Building things in Austin.",
"avatarUrl": "https://cdn.dfos.com/…",
"email": "brandon@example.com",
"createdAt": "2025-11-04T16:22:00.000Z"
}
Under a credential covering read:profile alone, email is absent — not
null:
{
"did": "did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc",
"username": "bvalosek",
"displayName": "Brandon",
"description": "Building things in Austin.",
"avatarUrl": "https://cdn.dfos.com/…",
"createdAt": "2025-11-04T16:22:00.000Z"
}
And under a credential covering read:email alone, the profile fields are
absent too. This is the whole response:
{
"did": "did:dfos:6encc4akrze2ah9kntzd9tc8zr24crc",
"email": "brandon@example.com"
}
Which scopes open which fields
Either credential scope opens the route, and then each scope serves its own
fields. Only did is unconditional:
| Your credential carries | Route | Profile fields | email |
|---|---|---|---|
read:profile | 200 | present | absent |
read:email | 200 | absent | present |
| both | 200 | present | present |
| neither | 403 | — | — |
"Profile fields" means username, displayName, description, avatarUrl,
and createdAt.
read:email stands on its own: an app that only needs an account key can ask for
it alone, call this route, and get back exactly {did, email}. It is not given a
name and a handle it did not ask for — a DFOS profile can be private, in
which case those fields are served nowhere anonymously either, so "they're public
anyway" is not a reason to include them.
The scope decides the shape, and nothing else does. A user's privacy toggle never changes what your credential returns: a subject reading their own data through a grant they issued is always permitted, so the same credential gets the same fields whether or not their profile is publicly listed.
There is no path parameter
You cannot name a user. The route has no {user} segment, and adding one to the
URL does not address anyone else's profile — the credential you present selects
the subject, and the subject is always the person who issued that credential.
This is the design, not an omission. An endpoint with a user parameter would have to answer "may I read this person's profile?" on every request, and would leak whether a named user exists at all. With the subject fixed by the credential, there is no identifier for a caller to probe.
Fields
| Field | Served under | Notes |
|---|---|---|
did | always | The user's protocol DID. Canonical and stable — store this. |
username | read:profile | Their handle, or null if unset. A mutable alias; never key on it. |
displayName | read:profile | Display name, or null. |
description | read:profile | Profile bio, or null. |
avatarUrl | read:profile | Permanent public CDN URL for their avatar, or null if they have none. |
createdAt | read:profile | When they joined DFOS (ISO 8601 UTC). |
email | read:email | The account email — available nowhere else on this API. |
did is unconditional because it is not a disclosure: it is the identity your
credential is already rooted at, so naming it back tells you nothing you did not
arrive holding.
Every other field is absent, never null, when your grant does not cover it.
null on this response means "the user has none" — an unset handle, an empty bio
— so absence is an authorization fact, not a data fact. Check for the key, not
for a falsy value.
avatarUrl is a permanent, unsigned CDN link — the same one
GET /v1/users/{user} serves — because an avatar is public media.
It is stable while the media is referenced, so it is safe to put in an <img>
and cache alongside the display name. It is not a signed URL and carries no
expiry. No other media appears on this response.
Calling it
Acting for a user, the request carries two headers — the credential and a fresh request proof signed over this exact method, host, path, and (empty) body. The mechanics, the freshness window, and the refusal codes are all in Authentication; the credential lifecycle is in Scopes and credentials.
Reading your OWN profile needs no credential: sign an identity proof with one of
your own keys and send Authorization alone, per
Reading your own data needs no credential.
That call returns every field above, because there is no narrower grant to apply.
How to get a credential to call this with: Set up § 7 takes you from the scope set you ask for to a runnable call against this route.
In short:
401— your proof was wrong. Sign a fresh one and retry.403— your proof was right and your grant is not. The credential was revoked, expired, or carries neitherread:profilenorread:emailfor this host. Retrying will not help; ask the user for a new grant. Note that a credential missing only ONE of the two scopes is not a403— the route succeeds and the fields that scope covers are simply absent.503— DFOS could not complete verification. Retry with backoff.
A revoked credential stops working on the very next request. Handle 403 as a
normal, expected state — it is how a user withdrawing access reaches your app —
rather than as an error to retry through.
Caching and privacy
This response is served Cache-Control: no-store and is never shared-cached:
unlike every anonymous response on this API, it is specific to one viewer.
Don't put it behind a shared cache or CDN of your own either.
The email you read — when your grant covers read:email — is the user's real
account email. Revoking the credential ends your access immediately; it does not
un-share what you already read, so store only what your app actually needs, and
delete it when the grant ends. Asking for read:email only when you actually
need it is the cheapest privacy decision available to you, and users approve
shorter lists more often.