Misk Authentication & Access Control: Callers, Capabilities, and Audit


Series: Building Production Services with Misk — Part 9 of 24

Back in Part 5 every action carried an @Unauthenticated annotation, and I promised it wasn’t optional. This is the post that explains why. Misk authentication is built on a small, opinionated model — every action declares its access posture, callers are either services or users with capabilities, and a single interceptor enforces the lot. It’s a refreshingly explicit alternative to scattering security config across a dozen places. Let’s wire it.

Access is mandatory

Every Misk action must declare who may call it. There’s no implicit default, and that’s deliberate: you cannot accidentally ship an endpoint with undefined authorization, because the framework won’t let an action exist without an access annotation. The two annotations are tiny — here’s the real @Authenticated:

annotation class Authenticated(
  val services: Array<String> = [],       // these calling services are allowed
  val capabilities: Array<String> = [],   // users with at least one of these are allowed
  val allowAnyService: Boolean = false,
  val allowAnyUser: Boolean = false,
)

annotation class Unauthenticated              // open endpoint

So an action is one of: open (@Unauthenticated), or restricted to specific services and/or user capabilities (@Authenticated(...)):

@Authenticated(capabilities = ["payments_admin"])   // a human with this capability
fun refund(...) { ... }

@Authenticated(services = ["ledger"])               // only the ledger service may call
fun postEntry(...) { ... }

@Authenticated(allowAnyUser = true)                 // any authenticated human
fun whoami(...) { ... }

The annotation reads as the access policy, right there on the handler. No external rules file, no separate security DSL — the contract lives with the endpoint.

The caller model: services and capabilities

Misk’s authorization rests on MiskCaller, which encodes the two kinds of caller a service deals with:

  • Services authenticate by identity — a service name. @Authenticated(services = ["ledger"]) means “only the service named ledger.”
  • Users authenticate by capability — a set of granted permissions. @Authenticated(capabilities = ["payments_admin"]) means “a user holding at least one of these capabilities.”

A MiskCaller carries a user (or a service) and a set of capabilities. This capability model is the important design choice: you don’t check for named users or hard-coded roles, you check for capabilities, which decouples “what this endpoint requires” from “who currently has it.” Granting access becomes a matter of assigning a capability upstream, not editing the service.

How it’s enforced

Here’s where Part 7 pays off: access control is just an ApplicationInterceptor. Installing AccessControlModule wires it in, and the module’s own source shows exactly that:

class AccessControlModule : ActionScopedProviderModule() {
  override fun configureProviders() {
    multibind<ApplicationInterceptor.Factory>().to<AccessInterceptor.Factory>()
    newMultibinder<AccessAnnotationEntry>()
    // ...
  }
}

The AccessInterceptor runs on every action, reads the action’s access annotation, compares it against the authenticated MiskCaller, and rejects the request with a 403/401 if it doesn’t match — before your handler runs. Because it’s an application interceptor, it works on the decoded, action-aware layer, which is exactly where “does this caller satisfy this action’s policy?” belongs. You don’t call it; you annotate, and it enforces.

Authenticators: where the caller comes from

The interceptor needs to know who is calling, and that’s the job of a MiskCallerAuthenticator — a multibound component that derives a MiskCaller from the request (typically from headers set by an auth proxy or service mesh). You contribute one (or several) via multibinding. For local development and tests, Misk ships a fake, which the exemplar installs:

class ExemplarAccessModule : KAbstractModule() {
  override fun configure() {
    install(AccessControlModule())
    multibind<MiskCallerAuthenticator>().to<FakeCallerAuthenticator>()
    // ...
  }
}

In production you’d multibind a real authenticator that extracts the caller’s identity and capabilities from however your infrastructure conveys them (a signed header from an edge proxy, mTLS service identity, etc.). The action code never changes — it always just reasons about a MiskCaller; only the authenticator that produces one differs between environments.

For purely local runs, the exemplar also binds a development caller so you’re “logged in” as someone with the right capabilities:

