What's the best way to secure API endpoints and allow it only from the front-end?

default discord avatar
iamlinkus
last month
202

I have a website (next.js) and its getting the content from payload api. Currently, I have access control made so that anyone can read it, meaning anyone with the link to the api can see the json output.



The problem is that some of the content should be secret (for example a document that's available only after the user fills out a form (email address)). But with current access controls anyone that has the link can just go and see the json output straight from the API.



What's the best way of handling this in payload (+ nextjs)? Do I create a "api token" global and make it public, and then use it in all my fetch requests within the server components of my front end? Or is there a better way?

  • default discord avatar
    thisisnotchris
    last month

    When you create a collection, you'll want to explicitly list permissions



    For example, if you have a form submission collection, you'll want to the user to be able to "create" but maybe not read/delete/update



    Or maybe you create a field on your Users collection that defines some roles, you could also differentiate by role



    Documentation on Access Control:

    https://payloadcms.com/docs/access-control/collections


    Regarding authentication, I would recommend reading through

    https://payloadcms.com/docs/authentication/overview

    to understand the available methods



    I personally use the cookie sessions, but it all depends on your setup

  • default discord avatar
    iamlinkus
    last month

    So even if I don't have users in my front end (it's a simple marketing site), I would still need to create a user for this purpose and then in my server-side components log in with a user specifically created for the front-end to get the functionality I want, right?

  • default discord avatar
    thisisnotchris
    last month

    Hmm



    I think I'm misunderstanding @iamlinkus



    Do you mind explaining again, I'm sorry

  • default discord avatar
    iamlinkus
    last month

    No worries, thanks for trying to help me.



    Let me explain it with a different example.

  • default discord avatar
    thisisnotchris
    last month

    Sure thing

  • default discord avatar
    iamlinkus
    last month

    I have a backend, that has an API (payload). And I have a website, a simple next.js front-end, which gets the content through the backend (payload) api. What I want to do is make the api return the content only if I fetch the content with some kind of an API key. If I don't have an API key (either through headers, or the request body), I should not be able to fetch the data.

  • default discord avatar
    thisisnotchris
    last month

    Ahhh okay I see

  • default discord avatar
    iamlinkus
    last month

    Now I'm used to working with different api's and they usually require to use an API_KEY within the request headers for it to actually return the data.



    So without the front-end having to actually "log in".

  • default discord avatar
    thisisnotchris
    last month

    Right, in this case you have a few options



    Is the content created by users?



    Are they the owners of the documents



    Generally a user would make a fetch request with their credentials, which would then provide a token or set a cookie that authorizes them



    In your scenario



    You want a global api key?

  • default discord avatar
    iamlinkus
    last month

    The content is created by the payload admin

  • default discord avatar
    thisisnotchris
    last month

    Ok so not by users

  • default discord avatar
    iamlinkus
    last month

    yup

  • default discord avatar
    thisisnotchris
    last month

    How do you want users to see the content



    You dont want it to be public



    So how will the users know the api key



    Will you give it to them manually?

  • default discord avatar
    iamlinkus
    last month

    I don't want anyone having the api endpoint url being able to see the data. I want only my front-end site (using server-components) be able to fetch the data.

  • default discord avatar
    thisisnotchris
    last month

    Ah

  • default discord avatar
    iamlinkus
    last month

    Ideally, I would create an API key by hand in payload, and then use that API key within my fetch requests of the front-end site.

  • default discord avatar
    thisisnotchris
    last month

    Well



    If you use the api key in fetch requests



    Then the key is not secured



    You could just inspect the network requests

  • default discord avatar
    iamlinkus
    last month

    hmm, true



    oh, but wait, I'm using server components



    so the requests would be server-side, the client wouldn't be able to inspect them

  • default discord avatar
    thisisnotchris
    last month

    Oh if that is the case, why not use the Local API



    https://payloadcms.com/docs/local-api/overview


    and manually check if a provided key matches the one on your server

  • default discord avatar
    iamlinkus
    last month

    Prob is that back-end lives in another server/service ๐Ÿ˜ฆ



    Front-end is on vercel, back-end is on google cloud run.

  • default discord avatar
    thisisnotchris
    last month

    I see, we have a similar setup at work



    We have a frontend (we do manage login/reg)



    an API that talks to our db and payload



    and payload



    Our process is



    The user logs in on the frontend, payload sends an http-only cookie to the user



    The user then makes a request to the API, with credentials included (the cookie storing a jwt token)



    I then decrypt the JWT on our API, because the API and Payload both have the same secret key



    If I can decypt the JWT I know the request was made from the site



    Because they cant be tampered with

  • default discord avatar
    iamlinkus
    last month

    RIght, but this requires user login, which my front-end doesn't have. The content is fetched (accessed) only by the front-end code through server-side components.

  • default discord avatar
    thisisnotchris
    last month

    I think you can also lock down requests with CSFR config



    @jmikrut Any idea on how they would do this?



    I'm guessing its going to be strict CSFR or something so only requests from a specific IP are valid



    https://payloadcms.com/docs/authentication/overview#csrf-protection
  • default discord avatar
    iamlinkus
    last month

    Oh man, last year we worked with BMW and we had to take care of a lot of security stuff, even though the stuff (CSFR stuff mostly) we were developing for them was a marketing website without user registrations or user data or anything and it still gives me nightmares ๐Ÿ˜„

  • default discord avatar
    thisisnotchris
    last month

    Ah well payload makes it easy



    import { buildConfig } from 'payload/config';
    
    const config = buildConfig({
      collections: [
        // collections here
      ],
      csrf: [ // whitelist of domains to allow cookie auth from
        'https://your-frontend-app.com',
        'https://your-other-frontend-app.com',
      ],
    });
    
    export default config;


    So if a request doesn't come from the frontend IP



    It will be blocked



    That's for "cookie auth" though as it states



    So



    You may need to go into server.ts



    and look at where the express logic is



    and add cors / csrf to the express handler

  • default discord avatar
    iamlinkus
    last month

    Yeah, really hoping I can avoid cookie auth for this simple sitch

  • default discord avatar
    thisisnotchris
    last month

    which is a similar process

  • default discord avatar
    iamlinkus
    last month

    Unless I absolutely can't

  • default discord avatar
    thisisnotchris
    last month

    Well can you try something now



    I would say, add a cors config



    for starter



    allowing only your frontend IP



    then try to request data locally

  • default discord avatar
    iamlinkus
    last month

    tried it, still lets me see the output json if I'm fetching the api

  • default discord avatar
    thisisnotchris
    last month

    can i see your payload config?



    In addition, in server.ts



    import cors



    import cors from "cors";


    then before payload init, try



    app.use(
      cors({
        origin: [
          "http://localhost:4200",
        ],
        credentials: true,
      })
    );


    maybe without the credentials part



    that way you know cors is set on both payloads config and express



    (not sure if payloads auto sets cors on express)

  • default discord avatar
    iamlinkus
    last month
    import cors from "cors";
    import { buildConfig } from "payload/config";
    import path from "path";
    import { cloudStorage } from "@payloadcms/plugin-cloud-storage";
    import { gcsAdapter } from "@payloadcms/plugin-cloud-storage/gcs";
    // import Examples from './collections/Examples';
    import Users from "./collections/Users";
    
    // Globals
    
    import Homepage from "./globals/Homepage";
    import About from "./globals/About";
    
    // Assets Collections
    
    import Logos from "./collections/Logos";
    import Icons from "./collections/Icons";
    
    const gcAdapter = gcsAdapter({
      bucket: process.env.GCS_BUCKET,
      options: {
        credentials: JSON.parse(process.env.GCS_CREDENTIALS || "{}"),
      },
    });
    
    export default buildConfig({
      serverURL: process.env.PAYLOAD_URL,
      // cors: [process.env.FRONTEND_URL],
      cors: ["lol"],
      admin: {
        user: Users.slug,
      },
      globals: [
        Homepage,
        About,
        // Add Globals here
      ],
      collections: [
        Logos,
        Icons,
        Users,
        // Add Collections here
        // Examples,
      ],
      plugins: [
        cloudStorage({
          collections: {
            logos: {
              adapter: gcAdapter,
              disableLocalStorage: true,
              disablePayloadAccessControl: true,
              prefix: "logos",
            },
            icons: {
              adapter: gcAdapter,
              disableLocalStorage: true,
              disablePayloadAccessControl: true,
              prefix: "icons",
            },
          },
        }),
      ],
      localization: {
        locales: ["en", "lt", "de"],
        defaultLocale: "en",
        fallback: true,
      },
      typescript: {
        outputFile: path.resolve(__dirname, "payload-types.ts"),
      },
      graphQL: {
        schemaOutputFile: path.resolve(__dirname, "generated-schema.graphql"),
      },
    });
  • default discord avatar
    thisisnotchris
    last month

    cors: "lol" ?

  • default discord avatar
    iamlinkus
    last month

    lol



    ๐Ÿ˜„

  • default discord avatar
    thisisnotchris
    last month

    oh you removed it for example



    ok

  • default discord avatar
    iamlinkus
    last month

    yeah, tried having something random and see if I still see the output

  • default discord avatar
    thisisnotchris
    last month

    ok and your server.ts

  • default discord avatar
    iamlinkus
    last month

    but shouldn't this setting work already if I set it within my payload config?

  • default discord avatar
    thisisnotchris
    last month

    its simple enough where it would be silly not to try



    also add the csrf array with the same url as your frontend



    export default buildConfig({
      serverURL:
        process.env.NODE_ENV === "production"
          ? process.env.PAYLOAD_PUBLIC_SERVER_PROD
          : process.env.PAYLOAD_PUBLIC_SERVER_DEV,
      admin: {
        user: Admins.slug,
      },
      cors: [
        "http://localhost:4200",
      ],
      csrf: [
        "http://localhost:4200",
      ],
  • default discord avatar
    iamlinkus
    last month

    Ok, are we trying to see whether setting up cors and csrf will prevent anyone with the api url seeing the output json?

  • default discord avatar
    thisisnotchris
    last month

    yes



    We want to set cors / csrf on BOTH payload



    and the server.ts



    to make double sure

  • default discord avatar
    iamlinkus
    last month

    kk, one min



    ok, so my config is:


    cors: [process.env.FRONTEND_URL],


    And my server.ts looks like:



    import express from "express";
    import payload from "payload";
    import cors from "cors";
    
    require("dotenv").config();
    const app = express();
    
    app.use(
      cors({
        origin: [process.env.FRONTEND_URL],
        credentials: true,
      })
    );
    
    // Redirect root to Admin panel
    app.get("/", (_, res) => {
      res.redirect("/admin");
    });
    
    const start = async () => {
      // Initialize Payload
      await payload.init({
        secret: process.env.PAYLOAD_SECRET,
        mongoURL: process.env.MONGODB_URI,
        express: app,
        onInit: async () => {
          payload.logger.info(`Payload Admin URL: ${payload.getAdminURL()}`);
        },
      });
    
      // Add your own express routes here
    
      app.listen(process.env.PAYLOAD_PORT);
    };
    
    start();


    Going to

    http://localhost:8000/api/globals/about

    still lets me see all the content



    localhost:8000 being my payload url

  • default discord avatar
    thisisnotchris
    last month

    hmmmm



    what about csrf on payload config?



    did you add that too



    cors: [


    "

    http://localhost:4200

    ",


    ],


    csrf: [


    "

    http://localhost:4200

    ",


    ],

  • default discord avatar
    iamlinkus
    last month

    oh sorry yeah



    export default buildConfig({
      serverURL: process.env.PAYLOAD_URL,
      cors: [process.env.FRONTEND_URL],
      csrf: [process.env.FRONTEND_URL],
      admin: {
        user: Users.slug,
      },
    ...
  • default discord avatar
    thisisnotchris
    last month

    cool and server reloaded?

  • default discord avatar
    iamlinkus
    last month

    yup

  • default discord avatar
    thisisnotchris
    last month

    weird



    ok lets just try a basic middleware example



    // Define a list of allowed IP addresses
    const allowedIPs = ['127.0.0.1'];
    
    function ipFilter(req, res, next) {
      const clientIP = req.ip;
      if (allowedIPs.includes(clientIP)) {
        next();
      } else {
        res.status(403).send('Access denied');
      }
    }
    app.use(ipFilter);


    That would technically restrict it to only a certain IP



    (express)



    not to say someone couldnt try ip spoofing



    but lets see if at least that works

  • default discord avatar
    iamlinkus
    last month

    yes, getting access denied

  • default discord avatar
    thisisnotchris
    last month

    Niceee



    ๐Ÿ˜„

  • default discord avatar
    iamlinkus
    last month

    when going straight to the api url



    ๐Ÿ˜„

  • default discord avatar
    thisisnotchris
    last month

    Niceeee



    You can further harden things



    and make spoofing harder to do, or prevent it



    I'd read a bit about

    https://expressjs.com/en/guide/behind-proxies.html


    But for a basic use case, that middleware does work

  • default discord avatar
    iamlinkus
    last month

    But what does this mean for payload.config that had both csrf and cors setup to only allow the localhost and it didn't work.

  • default discord avatar
    thisisnotchris
    last month

    Well



    It's not for every request IIRC



    It's for requests with Auth enabled



    Is what payload handles

  • default discord avatar
    iamlinkus
    last month

    got it

  • default discord avatar
    thisisnotchris
    last month

    also

    https://www.npmjs.com/package/helmet


    is good for security



    maybe you can harden the ip approach

  • default discord avatar
    iamlinkus
    last month

    well, another prob is that without setting up an additional service in google cloud and doing a bunch of configs I won't be able to have a static ip for the front-end, so I'll have to search for a better solution not using IP's ๐Ÿ˜„

  • default discord avatar
    thisisnotchris
    last month

    @Jarrod @jacobsfletch do you guys have any input on this scenario?



    Hopefuly I'm not missing anything important that makes this kind of thing easier

  • default discord avatar
    iamlinkus
    last month

    Or worst case resort to trying out the programmatic login route if I won't manage to find a solution for a simple API key that would be set both on payload instance env vars and in the front-end server-side code

  • default discord avatar
    thisisnotchris
    last month

    Yeah I guess I'm just not familiar enough with your setup. Generally, anything on the frontend is visisble to the public

  • default discord avatar
    iamlinkus
    last month

    Yup, I just thought of this situation when testing payload and wondered if there's a graceful/easy solution for that



    Ideally, this would suffice to get the content from the API



      const res = await fetch('https://backend.com/api/about', {
        headers: {
          'api-key': 'dawoiawiodoaiwjdo',
        },
      })


    while still preventing anyone visiting

    https://backend.com/api/about

    in their browser and seeing all the content publicly

  • default discord avatar
    thisisnotchris
    last month

    oh



    I guess you could make a new user



    Get their JWT token



    then

    https://payloadcms.com/docs/authentication/overview#identifying-users-via-the-authorization-header


    make your website requests use the token



    obvs though anyone could see the token on the frontend in the network tab

  • default discord avatar
    iamlinkus
    last month

    not if you fetch on the server, not the client

  • default discord avatar
    thisisnotchris
    last month

    Then yes, that should work nciely



    nicely*

  • default discord avatar
    thisisnotchris
    last month

    Right but wasn't sure if it fit your reqs



    Create a user for the third-party app, and log in each time to receive a token before you attempt to access any protected actions
    Enable API key support for the Collection, where you can generate a non-expiring API key per user in the collection


    per-user



    But in this case, its one user



    and only the server will see it



    so that can work

  • default discord avatar
    iamlinkus
    last month

    Oh, it's for collections only, no globals ๐Ÿ˜ฆ



    Oh man this would solve all my problems if it was available for globals too ๐Ÿ˜„

  • default discord avatar
    thisisnotchris
    last month

    hmm



    its not for globals?



    that seems odd

  • default discord avatar
    iamlinkus
    last month

    yeah, itโ€™s specifically stated that itโ€™s for collections. Globals donโ€™t have an โ€˜authโ€™ option

  • default discord avatar
    thisisnotchris
    last month

    I think auth for Globals is a sound feature req to make



    https://discord.com/channels/967097582721572934/967104816893530222/1007375737961054268


    Looks like people have discussed / built workaroudns

  • default discord avatar
    Jesse Sivonen
    last month

    How about just limiting access by the API token using Payload's access control mechanism?


    https://payloadcms.com/docs/access-control/overview


    You could create a plugin that attaches that access control to all collections and globals.



    The access control function could be just coded in a way that it checks the headers of the request if it matches one of the registered API clients

  • default discord avatar
    iamlinkus
    last month

    that's exactly what I'm trying out right now

  • default discord avatar
    Jesse Sivonen
    last month

    Yeah, sorry if I it already came up somewhere in the conversation. I couldn't read it all ๐Ÿ˜…

  • default discord avatar
    iamlinkus
    last month

    Yeah no, it just came up when @thisisnotchris found a community help archive thread (thank you @thisisnotchris โค๏ธ) but thanks for your input too, @Jesse Sivonen !

  • default discord avatar
    Jesse Sivonen
    last month

    Remember to mark this thread as answered

  • default discord avatar
    iamlinkus
    last month

    Yep, once I'll have this working I will!



    YES, it works perfectly!!



    Thank you @thisisnotchris & @Jesse Sivonen !



    For anyone finding this thread, here's my solution (pitch in anyone if you would do it differently):



    I have an access control function

    isFrontEndOrAdmin

    which looks like this:



    import { Access } from "payload/types";
    
    export const isFrontEnd: Access = ({ req }) => {
      // Check if the request has an api-key header and compare it to the preset variable
      // OR if a user is logged in (for them to be able to see it in the admin panel
      if (req?.headers["api-key"] === process.env.PAYLOAD_API_KEY || req?.user) {
        return true;
      }
      // Reject everyone else
      return false;
    };


    And in my global I use the

    access

    option like this:



      access: {
        read: isFrontEnd,
      },


    This will allow to fetch the data through the front-end using an api-key header, but will prevent anyone without the api key to see the data (including anyone going directly to the api endpoint url). For it to be safer, you should only fetch with this api key header from the server (server components), because fetching on the client will expose the api key to anyone able to use dev tools ๐Ÿ˜„

  • default discord avatar
    thisisnotchris
    last month

    Hey all back from lunch!



    So glad you figured it out ๐Ÿ˜„

  • discord user avatar
    Jarrod
    Payload Team
    last month

    super late to the party



    good answers, api key sounded like what you were needing

  • default discord avatar
    thisisnotchris
    last month

    @iamlinkus Just for good measure



    Is your comparison logic safe enough?



    Please someone else correct me but I can imagine this scenario (logs true)



      
      const req = {
          headers: {
            "api-key": false
        }
      }
      
      const process = {
          env: {
            PAYLOAD_API_KEY: false
        }
      }
      
       if (req?.headers["api-key"] === process.env.PAYLOAD_API_KEY) {
           console.log(true)
     } else {
    console.log(false)
     }


    your comparison logic is



     if (req?.headers["api-key"] === process.env.PAYLOAD_API_KEY || req?.user) {
        return true;
      }


    if the api-key is false and env doesnt get picked up for some reason



    Isn't it possible for it to return true?



    Just a thought

  • default discord avatar
    iamlinkus
    last month

    hmm, good catch, Iโ€™ll have to look into this when Iโ€™m on my mac tomorrow



    thanks @thisisnotchris

Open the post
Continue the discussion in Discord
Like what we're doing?
Star us on GitHub!

Star

Connect with the Payload Community on Discord

Discord

online

Can't find what you're looking for?

Get help straight from the Payload team with an Enterprise License.