There’s things that are confusing to most people, and then there’s the identity world. I don’t know what it is about OAuth2, OpenID Connect, and all the (many, many) related specs, but there are concepts in there that have taken me a very long to fully grasp - and some that I am still wrapping my head around.

For example, one thing that took me a while to internalize and eventually appreciate is the concept of identity principals: two tokens can hit the exact same API, using the exact same bearer scheme, and mean two completely different things about who’s asking.

There’s a real gap between knowing the mechanics of authorization_code versus client_credentials, and actually living with what each one does to access control once the token reaches your API. I was familiar with the spec, and had been implementing them inside dozens of apps for many years. It still took a while for it to click in me, that the two do not just represent different sequential flows, but carry two fundamentally different philosophies.

The difference comes down to who the token represents: a person, or an application.
In the identity world, that’s called a principal: the entity a token represents. This is the who a system authenticates, and then makes every authorization decision about, downstream.

“We are not the same” meme. A man wearing a suit (actor Giancarlo Esposito) with text overlayed: “You represent an app, I represent a user, we are not the same”

User vs app principals

When your principal is a user, the token represents a real person that signs in and consents to allowing your app to access their data. This is also called the “on-behalf-of” flow: the user’s existing permissions come along with them into whatever app they’re using. The OAuth2 grant type is generally authorization_code.

In this scenario, the app becomes just another interface to display (and/or manipulate) data the user already has access to.

For example, if you were building an email client, you’d be using the “on-behalf-of” flow to allow your app to access each individual user’s mailbox. Each user that signs into your app would have access to their own emails (and only theirs), generally through RESTful APIs (or some other protocol like GraphQL, IMAP, etc), and could direct the app to perform operations on them (like deleting a message).

With an app (or service) principal, no human is in the loop. The app authenticates on its own, using a client ID and secret (or a certificate or a managed identity, depending on the platform). The token represents the application itself, not any particular user of it. The OAuth2 grant type is client_credentials.

To stay in the example of an email app, you’d find yourself needing service accounts for admin/maintenance operations, such as backing up everyone’s accounts. In this case, your app’s access is not tied to a specific user, but rather to the permissions granted to the app itself.

What’s actually on the token

Tokens, which are generally in the JWT (JSON Web Token) format, carry claims describing the principal. The most relevant here is sub (subject), which contains the identity of the principal itself.

On behalf of a user:

{
  "iss": "https://auth.example.com",
  "sub": "ale@foo.com",
  "name": "Ale Segala",
  "client_id": "analytics-app-123",
  "scope": "data:read comments:write"
}

sub is ale@foo.com. The identity on the token is a person. client_id still records which app requested the token, but the subject the API cares about is the human behind it.

Client credentials:

{
  "iss": "https://auth.example.com",
  "sub": "myapp-123",
  "name": "Reporting Service",
  "client_id": "myapp-123",
  "scope": "data:read"
}

sub is myapp-123, the app’s own client ID. There’s no user anywhere in this token: the app and the subject are the same entity.

What each token can actually reach

User-scoped: intersected with what the person can already do

Even if the app requests more scope than it strictly needs, the resulting token can never reach something the user personally cannot. Practically, access ends up being the intersection of what the app asked for and what the user is already permitted to do.

In the resource’s audit logs, the entity shown as accessing the data is the user, not the app.

App-scoped: whatever was granted to the app, full stop

Access here isn’t tied to any particular person. Every user, script, or teammate who can trigger a call through the app inherits that exact same access, because the token carries the app’s own identity, not theirs. Someone with zero permissions of their own can, through the app, reach data they could never touch directly.

In the resource’s audit logs, conversely, the entity that appears as accessing the data is the app itself, and normally tokens have no indication of who the end user (the one currently connected to the app) is.

A litmus test - with some caveats

Sometimes you may read that the “on-behalf-of” flow is tied to operations a user actively performs while online. Meanwhile, using app tokens is for background jobs. This is a quick litmus test, but in reality things are a bit more nuanced and this simplification can be “dangerous”.

On one side, user tokens do not necessarily require a user to be actively present. With OAuth2, apps can request the offline_access scope which means they receive a refresh token. With that, apps can request updated session tokens when they expire, automatically, without the user being present. The app can continue to perform operations acting as the user, even after the user stopped using the app.
Staying in the example of the email app, this could be used to send emails scheduled for a future date: the app needs to be able to perform the action even if the user isn’t active.

On the other side, app tokens are not relegated to background jobs/tasks only. An app could use its own credentials to allow users to access data while they are connected, performing authorization checks inside the app itself.

The latter is an incredibly common use case. Most apps that connect to a database do so with credentials (tokens, API keys, user/password combinations) that represent the app itself and have full access to all of the data in the store. It’s the app itself, in its own business logic, that then determines who has access to what data.

The main point is that with user tokens, the resource server (the system that contains the data you’re accessing, like the API you’re invoking or database you’re connecting to) receives tokens that are scoped to a single user and carry over the access the users themselves have. With app tokens, instead, the principal is an app and the resource server doesn’t have information on the identity of the end user.

PS: API keys are a form of app tokens too - they are just long-lived and not JWTs

A concrete example: Azure Cosmos DB

Say I’m storing data in Azure Cosmos DB, and I’ve been granted the Cosmos DB Built-in Data Contributor role, scoped down to a single container: orders (a “container” is Cosmos DB’s term for what a relational database would call a table, roughly speaking). I have no role assignment at all on other containers like customers or inventory.

Two different integrations sit in front of that same database.

An internal app using an “on-behalf-of” flow. When I sign in, the app exchanges my session for a token on my behalf and uses it to talk to Cosmos DB. Because the token’s sub is me, Cosmos DB evaluates the request against my own RBAC role assignments. I can read and write orders through the app, and nothing else, exactly as if I’d connected directly (e.g. using the Azure Portal).

A backend job using client credentials. The same app also runs a nightly reporting job under a separate app registration, and that registration has been granted Cosmos DB Built-in Data Reader at the account level: read access to every container in the database. Every user who can trigger that reporting path, including people with zero Cosmos DB access of their own, can now read orders, customers, and inventory. My own write access to orders doesn’t carry over, because the token no longer represents me: it represents the app’s own service principal.

Container My direct RBAC Via the OBO app Via the client-credentials job
orders Data Contributor (read/write) Read/write Read only
customers None None Read only
inventory None None Read only

Notice customers: I have no direct access to it at all, yet I can read it the moment it’s fetched through the client-credentials job. And notice orders: even though I can write there directly, the same job caps me at read-only, because that’s all it was ever granted.

The app’s grant is its own access boundary: it doesn’t inherit my restrictions, and it doesn’t inherit my permissions. It just happens to sit behind the same API I’d otherwise call directly.