DEV Community

Akash Devdhar
Akash Devdhar

Posted on Originally published at akashdevdhar.com

Authentication vs Authorization: Why Most AI Apps Get It Wrong

Here is the failure mode I am seeing over and over in AI app reviews. The app checks who you are exactly once, at login, and after that it treats the agent as if it has a blank check for the rest of the session. Read a file, sure. Send an email, sure. Delete a record, why not, you logged in twenty minutes ago only. That is not an authorization model, that is a login screen pretending to be one.

Authentication and authorization are not the same question asked twice, they are two entirely different questions, and most AI apps are answering the first one and quietly skipping the second, forever, for the whole session.

The one question that gets asked, and the one that doesn't

Authentication happens once, at the door: who is this. The app checks a password, a session cookie, an SSO token, whatever, and it is satisfied. Fine so far, this part most teams actually get right.

Authorization is supposed to happen continuously, at every door inside the building: now that we know who this is, should this specific action, on this specific resource, right now, be allowed. This is the part that AI apps are skipping basically. Traditional web apps got away with checking authorization loosely because a human was the one clicking buttons, slowly, with natural friction built into every step. An AI agent has no such friction. It can chain fifteen tool calls in three seconds, and if your authorization model is "well, they logged in," every single one of those fifteen calls just inherited the same blank check.

What this actually looks like when it breaks

The one-time gate model most AI apps are actually running

Say your AI assistant is authenticated as a support engineer who can read customer tickets. Somewhere in its tool list, there is also a "run_sql_query" tool, because someone needed it for a debugging session six months ago and nobody removed it. The authentication layer sees a valid, logged-in support engineer. It has no concept of "this specific tool call, on this specific table, for this specific reason, right now." So the agent runs the query. Not because anyone decided the support engineer should have that access for that task, but because nobody built the layer that would have stopped it.

This is exactly the gap that RBAC and, better, scoped OAuth tokens are supposed to close, and it is also exactly the gap that gets silently reintroduced when a team wires an agent up with one long-lived service account "to keep things simple."

What continuous authorization actually looks like

Continuous, per-action authorization for an agent

The difference is not exotic. Every tool call the agent makes should carry a scope, and something on the other end should actually check that scope before doing anything, every single time, not just once at session start. This is what OAuth 2.0 scopes were built for, and it is what the newer Rich Authorization Requests spec (RFC 9396) extends further, letting you express authorization at the level of "read tickets in this one project" rather than a flat "read tickets, all of them, everywhere."

A quick code example

Here is roughly what the one-time-gate version looks like, which is unfortunately close to what a lot of agent frameworks default to:

# Checked once, at session start, then trusted for everything after
def handle_tool_call(session, tool_name, args):
    if session.is_authenticated:
        return execute_tool(tool_name, args)
    raise PermissionError("not logged in")
Enter fullscreen mode Exit fullscreen mode

And here is the per-action version, where the token's scope is actually checked against what the specific tool call is trying to do:

def handle_tool_call(session, tool_name, args):
    token = session.access_token  # short lived, scoped, from the OAuth flow
    required_scope = TOOL_SCOPE_MAP[tool_name]  # e.g. "tickets:read"

    if required_scope not in token.scopes:
        raise PermissionError(f"token lacks scope: {required_scope}")

    if not resource_matches_scope(args, token):
        # e.g. token is scoped to project_id=42, but the call targets project_id=17
        raise PermissionError("resource outside token's authorized scope")

    if token.is_expired():
        raise PermissionError("token expired, re-authorize")

    return execute_tool(tool_name, args)
Enter fullscreen mode Exit fullscreen mode

The second version has more code, I know, but every one of those checks is answering a question the first version never even asked. Which tool, on which resource, under which still-valid grant.

Standards worth reading

  • RFC 6749, The OAuth 2.0 Authorization Framework, for the base scope model.
  • RFC 9396, OAuth 2.0 Rich Authorization Requests, for expressing authorization at a finer grain than a flat scope string.
  • NIST SP 800-207, Zero Trust Architecture, for the broader principle this post is really just a specific case of: never trust a request just because it came from an already-authenticated session, verify it again for the specific action being taken.

The takeaway

Logging someone in, or logging an agent in, answers exactly one question and that question is not "should this happen." If your AI app's entire authorization model is "well, the session is valid," you do not have authorization at all, you have authentication wearing a bigger hat. Check the action, not just the actor, and check it every time, not just at the door.


Akash Devdhar is a Senior Software Engineer specializing in enterprise identity, authentication, authorization, and AI infrastructure. He writes about building secure AI systems using OAuth, OIDC, RBAC, and modern identity architectures.

Top comments (0)