bind<MiskCaller>()
  .annotatedWith<DevelopmentOnly>()
  .toInstance(MiskCaller(user = "triceratops", capabilities = setOf("admin_console", "customer_support", "users")))

Custom access annotations: the pattern that scales

Inlining capability strings on every action (@Authenticated(capabilities = ["admin_console"])) gets repetitive and error-prone fast — one typo’d capability and an endpoint is silently mis-secured. Misk’s answer is AccessAnnotationEntry: define a semantic access annotation once, map it to the required capabilities in a module, then annotate actions with the meaningful name. The exemplar does this for its dashboards:

multibind<AccessAnnotationEntry>()
  .toInstance(AccessAnnotationEntry<AdminDashboardAccess>(capabilities = listOf("admin_console")))

multibind<AccessAnnotationEntry>()
  .toInstance(AccessAnnotationEntry<SupportDashboardAccess>(capabilities = listOf("customer_support")))

Now actions are annotated with @AdminDashboardAccess rather than a raw capability list, and the capability mapping lives in exactly one place. Change which capabilities grant admin access and you edit one binding, not every admin endpoint. This is the access pattern you should reach for in any real service — name your access policies, don’t scatter capability strings.

Reading the current caller

Sometimes an action needs the caller itself — to scope a query to the user, to log who did what. Misk exposes the authenticated caller as ActionScoped<MiskCaller>, injectable into your action:

@Singleton
class MyAction @Inject constructor(
  private val caller: ActionScoped<MiskCaller>,
) : WebAction {
  @Get("/me")
  @Authenticated(allowAnyUser = true)
  fun me(): MeResponse {
    val who = caller.get()
    return MeResponse(user = who.user)
  }
}

ActionScoped<T> is request-scoped context produced by the interceptor chain — caller.get() returns the caller for the current request. (Testing ActionScoped has its own helpers, @WithMiskCaller, which we’ll use in Part 23.)

Audit logging

Authorization decides whether an action runs; auditing records that it ran. The misk-audit-client module provides an AuditClient interface for shipping audit events to a data warehouse, with implementations to suit each environment:

  • NoOpAuditClient — a real-environment client that sends nothing, for services that don’t need centralized auditing.
  • FakeAuditClient — for tests, available as a test fixture, so you can assert “this action emitted this audit event.”
  • A real client you implement — the exemplar’s ExemplarAuditClient — bound like any dependency:
class ExemplarAuditClientModule(private val config: AuditClientConfig) : KAbstractModule() {
  override fun configure() {
    bind<AuditClientConfig>().toInstance(config)
    bind<AuditClient>().to<ExemplarAuditClient>()
  }
}

Inject the AuditClient into actions that perform sensitive operations and emit an event recording the caller and what changed. For a payments-shaped service, “who issued this refund?” is not a question you want to answer with grep — auditing makes it a query.

Production notes & gotchas

  • Never reflexively reach for @Unauthenticated. It’s correct for health checks and genuinely public endpoints, and a liability everywhere else. The annotation is the policy; make it say what you mean.
  • Prefer custom access annotations over inline capabilities. @AdminDashboardAccess mapped once via AccessAnnotationEntry beats capabilities = ["admin_console"] copy-pasted across twenty actions. One place to change, no typos.
  • The fake authenticator is for dev and tests only. FakeCallerAuthenticator and the @DevelopmentOnly caller make local runs convenient; shipping them to production means everyone is triceratops. Bind a real authenticator per environment.
  • Capabilities, not roles or user lists. Check capabilities so access is granted upstream by assigning them, not by editing the service every time the org chart changes.
  • Audit the sensitive paths. Refunds, config changes, data exports — emit audit events from these, and use FakeAuditClient in tests to prove you did.

What’s next

Capability checks cover most authorization, but some policies are too rich for annotations — rules that depend on request data, relationships, or external state. In Part 10: Authorization with Open Policy Agent (misk-policy) we’ll wire Misk to OPA for type-safe, externalized policy decisions.


Target keywords: misk authentication, misk access control, MiskCaller.

Comments